release-2.9.0 (#1532)

* fix(macos): skip single-instance lock in Mac App Store builds (#1454)

fix(macos): skip single-instance lock in Mac App Store builds

* Fix/backend optional sharp (#1455)

Fix/backend optional sharp

* feat: issue #1218 (#1218)

https://github.com/Termix-SSH/Support/issues/1218

* feat: Add option to use saved global custom theme under Connection Defaults (#1209)

https://github.com/Termix-SSH/Support/issues/1209

* feat: Add Fleet Sharing functionality to the UI (#1228)

https://github.com/Termix-SSH/Support/issues/1228

* feat: Add a list view to the Docker management page (#1237)

https://github.com/Termix-SSH/Support/issues/1237

* feat: issue #1264 (#1264)

https://github.com/Termix-SSH/Support/issues/1264

* feat: 2FA With FortiToken (#1288)

https://github.com/Termix-SSH/Support/issues/1288

* feat: Allow to disable showing paths to folders (#1274)

https://github.com/Termix-SSH/Support/issues/1274

* feat: Sync Network Graph between Desktop and Remote Server (#1245)

https://github.com/Termix-SSH/Support/issues/1245

* feat: Version Number on offline servers (#1291)

https://github.com/Termix-SSH/Support/issues/1291

* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)

https://github.com/Termix-SSH/Support/issues/1302

* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)

https://github.com/Termix-SSH/Support/issues/1289

* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)

https://github.com/Termix-SSH/Support/issues/1303

* fix: I can't send a file on its own. (#1304)

https://github.com/Termix-SSH/Support/issues/1304

* fix: sudo password not autofilling (#1248)

https://github.com/Termix-SSH/Support/issues/1248

* fix: node-MainThread (#1300)

https://github.com/Termix-SSH/Support/issues/1300

* fix: Tunnel authentication with shared credentials (#1295)

https://github.com/Termix-SSH/Support/issues/1295

* fix: host key updates not syncing, metrics 404 race on first connect

Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.

* chore: increment ver

* feat: add category and icon fields to plugin manifest schema

* ci(deps): bump the github-actions group with 2 updates (#1458)

Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)

Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)

Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.

* feat: add allowlisted host view for plugins

An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.

* feat: add plugin loader with manifest validation and crash restart

Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.

* feat: route internal events through a shared plugin event bus

Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.

* feat: add plugin ctx broker with permission gate and audit

Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.

* feat: dispatch plugin HTTP routes and wire the runtime into startup

ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.

* fix: update stale SFTP transfer test for single file destination paths

The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.

* feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.

* feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.

* feat: ship host metrics as a first-party in-process plugin

* fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.

* feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.

* fix: remove compiled plugin JS left behind in plugins/ after build

copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.

* feat: add a permission-gated backend service registry for plugins

* feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.

* feat: add cross-plugin secret references gated by the provider's RBAC permission

* feat: move proxmox discovery/import/sync into a first-party plugin

* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.

* feat: wire remote-desktop plugin into core and rename i18n keys

Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.

* feat: move fleets into a first-party plugin

* feat: move automations into a first-party plugin

* feat: remove legacy alert rules, superseded by automations

Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.

* feat: move network topology into a first-party plugin

* feat: move tailscale device discovery and settings into a plugin

Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.

* feat: move workspaces into a first-party plugin

* feat: move web endpoint into a first-party plugin

* feat: collapse plugin runtime to one tier behind a plugin SDK

* feat: make each plugin an npm workspace built by a termix-plugin CLI

* feat: give plugins their own tables, migrations and sync entities

* feat: serve plugin http and websockets from one mount instead of ports

* feat: namespace plugin permissions and keep them valid when a plugin is off

* feat: give plugins their own settings and one settings screen

* feat: load plugin frontends at runtime and remove plugin ids from the shell

* fix: mock the ai status fetch in its activate test

* feat: add auth extension points and one ssh connect pipeline

* feat: rebuild workspaces as the reference plugin

* fix: gate second factors after SSO/LDAP behind an admin setting

* feat: convert network-topology to a full plugin (B1)

* feat: move snippets backend and tables into a plugin (B2)

* feat: point core frontend snippets pickers at the plugin (B2)

* feat: add the snippets plugin (B2)

* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

* feat: finish the tailscale plugin, including SSH auth (B3)

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* chore: update proxmox and host-metrics locale files for B5

* chore: regenerate drizzle migration journals for B5

* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

* feat: move proxmox node stats and collectors into the proxmox plugin

* feat: drop proxmox host columns, use plugin settings instead

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* feat: convert file manager into a plugin

* feat: convert ssh tunnels into a plugin

* feat: finish the web-endpoint plugin, add ctx.desktop (B8)

* feat: move the terminal fully into the ssh-terminal plugin (B9)

* feat: convert tmux monitoring into a plugin (B10)

* feat: convert serial console into the serial plugin

* feat: move session sharing and collab rooms into a plugin (B12)

* feat: turn session recording into a plugin (B13)

* feat: finish the remote-desktop plugin (B14)

* feat: finish the host-metrics plugin, move host status into core (B16)

* feat: finish the docker plugin (B15)

* feat: finish the automations plugin (B17)

* feat: finish the ai plugin (B18)

* feat: finish the homepage plugin, move service links and canvas out of core (B19)

* feat: convert wake-on-lan into a plugin (B20)

* feat: convert secret-sources into a plugin (B20)

* feat: move totp and passkeys into plugins (C1)

* feat: move sso and ldap logins into plugins (C2)

* feat: move opkssh and warpgate into plugins (C3)

* feat: move step-ca into a plugin (C4)

* feat: move vault ssh signing into a plugin (C5)

* feat: move termix identity into a plugin (C6)

* feat: move acme certificate automation into a plugin (C7)

* fix: finish 2.9.0 plugin moves and get every check green

* chore: lock the plugin boundary and remove 2.9.0 leftovers

* fix: harden the plugin runtime after the 2.9.0 security review

* feat: add plugin packing, signing, pinned trust and tmxplug bundling

* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

* fix: give bundled plugin backends a real require for CJS deps

* feat: improve UI loading time and jitter

* feat: show credential sharing inline in the sidebar like host sharing

* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

* feat: unify alerts into one plugin, channels, rules and inbox

* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

* fix: set blacksmith builder cache-key to stop broken gha cache fallback

* fix: link plugin-sdk workspace before npm ci in docker deps stage

* fix: link every plugin workspace before npm ci in docker deps stage

* fix(host-metrics): exclude image and firmware filesystems (#1469)

* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(hosts): normalize imported jump host references (#1470)

* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent (#1476)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent

* fix(dashboard): reuse translated host status labels (#1475)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels

* docs(api): clarify local and remote version fields (#1474)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields

* fix(file-manager): preserve selected backgrounds on hover (#1473)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown

* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F

* fix: keep rounded-full circular in production builds

* fix(file-manager): cancel uploads and stop remote writes (#1477)

* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): apply compact density to directory tree (#1478)

* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): preserve font sizes in compact mode (#1480)

* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): prioritize file names in narrow lists (#1483)

* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(remote-desktop): probe the saved host guacd endpoint (#1481)

* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* fix(homepage): check allowlisted private service reachability (#1479)

* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain

* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)

* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* chore: simplify plugin readmes, changelogs and manifest descriptions

* fix: keep plugins loaded after signing in from the login screen

* fix(file-manager): accept OS file drops over existing rows (#1484)

* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(file-manager): pipeline SFTP download reads (#1485)

* feat: add plugin sdk build to beta release

* fix: make link-store secret check immune to random base64 matches

* fix(ldap): preserve the directory-provided bind DN (#1486)

* fix: compute beta changes across dev branches and move the beta tag

* chore: untrack plugin sdk ARCHITECTURE.md

* chore: remove references to plugin sdk ARCHITECTURE.md

* feat: merge host editor terminal tabs into one owned by ssh-terminal

* feat: add descriptions to plugin host enable toggles

* fix(file-manager): show download progress in file viewers (#1487)

* fix(electron): signal backend readiness over IPC (#1488)

* fix(split-screen): allow layout creation without randomUUID (#1490)

* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation

* fix(sidebar): remeasure visible rows after density changes (#1489)

* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation

* feat(split-screen): redesign split screen with free-form panes and pane picker

* feat(sidebar): add host click behavior setting, default to always opening a new tab

* feat(snippets): redesign panel to match the host list

* fix(session-recording): stop session logs panel from refetching in a loop

* feat(snippets): bring back sharing, target hosts and drag reorder

* feat(ssh-terminal): add toolbar position, start state, display and fade host settings

* feat(ssh-terminal): add host setting to turn off password prompt auto-fill

* fix(ai): stop streams getting cut off and keep tool steps across messages

* feat: improve quick connect system using plugin api

* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

* feat: move terminal settings, look and ssh tools into ssh-terminal plugin

* feat: move snippets, keybinding actions and macros out of core into plugins

* feat: store rdp, vnc and telnet logins in a generic per-protocol table

* feat: type the plugin host record, move plugin strings out of core and add lint checks

* feat: replace core homepage widget registry with generic extension points

* feat: plugins only see their own host settings in the browser

* feat: harden plugin api for separate repos, signed updates and generic external login

* fix: keep plugin css below core utilities and version plugin assets by their css

* fix: load and save plugin host settings in the editor and tighten settings row spacing

* feat: redesign user host defaults system

* fix(remote-desktop): release held keys on focus loss (#1498)

* fix(session-sharing): replace local presentation after takeover (#1500)

* fix(file-manager): refresh disk usage with directory listing (#1503)

* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)

* feat(remote-desktop): expose display zoom controls for RDP (#1514)

* fix(file-manager): restore text selection and copying in Markdown previews (#1518)

* fix(sidebar): keep compact host addresses visible (#1524)

* fix(hosts): preserve plugin settings when reopening the editor (#1499)

* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)

* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

* fix(session-sharing): show participant presence in member terminals (#1504)

* fix(ai): sync provider configuration and keys across linked devices (#1506)

* feat(ssh-terminal): add a duplicate tab action (#1510)

* feat(file-manager): allow disabling move-to-trash confirmation (#1511)

* feat(file-manager): default to the remote login directory (#1512)

* feat(homepage): synchronize canvas layouts across devices (#1515)

* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)

* feat(tmux-monitor): add a unified multi-host view (#1520)

* fix(electron): allow explicit quit with active connections (#1522)

* feat: reconnect all disconnected terminal tabs (#1523)

* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)

* fix(database): preserve RDP domain during startup backfill (#1497)

* feat(sidebar): add keyboard type-ahead search for hosts (#1516)

* feat(automations): add per-host maintenance mode and recurring schedules (#1521)

* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)

* fix(session-sharing): use the linked server for desktop meetings and links (#1507)

* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)

* fix: type errors and lint failures from merged plugin prs

* feat(installer): make the Windows desktop shortcut optional (#1517)

* fix: ship the plugin sdk and serialport in desktop builds

* fix: prefer ipv4 when resolving ssh host names (#1365)

* fix: readable terminal selection with the light theme (#1319)

* fix: build desktop session share links on the linked server (#1350)

* fix: show meeting guests to members and hide the roster from guests (#1353)

* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)

* fix: show disk usage for the mount being browsed (#1347)

* fix: load the linux tray icon reliably and log tray failures (#1332)

* fix: cover the gnome close path in the desktop quit test (#1332)

* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)

* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

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

* chore: skip crowdin pre-translate in release for now

* chore: sync Crowdin translations for 2.9.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Changelog
## 1.0.0
First release, bundled with Termix 2.9.0.
Lets you browse devices on your tailnet and pick one when adding a host. Also shows the Tailscale SSH sign-in check when a host asks for it.
+30
View File
@@ -0,0 +1,30 @@
# Tailscale
Browse and connect to devices on your tailnet, and handle Tailscale SSH sign-in checks.
## Features
- Browse the devices on your Tailscale or Headscale tailnet.
- Pick a device when adding a host to fill in its address.
- Shows the Tailscale SSH sign-in check when a host asks for it.
- Adds a Tailscale tool to Host Metrics.
## Settings
### Admin
- API key: a Tailscale or Headscale API key for your tailnet.
- API base URL: leave empty for Tailscale, or point it at a Headscale instance.
- Device list: check that the key works and see how many devices are reachable.
## Permissions
- `tailscale.devices.view`: See the devices on the tailnet. Only admins have it by default.
## Development
```bash
npm run build # build into dist/
npm run test # run this plugin's tests
npm run typecheck # type-check this plugin
```
+86
View File
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Select Tailscale device",
"tailscaleDeviceSelectPlaceholder": "Select a device...",
"tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.",
"tailscaleDocsLink": "View docs",
"tailscaleLoadingDevices": "Loading devices...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "No devices found in your tailnet.",
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Open Browser to Authenticate",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Refresh",
"retry": "Retry",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "No data available",
"filter": "Filter...",
"tsNotInstalled": "Tailscale is not installed on this host",
"tsRunning": "Running",
"tsStopped": "Stopped",
"tsEnable": "Connect",
"tsDisable": "Disconnect",
"tsEnabling": "Connecting to Tailscale...",
"tsDisabling": "Disconnecting from Tailscale...",
"tsEnabled": "Tailscale connected",
"tsDisabled": "Tailscale disconnected",
"tsExitNode": "Exit node",
"tsExitNodeActive": "Exit node active"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connect to Terminal",
"usernameLabel": "Username"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Bekyk tailnet-toestelle",
"description": "Sien die toestelle op die gekoppelde stertnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Stertnet"
},
"apiKey": {
"label": "API-sleutel",
"description": "Word gebruik om die toestelle op jou tailnet te lys. Werk met 'n Tailscale- of Headscale-sleutel.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API-basis-URL",
"description": "Los leeg vir Stertskaal. Stel dit om na 'n Hoofskaal-instansie te wys.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Toestellys",
"checking": "Kontroleer die stertnet...",
"noKey": "Geen API-sleutel nog gestel nie.",
"error": "Kon nie die tailnet bereik nie. Kontroleer die API-sleutel.",
"count": "{{count}} toestel bereikbaar",
"count_other": "{{count}} toestelle bereikbaar",
"refresh": "Kontroleer"
}
},
"nav": {
"tailscale": "Stertskaal"
},
"hosts": {
"tailscaleUsernameHint": "Dit moet 'n Unix-gebruiker wees aan wie jou Tailscale-identiteit in die tailnet se SSH ACL toegestaan word, nie noodwendig root nie.",
"tailscaleDeviceSelect": "Kies Tailscale-toestel",
"tailscaleDeviceSelectPlaceholder": "Kies 'n toestel...",
"tailscaleNoApiKey": "Geen Tailscale API-sleutel gekonfigureer nie. Voeg een by in Admin-instellings om toestelontdekking te aktiveer.",
"tailscaleDocsLink": "Bekyk dokumente",
"tailscaleLoadingDevices": "Laai toestelle...",
"tailscaleDeviceLoadFailed": "Kon nie Tailscale-toestelle laai nie. Kontroleer die API-URL en netwerkverbinding.",
"tailscaleNoDevices": "Geen toestelle in jou tailnet gevind nie.",
"tailscaleDeviceAutoFill": "As jy 'n toestel kies, sal die gasheer-IP-adres outomaties ingevul word.",
"tailscaleAddHost": "Voeg gasheer by",
"tailscaleCopyIp": "Kopieer Tailskaal IP",
"tailscaleIpCopied": "IP gekopieer",
"tailscaleIpCopyFailed": "Kon nie die IP-adres kopieer nie",
"filterAuthTailscale": "Stertskaal"
},
"terminal": {
"tailscaleCheckRequired": "Stertskaal-verifikasie vereis",
"tailscaleCheckDescription": "Tailscale SSH vereis 'n bykomende kontrole. Verifieer in jou blaaier om voort te gaan.",
"tailscaleCheckOpenBrowser": "Maak blaaier oop om te verifieer",
"tailscaleCheckWaiting": "Wag vir Tailscale-verifikasie...",
"tailscaleCheckTimeout": "Tailscale-verifikasie het verstryk. Probeer asseblief weer."
},
"manager": {
"title": "Stertskaal",
"refresh": "Verfris",
"retry": "Probeer weer",
"sudoHint": "Stel 'n sudo-wagwoord vir hierdie gasheer om verhoogde aksies toe te laat.",
"noData": "Geen data beskikbaar nie",
"filter": "Filter...",
"tsNotInstalled": "Tailscale is nie op hierdie gasheer geïnstalleer nie",
"tsRunning": "Hardloop",
"tsStopped": "Gestop",
"tsEnable": "Verbind",
"tsDisable": "Ontkoppel",
"tsEnabling": "Verbind met Tailscale...",
"tsDisabling": "Ontkoppel tans van Tailscale...",
"tsEnabled": "Stertskaal gekoppel",
"tsDisabled": "Stertskaal ontkoppel",
"tsExitNode": "Uitgangsknoop",
"tsExitNodeActive": "Uitgangsknoop aktief"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Koppel aan terminaal",
"usernameLabel": "Gebruikersnaam"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "عرض أجهزة Tailnet",
"description": "اطلع على الأجهزة الموجودة على شبكة الاتصال اللاسلكية."
}
},
"settings": {
"group": {
"api": "واجهة برمجة تطبيقات Tailscale",
"devices": "شبكة الذيل"
},
"apiKey": {
"label": "مفتاح API",
"description": "يُستخدم لعرض قائمة الأجهزة الموجودة على شبكتك المحلية. يعمل مع مفتاح Tailscale أو Headscale.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "عنوان URL الأساسي لواجهة برمجة التطبيقات",
"description": "اترك حقل \"مقياس الذيل\" فارغًا. اضبط هذا الحقل ليشير إلى مثيل \"مقياس الرأس\".",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "قائمة الأجهزة",
"checking": "فحص شبكة الذيل...",
"noKey": "لم يتم تعيين مفتاح API بعد.",
"error": "تعذر الوصول إلى شبكة الوصول. يرجى التحقق من مفتاح واجهة برمجة التطبيقات (API).",
"count": "{{count}} الجهاز قابل للوصول",
"count_other": "{{count}} الأجهزة التي يمكن الوصول إليها",
"refresh": "يفحص"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "يجب أن يكون هذا مستخدمًا لنظام Unix، حيث يتم منح هوية Tailscale الخاصة بك في قائمة التحكم بالوصول SSH الخاصة بشبكة tailnet، وليس بالضرورة المستخدم الجذر.",
"tailscaleDeviceSelect": "اختيار جهاز Tailscale",
"tailscaleDeviceSelectPlaceholder": "اختر جهازًا...",
"tailscaleNoApiKey": "لم يتم تكوين مفتاح API لـ Tailscale. أضف واحدًا في إعدادات المسؤول لتمكين اكتشاف الأجهزة.",
"tailscaleDocsLink": "عرض المستندات",
"tailscaleLoadingDevices": "جارٍ تحميل الأجهزة...",
"tailscaleDeviceLoadFailed": "فشل تحميل أجهزة Tailscale. يرجى التحقق من عنوان URL الخاص بواجهة برمجة التطبيقات (API) واتصال الشبكة.",
"tailscaleNoDevices": "لم يتم العثور على أجهزة في tailnet الخاص بك.",
"tailscaleDeviceAutoFill": "سيؤدي تحديد جهاز إلى تعبئة عنوان IP للمضيف تلقائيًا.",
"tailscaleAddHost": "إضافة مضيف",
"tailscaleCopyIp": "نسخ عنوان IP الخاص بـ Tailscale",
"tailscaleIpCopied": "تم نسخ عنوان IP",
"tailscaleIpCopyFailed": "تعذر نسخ عنوان IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "يلزم التحقق من الهوية على مستوى الذيل",
"tailscaleCheckDescription": "يتطلب Tailscale SSH إجراء فحص إضافي. قم بتسجيل الدخول إلى متصفحك للمتابعة.",
"tailscaleCheckOpenBrowser": "افتح المتصفح للمصادقة",
"tailscaleCheckWaiting": "في انتظار مصادقة Tailscale...",
"tailscaleCheckTimeout": "انتهت مهلة مصادقة Tailscale. يرجى المحاولة مرة أخرى."
},
"manager": {
"title": "Tailscale",
"refresh": "تحديث",
"retry": "إعادة المحاولة",
"sudoHint": "قم بتعيين كلمة مرور sudo لهذا المضيف للسماح بإجراءات ذات صلاحيات إدارية.",
"noData": "لا توجد بيانات",
"filter": "تصفية...",
"tsNotInstalled": "Tailscale غير مثبت على هذا المضيف",
"tsRunning": "قيد التشغيل",
"tsStopped": "متوقف",
"tsEnable": "اتصال",
"tsDisable": "افصل",
"tsEnabling": "جارٍ الاتصال بـ Tailscale...",
"tsDisabling": "جارٍ قطع الاتصال بـ Tailscale...",
"tsEnabled": "Tailscale متصل",
"tsDisabled": "Tailscale غير متصل",
"tsExitNode": "عقدة خروج",
"tsExitNodeActive": "عقدة خروج نشطة"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "اتصل بالطرفية",
"usernameLabel": "اسم المستخدم"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Вижте устройствата за мрежата на задната част",
"description": "Вижте устройствата в свързаната мрежа за пренос на данни (tailnet)."
}
},
"settings": {
"group": {
"api": "API за Tailscale",
"devices": "Опашка"
},
"apiKey": {
"label": "API ключ",
"description": "Използва се за изброяване на устройствата във вашата tailnet мрежа. Работи с ключ Tailscale или Headscale.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "Базов URL адрес на API",
"description": "Оставете празно за Tailscale. Задайте това да сочи към екземпляр на Headscale.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Списък с устройства",
"checking": "Проверка на мрежата на опашката...",
"noKey": "Все още не е зададен API ключ.",
"error": "Не можа да се достигне tailnet. Проверете API ключа.",
"count": "{{count}} устройство е достъпно",
"count_other": "{{count}} устройства са достъпни",
"refresh": "Проверете"
}
},
"nav": {
"tailscale": "Задна скала"
},
"hosts": {
"tailscaleUsernameHint": "Това трябва да е Unix потребител, чиято самоличност на Tailscale е предоставена в SSH ACL на tailnet, не е задължително root.",
"tailscaleDeviceSelect": "Изберете устройство за Tailscale",
"tailscaleDeviceSelectPlaceholder": "Изберете устройство...",
"tailscaleNoApiKey": "Няма конфигуриран ключ за Tailscale API. Добавете такъв в настройките на администратора, за да активирате откриването на устройства.",
"tailscaleDocsLink": "Преглед на документи",
"tailscaleLoadingDevices": "Зареждане на устройства...",
"tailscaleDeviceLoadFailed": "Зареждането на устройствата Tailscale не бе успешно. Проверете URL адреса на API и мрежовата връзка.",
"tailscaleNoDevices": "Няма намерени устройства във вашата мрежа за контакт.",
"tailscaleDeviceAutoFill": "Избирането на устройство ще попълни автоматично IP адреса на хоста.",
"tailscaleAddHost": "Добавяне на хост",
"tailscaleCopyIp": "Копиране на IP адрес на Tailscale",
"tailscaleIpCopied": "IP адресът е копиран",
"tailscaleIpCopyFailed": "Не можа да се копира IP адресът",
"filterAuthTailscale": "Задна скала"
},
"terminal": {
"tailscaleCheckRequired": "Изисква се удостоверяване на Tailscale",
"tailscaleCheckDescription": "Tailscale SSH изисква допълнителна проверка. Удостоверете се в браузъра си, за да продължите.",
"tailscaleCheckOpenBrowser": "Отворете браузъра за удостоверяване",
"tailscaleCheckWaiting": "Чака се удостоверяване на Tailscale...",
"tailscaleCheckTimeout": "Времето за изчакване на удостоверяването на Tailscale изтече. Моля, опитайте отново."
},
"manager": {
"title": "Задна скала",
"refresh": "Обновяване",
"retry": "Опитай отново",
"sudoHint": "Задайте парола за sudo за този хост, за да разрешите действия с повишени права.",
"noData": "Няма налични данни",
"filter": "Филтър...",
"tsNotInstalled": "Tailscale не е инсталиран на този хост",
"tsRunning": "Бягане",
"tsStopped": "Спряно",
"tsEnable": "Свържете се",
"tsDisable": "Изключване",
"tsEnabling": "Свързване с Tailscale...",
"tsDisabling": "Изключване от Tailscale...",
"tsEnabled": "Свързана опашка",
"tsDisabled": "Задната скала е изключена",
"tsExitNode": "Изходен възел",
"tsExitNodeActive": "Изходният възел е активен"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Свързване с терминала",
"usernameLabel": "Потребителско име"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Select Tailscale device",
"tailscaleDeviceSelectPlaceholder": "Select a device...",
"tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.",
"tailscaleDocsLink": "View docs",
"tailscaleLoadingDevices": "Loading devices...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "No devices found in your tailnet.",
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Open Browser to Authenticate",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Refresh",
"retry": "Retry",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "No data available",
"filter": "Filter...",
"tsNotInstalled": "Tailscale is not installed on this host",
"tsRunning": "Running",
"tsStopped": "Stopped",
"tsEnable": "Connect",
"tsDisable": "Disconnect",
"tsEnabling": "Connecting to Tailscale...",
"tsDisabling": "Disconnecting from Tailscale...",
"tsEnabled": "Tailscale connected",
"tsDisabled": "Tailscale disconnected",
"tsExitNode": "Exit node",
"tsExitNodeActive": "Exit node active"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connect to Terminal",
"usernameLabel": "Username"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Veure dispositius de xarxa de cua",
"description": "Veureu els dispositius de la xarxa de cua connectada."
}
},
"settings": {
"group": {
"api": "API de tailscal",
"devices": "Xarxa de cua"
},
"apiKey": {
"label": "Clau API",
"description": "S'utilitza per llistar els dispositius de la xarxa de tailnet. Funciona amb una clau Tailscale o Headscale.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "URL base de l'API",
"description": "Deixeu-ho en blanc per a Tailscale. Defineu-ho perquè apunti a una instància de Headscale.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Llista de dispositius",
"checking": "Revisant la xarxa de cua...",
"noKey": "Encara no s'ha definit cap clau API.",
"error": "No s'ha pogut accedir a la xarxa de cua. Comproveu la clau API.",
"count": "dispositiu {{count}} accessible",
"count_other": "dispositius accessibles {{count}}",
"refresh": "Comprovar"
}
},
"nav": {
"tailscale": "Escala de cua"
},
"hosts": {
"tailscaleUsernameHint": "Aquest ha de ser un usuari d'Unix; la teva identitat Tailscale està atorgada a l'ACL SSH de la xarxa principal, no necessàriament un usuari root.",
"tailscaleDeviceSelect": "Selecciona el dispositiu Tailscale",
"tailscaleDeviceSelectPlaceholder": "Selecciona un dispositiu...",
"tailscaleNoApiKey": "No hi ha cap clau d'API de Tailscale configurada. Afegeix-ne una a la configuració de l'administrador per habilitar la detecció de dispositius.",
"tailscaleDocsLink": "Veure documents",
"tailscaleLoadingDevices": "S'estan carregant els dispositius...",
"tailscaleDeviceLoadFailed": "No s'han pogut carregar els dispositius Tailscale. Comproveu l'URL de l'API i la connexió de xarxa.",
"tailscaleNoDevices": "No s'han trobat dispositius a la xarxa de cua.",
"tailscaleDeviceAutoFill": "En seleccionar un dispositiu, l'adreça IP de l'amfitrió s'omplirà automàticament.",
"tailscaleAddHost": "Afegeix un amfitrió",
"tailscaleCopyIp": "Copia IP de tailscal",
"tailscaleIpCopied": "IP copiada",
"tailscaleIpCopyFailed": "No s'ha pogut copiar la IP",
"filterAuthTailscale": "Escala de cua"
},
"terminal": {
"tailscaleCheckRequired": "Autenticació a escala final requerida",
"tailscaleCheckDescription": "L'SSH a escala de punta requereix una comprovació addicional. Autentiqueu-vos al navegador per continuar.",
"tailscaleCheckOpenBrowser": "Obre el navegador per autenticar-te",
"tailscaleCheckWaiting": "Esperant l'autenticació de Tailscale...",
"tailscaleCheckTimeout": "S'ha esgotat el temps d'espera de l'autenticació a escala final. Torna-ho a provar."
},
"manager": {
"title": "Escala de cua",
"refresh": "Actualitza",
"retry": "Torna-ho a intentar",
"sudoHint": "Estableix una contrasenya sudo per a aquest host per permetre accions amb privilegis elevats.",
"noData": "No hi ha dades disponibles",
"filter": "Filtra...",
"tsNotInstalled": "Tailscale no està instal·lat en aquest amfitrió",
"tsRunning": "Córrer",
"tsStopped": "Aturat",
"tsEnable": "Connecta",
"tsDisable": "Desconnecta",
"tsEnabling": "Connectant a Tailscale...",
"tsDisabling": "Desconnectant de Tailscale...",
"tsEnabled": "Escala de cua connectada",
"tsDisabled": "Escala de cua desconnectada",
"tsExitNode": "Node de sortida",
"tsExitNodeActive": "Node de sortida actiu"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connecta't al terminal",
"usernameLabel": "Nom d'usuari"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Zobrazit zařízení pro zadní část vozu",
"description": "Podívejte se na zařízení v připojené koncové síti (tailnet)."
}
},
"settings": {
"group": {
"api": "API pro koncové systémy",
"devices": "Tailnet"
},
"apiKey": {
"label": "Klíč API",
"description": "Používá se k zobrazení seznamu zařízení v síti Tailnet. Funguje s klíčem Tailscale nebo Headscale.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "Základní URL API",
"description": "Pro Tailscale nechte prázdné. Nastavte tak, aby ukazovalo na instanci Headscale.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Seznam zařízení",
"checking": "Kontrola zadní sítě...",
"noKey": "Zatím nebyl nastaven žádný klíč API.",
"error": "Nepodařilo se dosáhnout sítě Tailnet. Zkontrolujte klíč API.",
"count": "{{count}} zařízení je dosažitelné",
"count_other": "{{count}} zařízení dosažitelných",
"refresh": "Kontrola"
}
},
"nav": {
"tailscale": "Koncová stupnice"
},
"hosts": {
"tailscaleUsernameHint": "Musí se jednat o uživatele Unixu, kterému je vaše identita Tailscale udělena v SSH ACL tailnetu, nikoli nutně o uživatele root.",
"tailscaleDeviceSelect": "Vyberte zařízení Tailscale",
"tailscaleDeviceSelectPlaceholder": "Vyberte zařízení...",
"tailscaleNoApiKey": "Není nakonfigurován žádný klíč Tailscale API. Přidejte jej v nastavení správce, abyste povolili vyhledávání zařízení.",
"tailscaleDocsLink": "Zobrazit dokumenty",
"tailscaleLoadingDevices": "Načítání zařízení...",
"tailscaleDeviceLoadFailed": "Nepodařilo se načíst zařízení Tailscale. Zkontrolujte URL API a síťové připojení.",
"tailscaleNoDevices": "Ve vaší koncové síti nebyla nalezena žádná zařízení.",
"tailscaleDeviceAutoFill": "Výběrem zařízení se automaticky vyplní IP adresa hostitele.",
"tailscaleAddHost": "Přidat hostitele",
"tailscaleCopyIp": "Kopírovat IP adresu Tailscale",
"tailscaleIpCopied": "IP adresa zkopírována",
"tailscaleIpCopyFailed": "Nepodařilo se zkopírovat IP adresu",
"filterAuthTailscale": "Koncová stupnice"
},
"terminal": {
"tailscaleCheckRequired": "Vyžadováno ověřování Tailscale",
"tailscaleCheckDescription": "Tailscale SSH vyžaduje dodatečnou kontrolu. Pro pokračování se ověřte ve svém prohlížeči.",
"tailscaleCheckOpenBrowser": "Otevřít prohlížeč pro ověření",
"tailscaleCheckWaiting": "Čekání na ověření Tailscale...",
"tailscaleCheckTimeout": "Časový limit pro ověřování Tailscale vypršel. Zkuste to prosím znovu."
},
"manager": {
"title": "Koncová stupnice",
"refresh": "Obnovit",
"retry": "Zkusit znovu",
"sudoHint": "Nastavte pro tento hostitel heslo sudo, abyste povolili akce se zvýšenými oprávněními.",
"noData": "Nejsou k dispozici žádná data",
"filter": "Filtr...",
"tsNotInstalled": "Tailscale není na tomto hostiteli nainstalován",
"tsRunning": "Běh",
"tsStopped": "Zastaveno",
"tsEnable": "Připojit",
"tsDisable": "Odpojit",
"tsEnabling": "Připojování k Tailscale...",
"tsDisabling": "Odpojování od Tailscale...",
"tsEnabled": "Koncová váha připojena",
"tsDisabled": "Tailscale odpojeno",
"tsExitNode": "Východový uzel",
"tsExitNodeActive": "Výstupní uzel je aktivní"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Připojení k terminálu",
"usernameLabel": "Uživatelské jméno"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Se tailnet-enheder",
"description": "Se enhederne på det tilsluttede halenet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Halenet"
},
"apiKey": {
"label": "API-nøgle",
"description": "Bruges til at liste enhederne på dit tailnet. Fungerer med en Tailscale- eller Headscale-nøgle.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API-basis-URL",
"description": "Lad være tom for Tailscale. Indstil dette til at pege på en Headscale-instans.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Enhedsliste",
"checking": "Tjekker bagnettet...",
"noKey": "Ingen API-nøgle angivet endnu.",
"error": "Kunne ikke nå tailnet. Tjek API-nøglen.",
"count": "{{count}} enhed tilgængelig",
"count_other": "{{count}} enheder tilgængelige",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Haleskala"
},
"hosts": {
"tailscaleUsernameHint": "Dette skal være en Unix-bruger, som din Tailscale-identitet er tildelt i tailnets SSH ACL, ikke nødvendigvis root.",
"tailscaleDeviceSelect": "Vælg Tailscale-enhed",
"tailscaleDeviceSelectPlaceholder": "Vælg en enhed...",
"tailscaleNoApiKey": "Ingen Tailscale API-nøgle konfigureret. Tilføj en i administratorindstillinger for at aktivere enhedsregistrering.",
"tailscaleDocsLink": "Se dokumenter",
"tailscaleLoadingDevices": "Indlæser enheder...",
"tailscaleDeviceLoadFailed": "Kunne ikke indlæse Tailscale-enheder. Kontroller API-URL'en og netværksforbindelsen.",
"tailscaleNoDevices": "Ingen enheder fundet i dit tailnet.",
"tailscaleDeviceAutoFill": "Hvis du vælger en enhed, udfyldes værtens IP-adresse automatisk.",
"tailscaleAddHost": "Tilføj vært",
"tailscaleCopyIp": "Kopiér Tailscale IP",
"tailscaleIpCopied": "IP kopieret",
"tailscaleIpCopyFailed": "Kunne ikke kopiere IP-adressen",
"filterAuthTailscale": "Haleskala"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale-godkendelse påkrævet",
"tailscaleCheckDescription": "Tailscale SSH kræver en yderligere kontrol. Godkend i din browser for at fortsætte.",
"tailscaleCheckOpenBrowser": "Åbn browseren for at godkende",
"tailscaleCheckWaiting": "Venter på Tailscale-godkendelse...",
"tailscaleCheckTimeout": "Tailscale-godkendelsen er udløbet. Prøv igen."
},
"manager": {
"title": "Haleskala",
"refresh": "Opfriske",
"retry": "Prøv igen",
"sudoHint": "Indstil en sudo-adgangskode til denne vært for at tillade handlinger med administratorrettigheder.",
"noData": "Ingen data tilgængelige",
"filter": "Filter...",
"tsNotInstalled": "Tailscale er ikke installeret på denne vært",
"tsRunning": "Løb",
"tsStopped": "Stoppet",
"tsEnable": "Forbinde",
"tsDisable": "Afbryde",
"tsEnabling": "Forbinder til Tailscale...",
"tsDisabling": "Afbryder forbindelsen til Tailscale...",
"tsEnabled": "Haleskala forbundet",
"tsDisabled": "Haleskalaen er afbrudt",
"tsExitNode": "Udgangsnode",
"tsExitNodeActive": "Afslut node aktiv"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Opret forbindelse til terminalen",
"usernameLabel": "Brugernavn"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Tailnet-Geräte anzeigen",
"description": "Sehen Sie sich die Geräte im verbundenen Netzwerk an."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API-Schlüssel",
"description": "Dient zur Auflistung der Geräte in Ihrem Tailscale-Netzwerk. Funktioniert mit einem Tailscale- oder Headscale-Schlüssel.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API-Basis-URL",
"description": "Lassen Sie das Feld „Tailscale“ leer. Stellen Sie diesen Wert so ein, dass er auf eine „Headscale“-Instanz verweist.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Geräteliste",
"checking": "Überprüfung des Rücknetzes...",
"noKey": "Es wurde noch kein API-Schlüssel festgelegt.",
"error": "Das Tailnet konnte nicht erreicht werden. Bitte überprüfen Sie den API-Schlüssel.",
"count": "{{count}} erreichbares Gerät",
"count_other": "{{count}} erreichbare Geräte",
"refresh": "Überprüfen"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "Dies muss ein Unix-Benutzer sein, dem Ihre Tailscale-Identität in der SSH-ACL des Tailnets gewährt wird, nicht unbedingt root.",
"tailscaleDeviceSelect": "Tailscale-Gerät auswählen",
"tailscaleDeviceSelectPlaceholder": "Gerät auswählen...",
"tailscaleNoApiKey": "Kein Tailscale API-Schlüssel konfiguriert. Fügen Sie einen in den Admin-Einstellungen hinzu, um die Geräteerkennung zu aktivieren.",
"tailscaleDocsLink": "Dokumentation anzeigen",
"tailscaleLoadingDevices": "Geräte werden geladen...",
"tailscaleDeviceLoadFailed": "Die Tailscale-Geräte konnten nicht geladen werden. Bitte überprüfen Sie die API-URL und die Netzwerkverbindung.",
"tailscaleNoDevices": "Keine Geräte in Ihrem Tailnet gefunden.",
"tailscaleDeviceAutoFill": "Die Auswahl eines Geräts füllt die Host-IP-Adresse automatisch aus.",
"tailscaleAddHost": "Host hinzufügen",
"tailscaleCopyIp": "Tailscale IP kopieren",
"tailscaleIpCopied": "IP kopiert",
"tailscaleIpCopyFailed": "Die IP-Adresse konnte nicht kopiert werden.",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale-Authentifizierung erforderlich",
"tailscaleCheckDescription": "Tailscale SSH erfordert eine zusätzliche Überprüfung. Authentifizieren Sie sich in Ihrem Browser, um fortzufahren.",
"tailscaleCheckOpenBrowser": "Browser zum Authentifizieren öffnen",
"tailscaleCheckWaiting": "Warten auf Tailscale-Authentifizierung...",
"tailscaleCheckTimeout": "Die Tailscale-Authentifizierung ist fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"manager": {
"title": "Tailscale",
"refresh": "Aktualisieren",
"retry": "Erneut versuchen",
"sudoHint": "Legen Sie ein sudo-Passwort für diesen Host fest, um erhöhte Aktionen zu ermöglichen.",
"noData": "Keine Daten verfügbar",
"filter": "Filtern...",
"tsNotInstalled": "Tailscale ist auf diesem Host nicht installiert",
"tsRunning": "Läuft",
"tsStopped": "Gestoppt",
"tsEnable": "Verbinden",
"tsDisable": "Trennen",
"tsEnabling": "Verbinde mit Tailscale...",
"tsDisabling": "Trenne von Tailscale...",
"tsEnabled": "Tailscale verbunden",
"tsDisabled": "Tailscale getrennt",
"tsExitNode": "Exit-Knoten",
"tsExitNodeActive": "Exit-Knoten aktiv"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Mit Terminal verbinden",
"usernameLabel": "Benutzername"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Προβολή συσκευών tailnet",
"description": "Δείτε τις συσκευές στο συνδεδεμένο tailnet."
}
},
"settings": {
"group": {
"api": "API κλίμακας ουράς",
"devices": "Tailnet"
},
"apiKey": {
"label": "Κλειδί API",
"description": "Χρησιμοποιείται για την καταχώριση των συσκευών στο tailnet σας. Λειτουργεί με πλήκτρο Tailscale ή Headscale.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "URL βάσης API",
"description": "Αφήστε το κενό για το Tailscale. Ορίστε το ώστε να δείχνει σε μια παρουσία Headscale.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Λίστα συσκευών",
"checking": "Έλεγχος του διχτυού...",
"noKey": "Δεν έχει οριστεί ακόμη κλειδί API.",
"error": "Δεν ήταν δυνατή η επικοινωνία με το tailnet. Ελέγξτε το κλειδί API.",
"count": "{{count}} συσκευή προσβάσιμη",
"count_other": "{{count}} προσβάσιμες συσκευές",
"refresh": "Ελεγχος"
}
},
"nav": {
"tailscale": "Κλίμακα ουράς"
},
"hosts": {
"tailscaleUsernameHint": "Αυτός πρέπει να είναι χρήστης Unix. Η ταυτότητα Tailscale σας έχει εκχωρηθεί στο SSH ACL του tailnet, όχι απαραίτητα root.",
"tailscaleDeviceSelect": "Επιλογή συσκευής κλίμακας ουράς",
"tailscaleDeviceSelectPlaceholder": "Επιλέξτε μια συσκευή...",
"tailscaleNoApiKey": "Δεν έχει ρυθμιστεί κλειδί API Tailscale. Προσθέστε ένα στις Ρυθμίσεις διαχειριστή για να ενεργοποιήσετε την ανακάλυψη συσκευών.",
"tailscaleDocsLink": "Προβολή εγγράφων",
"tailscaleLoadingDevices": "Φόρτωση συσκευών...",
"tailscaleDeviceLoadFailed": "Αποτυχία φόρτωσης συσκευών Tailscale. Ελέγξτε τη διεύθυνση URL του API και τη σύνδεση δικτύου.",
"tailscaleNoDevices": "Δεν βρέθηκαν συσκευές στο tailnet σας.",
"tailscaleDeviceAutoFill": "Η επιλογή μιας συσκευής θα συμπληρώσει αυτόματα τη διεύθυνση IP του κεντρικού υπολογιστή.",
"tailscaleAddHost": "Προσθήκη κεντρικού υπολογιστή",
"tailscaleCopyIp": "Αντιγραφή IP κλίμακας ουράς",
"tailscaleIpCopied": "Η διεύθυνση IP αντιγράφηκε",
"tailscaleIpCopyFailed": "Δεν ήταν δυνατή η αντιγραφή της IP",
"filterAuthTailscale": "Κλίμακα ουράς"
},
"terminal": {
"tailscaleCheckRequired": "Απαιτείται έλεγχος ταυτότητας κλίμακας ουράς",
"tailscaleCheckDescription": "Το Tailscale SSH απαιτεί έναν επιπλέον έλεγχο. Επαληθεύστε τον κωδικό σας στο πρόγραμμα περιήγησής σας για να συνεχίσετε.",
"tailscaleCheckOpenBrowser": "Άνοιγμα προγράμματος περιήγησης για έλεγχο ταυτότητας",
"tailscaleCheckWaiting": "Αναμονή για έλεγχο ταυτότητας Tailscale...",
"tailscaleCheckTimeout": "Το χρονικό όριο ελέγχου ταυτότητας κλίμακας έληξε. Δοκιμάστε ξανά."
},
"manager": {
"title": "Κλίμακα ουράς",
"refresh": "Φρεσκάρω",
"retry": "Δοκιμάζω πάλι",
"sudoHint": "Ορίστε έναν κωδικό πρόσβασης sudo για αυτόν τον κεντρικό υπολογιστή για να επιτρέψετε ενέργειες με αυξημένα δικαιώματα.",
"noData": "Δεν υπάρχουν διαθέσιμα δεδομένα",
"filter": "Φίλτρο...",
"tsNotInstalled": "Το Tailscale δεν είναι εγκατεστημένο σε αυτόν τον κεντρικό υπολογιστή",
"tsRunning": "Τρέξιμο",
"tsStopped": "Σταμάτησε",
"tsEnable": "Συνδέω",
"tsDisable": "Αποσυνδέω",
"tsEnabling": "Σύνδεση με το Tailscale...",
"tsDisabling": "Αποσύνδεση από το Tailscale...",
"tsEnabled": "Συνδεδεμένο με κλίμακα ουράς",
"tsDisabled": "Η κλίμακα ουράς αποσυνδέθηκε",
"tsExitNode": "Κόμβος εξόδου",
"tsExitNodeActive": "Κόμβος εξόδου ενεργός"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Σύνδεση με τερματικό",
"usernameLabel": "Όνομα χρήστη"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Ver dispositivos de red de cola",
"description": "Vea los dispositivos en la red de cola conectada."
}
},
"settings": {
"group": {
"api": "API de Tailscale",
"devices": "Tailnet"
},
"apiKey": {
"label": "clave API",
"description": "Se utiliza para listar los dispositivos en su red de seguimiento. Funciona con una clave Tailscale o Headscale.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "URL base de la API",
"description": "Déjelo vacío para Tailscale. Configure esto para que apunte a una instancia de Headscale.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Lista de dispositivos",
"checking": "Comprobando la red de cola...",
"noKey": "Aún no se ha configurado ninguna clave API.",
"error": "No se pudo acceder a la red de cola. Compruebe la clave API.",
"count": "{{count}} dispositivo alcanzable",
"count_other": "{{count}} dispositivos alcanzables",
"refresh": "Controlar"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "Debe tratarse de un usuario de Unix cuya identidad de Tailscale se concede en la ACL SSH de tailnet, no necesariamente como root.",
"tailscaleDeviceSelect": "Seleccionar dispositivo Tailscale",
"tailscaleDeviceSelectPlaceholder": "Seleccione un dispositivo...",
"tailscaleNoApiKey": "No se ha configurado ninguna clave de API de Tailscale. Añada una en Ajustes de administración para habilitar el descubrimiento de dispositivos.",
"tailscaleDocsLink": "Ver documentación",
"tailscaleLoadingDevices": "Cargando dispositivos...",
"tailscaleDeviceLoadFailed": "No se pudieron cargar los dispositivos Tailscale. Compruebe la URL de la API y la conexión de red.",
"tailscaleNoDevices": "No se han encontrado dispositivos en tu tailnet.",
"tailscaleDeviceAutoFill": "Al seleccionar un dispositivo se rellenará automáticamente la dirección IP del host.",
"tailscaleAddHost": "Agregar host",
"tailscaleCopyIp": "Copiar propiedad intelectual de Tailscale",
"tailscaleIpCopied": "IP copiada",
"tailscaleIpCopyFailed": "No se pudo copiar la IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Se requiere autenticación de Tailscale.",
"tailscaleCheckDescription": "Tailscale SSH requiere una verificación adicional. Autentícate en tu navegador para continuar.",
"tailscaleCheckOpenBrowser": "Abrir navegador para autenticarse",
"tailscaleCheckWaiting": "Esperando la autenticación de Tailscale...",
"tailscaleCheckTimeout": "La autenticación de Tailscale ha caducado. Inténtalo de nuevo."
},
"manager": {
"title": "Tailscale",
"refresh": "Actualizar",
"retry": "Reintentar",
"sudoHint": "Establezca una contraseña de sudo para este host para permitir acciones con privilegios elevados.",
"noData": "Sin datos disponibles",
"filter": "Filtrar...",
"tsNotInstalled": "Tailscale no está instalado en este host",
"tsRunning": "En ejecución",
"tsStopped": "Detenido",
"tsEnable": "Conectar",
"tsDisable": "Desconectar",
"tsEnabling": "Conectando a Tailscale...",
"tsDisabling": "Desconectando de Tailscale...",
"tsEnabled": "Tailscale conectado",
"tsDisabled": "Tailscale desconectado",
"tsExitNode": "Nodo de salida",
"tsExitNodeActive": "Nodo de salida activo"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Conectar a Terminal",
"usernameLabel": "Nombre de usuario"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Näytä häntäverkon laitteet",
"description": "Katso laitteet yhdistetyssä häntäverkossa."
}
},
"settings": {
"group": {
"api": "Tailscale-API",
"devices": "Hännänverkko"
},
"apiKey": {
"label": "API-avain",
"description": "Käytetään laitettasi listaamaan häntäverkossasi. Toimii Tailscale- tai Headscale-näppäimellä.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API-perus-URL",
"description": "Jätä tyhjäksi Tailscale-kohdassa. Aseta tämä osoittamaan Headscale-instanssiin.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Laiteluettelo",
"checking": "Hännänverkon tarkistus...",
"noKey": "API-avainta ei ole vielä asetettu.",
"error": "Hälytysverkkoon ei saatu yhteyttä. Tarkista API-avain.",
"count": "{{count}} laite tavoitettavissa",
"count_other": "{{count}} laitteita tavoitettavissa",
"refresh": "Tarkista"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "Tämän on oltava Unix-käyttäjä, jolle Tailscale-identiteettisi on myönnetty tailnetin SSH ACL:ssä, ei välttämättä pääkäyttäjä.",
"tailscaleDeviceSelect": "Valitse Tailscale-laite",
"tailscaleDeviceSelectPlaceholder": "Valitse laite...",
"tailscaleNoApiKey": "Tailscale API -avainta ei ole määritetty. Lisää sellainen järjestelmänvalvojan asetuksissa, jotta laitteiden löytäminen on mahdollista.",
"tailscaleDocsLink": "Näytä dokumentit",
"tailscaleLoadingDevices": "Laitteita ladataan...",
"tailscaleDeviceLoadFailed": "Tailscale-laitteiden lataus epäonnistui. Tarkista API-URL-osoite ja verkkoyhteys.",
"tailscaleNoDevices": "Hätäverkostasi ei löytynyt laitteita.",
"tailscaleDeviceAutoFill": "Laitteen valinta täyttää isännän IP-osoitteen automaattisesti.",
"tailscaleAddHost": "Lisää isäntä",
"tailscaleCopyIp": "Kopioi Tailscale IP-osoite",
"tailscaleIpCopied": "IP-osoite kopioitu",
"tailscaleIpCopyFailed": "IP-osoitteen kopiointi epäonnistui",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale-todennus vaaditaan",
"tailscaleCheckDescription": "Tailscale SSH vaatii lisätarkistuksen. Jatka tunnistautumalla selaimessasi.",
"tailscaleCheckOpenBrowser": "Avaa selain todennusta varten",
"tailscaleCheckWaiting": "Odotetaan Tailscale-todennusta...",
"tailscaleCheckTimeout": "Tailscale-todennus aikakatkaistiin. Yritä uudelleen."
},
"manager": {
"title": "Tailscale",
"refresh": "Päivitä",
"retry": "Yritä uudelleen",
"sudoHint": "Aseta tälle isännälle sudo-salasana, jotta voit sallia toiminnot laajentuneilla oikeuksilla.",
"noData": "Ei tietoja saatavilla",
"filter": "Suodattaa...",
"tsNotInstalled": "Tailscalea ei ole asennettu tälle isännälle",
"tsRunning": "Juoksu",
"tsStopped": "Pysähtynyt",
"tsEnable": "Yhdistä",
"tsDisable": "Katkaise yhteys",
"tsEnabling": "Yhdistetään Tailscaleen...",
"tsDisabling": "Yhteyden katkaiseminen Tailscaleen...",
"tsEnabled": "Tailscale yhdistetty",
"tsDisabled": "Tailscale irrotettu",
"tsExitNode": "Poistumissolmu",
"tsExitNodeActive": "Poistumissolmu aktiivinen"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Yhdistä terminaaliin",
"usernameLabel": "Käyttäjätunnus"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Afficher les dispositifs tailnet",
"description": "Consultez les appareils connectés au réseau."
}
},
"settings": {
"group": {
"api": "API Tailscale",
"devices": "Tailnet"
},
"apiKey": {
"label": "Clé API",
"description": "Permet de lister les appareils connectés à votre réseau. Fonctionne avec une clé Tailscale ou Headscale.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "URL de base de l'API",
"description": "Laisser vide pour Tailscale. Configurez ce champ pour qu'il pointe vers une instance Headscale.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Liste des appareils",
"checking": "Vérification du filet arrière...",
"noKey": "Aucune clé API n'a encore été définie.",
"error": "Impossible de contacter le serveur de queue. Vérifiez la clé API.",
"count": "{{count}} appareil joignable",
"count_other": "{{count}} appareils joignables",
"refresh": "Vérifier"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "Il doit s'agir d'un utilisateur Unix auquel votre identité Tailscale est attribuée dans la liste de contrôle d'accès SSH du réseau Tailnet, pas nécessairement root.",
"tailscaleDeviceSelect": "Sélectionner un appareil Tailscale",
"tailscaleDeviceSelectPlaceholder": "Sélectionner un appareil...",
"tailscaleNoApiKey": "Aucune clé API Tailscale configurée. Ajoutez-en une dans les paramètres d'administration pour activer la découverte d'appareils.",
"tailscaleDocsLink": "Voir la documentation",
"tailscaleLoadingDevices": "Chargement des appareils...",
"tailscaleDeviceLoadFailed": "Impossible de charger les périphériques Tailscale. Vérifiez l'URL de l'API et votre connexion réseau.",
"tailscaleNoDevices": "Aucun appareil trouvé dans votre tailnet.",
"tailscaleDeviceAutoFill": "La sélection d’un appareil remplira automatiquement l’adresse IP de l’hôte.",
"tailscaleAddHost": "Ajouter l'hôte",
"tailscaleCopyIp": "Copie de l'adresse IP de Tailscale",
"tailscaleIpCopied": "Adresse IP copiée",
"tailscaleIpCopyFailed": "Impossible de copier l'adresse IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Authentification Tailscale requise",
"tailscaleCheckDescription": "Tailscale SSH requiert une vérification supplémentaire. Authentifiez-vous dans votre navigateur pour continuer.",
"tailscaleCheckOpenBrowser": "Ouvrir le navigateur pour s'authentifier",
"tailscaleCheckWaiting": "En attente de l'authentification Tailscale...",
"tailscaleCheckTimeout": "L'authentification Tailscale a expiré. Veuillez réessayer."
},
"manager": {
"title": "Tailscale",
"refresh": "Actualiser",
"retry": "Réessayer",
"sudoHint": "Définissez un mot de passe sudo pour cet hôte afin d'autoriser les actions avec privilèges élevés.",
"noData": "Aucune donnée disponible",
"filter": "Filtrer...",
"tsNotInstalled": "Tailscale n’est pas installé sur cet hôte",
"tsRunning": "En cours d’exécution",
"tsStopped": "Arrêté",
"tsEnable": "Se connecter",
"tsDisable": "Déconnecter",
"tsEnabling": "Connexion à Tailscale...",
"tsDisabling": "Déconnexion de Tailscale...",
"tsEnabled": "Tailscale connecté",
"tsDisabled": "Tailscale déconnecté",
"tsExitNode": "Nœud de sortie",
"tsExitNodeActive": "Nœud de sortie actif"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Se connecter au terminal",
"usernameLabel": "Nom d’utilisateur"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "הצג התקני רשת אחורית",
"description": "ראה את המכשירים ברשת הזנב המחוברת."
}
},
"settings": {
"group": {
"api": "ממשק ה-API של Tailscale",
"devices": "טיילנט"
},
"apiKey": {
"label": "מפתח API",
"description": "משמש לרישום המכשירים ברשת הזנב שלך. עובד עם מפתח Tailscale או Headscale.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "כתובת אתר בסיסית של ה-API",
"description": "השאר ריק עבור Tailscale. הגדר זאת כך שיפנה אל מופע Headscale.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "רשימת המכשירים",
"checking": "בודקים את רשת הזנב...",
"noKey": "עדיין לא הוגדר מפתח API.",
"error": "לא ניתן היה להגיע לרשת הזנב. בדוק את מפתח ה-API.",
"count": "ניתן להגיע למכשיר {{count}}",
"count_other": "{{count}} מכשירים נגישים",
"refresh": "לִבדוֹק"
}
},
"nav": {
"tailscale": "קנה מידה זנב"
},
"hosts": {
"tailscaleUsernameHint": "זה חייב להיות משתמש יוניקס שזהות Tailscale שלך מוענקת לו ב-SSH ACL של tailnet, לאו דווקא root.",
"tailscaleDeviceSelect": "בחר מכשיר Tailscale",
"tailscaleDeviceSelectPlaceholder": "בחר מכשיר...",
"tailscaleNoApiKey": "לא הוגדר מפתח API של Tailscale. הוסף אחד בהגדרות הניהול כדי לאפשר גילוי מכשירים.",
"tailscaleDocsLink": "הצג מסמכים",
"tailscaleLoadingDevices": "טוען מכשירים...",
"tailscaleDeviceLoadFailed": "טעינת התקני Tailscale נכשלה. בדוק את כתובת ה-URL של ה-API ואת חיבור הרשת.",
"tailscaleNoDevices": "לא נמצאו מכשירים ברשת הזנב שלך.",
"tailscaleDeviceAutoFill": "בחירת מכשיר תמלא אוטומטית את כתובת ה-IP של המארח.",
"tailscaleAddHost": "הוסף מארח",
"tailscaleCopyIp": "העתקת כתובת IP של Tailscale",
"tailscaleIpCopied": "כתובת ה-IP הועתקה",
"tailscaleIpCopyFailed": "לא ניתן היה להעתיק את ה-IP",
"filterAuthTailscale": "קנה מידה זנב"
},
"terminal": {
"tailscaleCheckRequired": "נדרש אימות Tailscale",
"tailscaleCheckDescription": "‏SSH של Tailscale דורש בדיקה נוספת. יש לבצע אימות בדפדפן כדי להמשיך.",
"tailscaleCheckOpenBrowser": "פתח את הדפדפן כדי לאמת",
"tailscaleCheckWaiting": "ממתין לאימות Tailscale...",
"tailscaleCheckTimeout": "אימות Tailscale הסתיים. אנא נסה שוב."
},
"manager": {
"title": "קנה מידה זנב",
"refresh": "לְרַעֲנֵן",
"retry": "נסה שוב",
"sudoHint": "הגדר סיסמת sudo עבור מחשב מארח זה כדי לאפשר פעולות מוגברות.",
"noData": "אין נתונים זמינים",
"filter": "לְסַנֵן...",
"tsNotInstalled": "Tailscale אינו מותקן על מחשב אחסון זה",
"tsRunning": "רִיצָה",
"tsStopped": "נֶעצָר",
"tsEnable": "לְחַבֵּר",
"tsDisable": "לְנַתֵק",
"tsEnabling": "מתחבר ל-Tailscale...",
"tsDisabling": "מתנתק מ-Tailscale...",
"tsEnabled": "זנבסקאלה מחוברת",
"tsDisabled": "סולם הזנב מנותק",
"tsExitNode": "צומת יציאה",
"tsExitNodeActive": "צומת יציאה פעיל"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "התחברות לטרמינל",
"usernameLabel": "שם משתמש"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Tailscale डिवाइस चुनें",
"tailscaleDeviceSelectPlaceholder": "एक डिवाइस चुनें...",
"tailscaleNoApiKey": "कोई Tailscale API कुंजी कॉन्फ़िगर नहीं की गई है। डिवाइस खोज सक्षम करने के लिए व्यवस्थापक सेटिंग में एक जोड़ें।",
"tailscaleDocsLink": "दस्तावेज़ देखें",
"tailscaleLoadingDevices": "उपकरण लोड हो रहे हैं...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "आपके टेलनेट में कोई उपकरण नहीं मिला।",
"tailscaleDeviceAutoFill": "डिवाइस चुनने पर होस्ट IP पता स्वतः भर जाएगा।",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "प्रमाणीकरण के लिए ब्राउज़र खोलें",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "रीफ़्रेश",
"retry": "पुनः प्रयास करें",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "कोई डेटा उपलब्ध नहीं",
"filter": "फ़िल्टर...",
"tsNotInstalled": "Tailscale इस होस्ट पर स्थापित नहीं है",
"tsRunning": "चालू",
"tsStopped": "बंद",
"tsEnable": "कनेक्ट",
"tsDisable": "डिस्कनेक्ट करें",
"tsEnabling": "Tailscale से कनेक्ट हो रहा है...",
"tsDisabling": "Tailscale से डिस्कनेक्ट हो रहा है...",
"tsEnabled": "Tailscale कनेक्टेड",
"tsDisabled": "Tailscale डिस्कनेक्टेड",
"tsExitNode": "एक्जिट नोड",
"tsExitNodeActive": "एक्जिट नोड सक्रिय"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "टर्मिनल से कनेक्ट करें",
"usernameLabel": "उपयोगकर्ता नाम"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Tailnet eszközök megtekintése",
"description": "Tekintse meg a csatlakoztatott hátsó hálózaton lévő eszközöket."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Farokháló"
},
"apiKey": {
"label": "API-kulcs",
"description": "A farokhálózaton lévő eszközök listázására szolgál. Tailscale vagy Headscale kulccsal működik.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API alap URL",
"description": "Tailscale esetén hagyja üresen. Állítsa be úgy, hogy egy Headscale példányra mutasson.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Eszközlista",
"checking": "A farokháló ellenőrzése...",
"noKey": "Még nincs beállítva API-kulcs.",
"error": "Nem sikerült elérni a farokhálózatot. Ellenőrizze az API-kulcsot.",
"count": "{{count}} eszköz elérhető",
"count_other": "{{count}} elérhető eszközök",
"refresh": "Ellenőrzés"
}
},
"nav": {
"tailscale": "Farokskála"
},
"hosts": {
"tailscaleUsernameHint": "Ennek Unix felhasználónak kell lennie, akinek a Tailscale identitását a tailnet SSH ACL-jében adták meg, nem feltétlenül root felhasználónak.",
"tailscaleDeviceSelect": "Tailscale eszköz kiválasztása",
"tailscaleDeviceSelectPlaceholder": "Válasszon egy eszközt...",
"tailscaleNoApiKey": "Nincs konfigurálva Tailscale API-kulcs. Adjon hozzá egyet az adminisztrátori beállításokban az eszközfelderítés engedélyezéséhez.",
"tailscaleDocsLink": "Dokumentumok megtekintése",
"tailscaleLoadingDevices": "Eszközök betöltése...",
"tailscaleDeviceLoadFailed": "Nem sikerült betölteni a Tailscale eszközöket. Ellenőrizze az API URL-t és a hálózati kapcsolatot.",
"tailscaleNoDevices": "Nem találhatók eszközök a hátsó hálózatban.",
"tailscaleDeviceAutoFill": "Egy eszköz kiválasztása automatikusan kitölti a gazdagép IP-címét.",
"tailscaleAddHost": "Gazdagép hozzáadása",
"tailscaleCopyIp": "Tailscale IP másolása",
"tailscaleIpCopied": "IP-cím lemásolva",
"tailscaleIpCopyFailed": "Nem sikerült másolni az IP-címet",
"filterAuthTailscale": "Farokskála"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale hitelesítés szükséges",
"tailscaleCheckDescription": "A Tailscale SSH további ellenőrzést igényel. A folytatáshoz hitelesítse magát a böngészőjében.",
"tailscaleCheckOpenBrowser": "Böngésző megnyitása a hitelesítéshez",
"tailscaleCheckWaiting": "Tailscale hitelesítésre várunk...",
"tailscaleCheckTimeout": "A farokskálázási hitelesítés időtúllépést okozott. Próbálja újra."
},
"manager": {
"title": "Farokskála",
"refresh": "Frissítés",
"retry": "Újrapróbálkozás",
"sudoHint": "Állítson be egy sudo jelszót ehhez a gazdagéphez a magasabb szintű műveletek engedélyezéséhez.",
"noData": "Nincsenek elérhető adatok",
"filter": "Szűrő...",
"tsNotInstalled": "A Tailscale nincs telepítve erre a gazdagépre",
"tsRunning": "Futás",
"tsStopped": "Leállítva",
"tsEnable": "Csatlakozás",
"tsDisable": "Leválasztás",
"tsEnabling": "Kapcsolódás a Tailscale-hez...",
"tsDisabling": "Lecsatlakozás a Tailscale-ről...",
"tsEnabled": "Farokméret csatlakoztatva",
"tsDisabled": "Farokváz leválasztva",
"tsExitNode": "Kilépési csomópont",
"tsExitNodeActive": "Kilépési csomópont aktív"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Csatlakozás a terminálhoz",
"usernameLabel": "Felhasználónév"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Select Tailscale device",
"tailscaleDeviceSelectPlaceholder": "Select a device...",
"tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.",
"tailscaleDocsLink": "View docs",
"tailscaleLoadingDevices": "Loading devices...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "No devices found in your tailnet.",
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Open Browser to Authenticate",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Refresh",
"retry": "Retry",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "No data available",
"filter": "Filter...",
"tsNotInstalled": "Tailscale is not installed on this host",
"tsRunning": "Running",
"tsStopped": "Stopped",
"tsEnable": "Connect",
"tsDisable": "Disconnect",
"tsEnabling": "Connecting to Tailscale...",
"tsDisabling": "Disconnecting from Tailscale...",
"tsEnabled": "Tailscale connected",
"tsDisabled": "Tailscale disconnected",
"tsExitNode": "Exit node",
"tsExitNodeActive": "Exit node active"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connect to Terminal",
"usernameLabel": "Nama pengguna"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Visualizza i dispositivi tailnet",
"description": "Visualizza i dispositivi sulla tailnet connessa."
}
},
"settings": {
"group": {
"api": "API di Tailscale",
"devices": "Tailnet"
},
"apiKey": {
"label": "Chiave API",
"description": "Utilizzato per elencare i dispositivi presenti nella tua rete di coda. Funziona con una chiave Tailscale o Headscale.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "URL di base dell'API",
"description": "Lascia vuoto per Tailscale. Imposta questo campo in modo che punti a un'istanza di Headscale.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Elenco dei dispositivi",
"checking": "Controllo della rete di coda...",
"noKey": "Non è ancora stata impostata alcuna chiave API.",
"error": "Impossibile raggiungere la rete locale. Verificare la chiave API.",
"count": "{{count}} dispositivo raggiungibile",
"count_other": "{{count}} dispositivi raggiungibili",
"refresh": "Controllo"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "Deve trattarsi di un utente Unix a cui è concessa l'identità Tailscale nell'ACL SSH di Tailnet, non necessariamente root.",
"tailscaleDeviceSelect": "Seleziona dispositivo Tailscale",
"tailscaleDeviceSelectPlaceholder": "Seleziona un dispositivo...",
"tailscaleNoApiKey": "Nessuna chiave API Tailscale configurata. Aggiungine una in Impostazioni amministratore per abilitare il rilevamento dispositivi.",
"tailscaleDocsLink": "Visualizza documentazione",
"tailscaleLoadingDevices": "Caricamento dispositivi...",
"tailscaleDeviceLoadFailed": "Impossibile caricare i dispositivi Tailscale. Verificare l'URL dell'API e la connessione di rete.",
"tailscaleNoDevices": "Nessun dispositivo trovato nel tuo tailnet.",
"tailscaleDeviceAutoFill": "Selezionando un dispositivo l'indirizzo IP dell'host verrà compilato automaticamente.",
"tailscaleAddHost": "Aggiungi host",
"tailscaleCopyIp": "Copia la proprietà intellettuale di Tailscale",
"tailscaleIpCopied": "IP copiato",
"tailscaleIpCopyFailed": "Impossibile copiare l'indirizzo IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Autenticazione Tailscale richiesta",
"tailscaleCheckDescription": "Tailscale SSH richiede un controllo aggiuntivo. Autenticati nel tuo browser per continuare.",
"tailscaleCheckOpenBrowser": "Apri il browser per autenticarti",
"tailscaleCheckWaiting": "In attesa dell'autenticazione di Tailscale...",
"tailscaleCheckTimeout": "Autenticazione di Tailscale scaduta. Riprova."
},
"manager": {
"title": "Tailscale",
"refresh": "Aggiorna",
"retry": "Riprova",
"sudoHint": "Imposta una password sudo per questo host per consentire azioni con privilegi elevati.",
"noData": "Nessun dato disponibile",
"filter": "Filtra...",
"tsNotInstalled": "Tailscale non è installato su questo host",
"tsRunning": "In esecuzione",
"tsStopped": "Arrestato",
"tsEnable": "Connetti",
"tsDisable": "Disconnetti",
"tsEnabling": "Connessione a Tailscale in corso...",
"tsDisabling": "Disconnessione da Tailscale in corso...",
"tsEnabled": "Tailscale connesso",
"tsDisabled": "Tailscale disconnesso",
"tsExitNode": "Nodo di uscita",
"tsExitNodeActive": "Nodo di uscita attivo"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connetti al terminale",
"usernameLabel": "Nome utente"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "テールネットデバイスを表示する",
"description": "接続されているテールネット上のデバイスを確認してください。"
}
},
"settings": {
"group": {
"api": "テールスケールAPI",
"devices": "テールネット"
},
"apiKey": {
"label": "APIキー",
"description": "テールネット上のデバイスを一覧表示するために使用します。TailscaleキーまたはHeadscaleキーと連携して動作します。",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "APIベースURL",
"description": "テールスケールの場合は空欄のままにしてください。ヘッドスケールインスタンスを指定する場合は、こちらを設定してください。",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "デバイスリスト",
"checking": "テールネットをチェック中...",
"noKey": "APIキーはまだ設定されていません。",
"error": "テールネットに接続できませんでした。APIキーを確認してください。",
"count": "{{count}} デバイスに到達可能",
"count_other": "{{count}} デバイスに到達可能",
"refresh": "チェック"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "これは、tailnetのSSH ACLでTailscaleのIDが付与されているUnixユーザーである必要があり、必ずしもrootである必要はありません。",
"tailscaleDeviceSelect": "Tailscaleデバイスを選択",
"tailscaleDeviceSelectPlaceholder": "デバイスを選択...",
"tailscaleNoApiKey": "Tailscale APIキーが設定されていません。デバイス検出を有効にするには、管理者設定で追加してください。",
"tailscaleDocsLink": "ドキュメントを表示",
"tailscaleLoadingDevices": "デバイスを読み込み中...",
"tailscaleDeviceLoadFailed": "Tailscaleデバイスの読み込みに失敗しました。API URLとネットワーク接続を確認してください。",
"tailscaleNoDevices": "テールネットにデバイスが見つかりません。",
"tailscaleDeviceAutoFill": "デバイスを選択するとホストIPアドレスが自動入力されます。",
"tailscaleAddHost": "ホストを追加",
"tailscaleCopyIp": "Tailscale IPをコピーする",
"tailscaleIpCopied": "IPアドレスがコピーされました",
"tailscaleIpCopyFailed": "IPアドレスをコピーできませんでした",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "テールスケール認証が必要です",
"tailscaleCheckDescription": "TailscaleのSSH接続には追加の認証が必要です。ブラウザで認証を行ってから続行してください。",
"tailscaleCheckOpenBrowser": "ブラウザを開いて認証",
"tailscaleCheckWaiting": "Tailscaleの認証を待っています...",
"tailscaleCheckTimeout": "Tailscaleの認証がタイムアウトしました。もう一度お試しください。"
},
"manager": {
"title": "Tailscale",
"refresh": "再読み込み",
"retry": "再試行",
"sudoHint": "このホストに対してsudoパスワードを設定し、管理者権限での操作を許可してください。",
"noData": "利用可能なデータはありません",
"filter": "フィルター...",
"tsNotInstalled": "このホストにTailscaleがインストールされていません",
"tsRunning": "実行中",
"tsStopped": "停止中",
"tsEnable": "接続",
"tsDisable": "切断",
"tsEnabling": "Tailscaleに接続中...",
"tsDisabling": "Tailscaleから切断中...",
"tsEnabled": "Tailscale接続済み",
"tsDisabled": "Tailscale切断済み",
"tsExitNode": "Exitノード",
"tsExitNodeActive": "Exitノード有効"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "ターミナルに接続",
"usernameLabel": "ユーザー名"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "테일넷 장치 보기",
"description": "연결된 테일넷에서 장치를 확인하세요."
}
},
"settings": {
"group": {
"api": "테일스케일 API",
"devices": "테일넷"
},
"apiKey": {
"label": "API 키",
"description": "테일넷에 연결된 장치 목록을 표시하는 데 사용됩니다. Tailscale 또는 Headscale 키와 함께 작동합니다.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API 기본 URL",
"description": "Tailscale을 사용하려면 비워 두십시오. Headscale 인스턴스를 가리키도록 설정하십시오.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "기기 목록",
"checking": "테일넷을 확인하는 중...",
"noKey": "아직 API 키가 설정되지 않았습니다.",
"error": "테일넷에 연결할 수 없습니다. API 키를 확인하세요.",
"count": "{{count}} 장치에 연결 가능",
"count_other": "{{count}} 기기 연결 가능",
"refresh": "확인하다"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "이 값은 Tailscale ID가 Tailnet의 SSH ACL에 부여된 Unix 사용자여야 하며, 반드시 root일 필요는 없습니다.",
"tailscaleDeviceSelect": "Tailscale 기기 선택",
"tailscaleDeviceSelectPlaceholder": "기기 선택...",
"tailscaleNoApiKey": "Tailscale API 키가 구성되지 않았습니다. 기기 검색을 활성화하려면 관리자 설정에서 추가하세요.",
"tailscaleDocsLink": "문서 보기",
"tailscaleLoadingDevices": "장치를 불러오는 중...",
"tailscaleDeviceLoadFailed": "Tailscale 장치를 불러오는 데 실패했습니다. API URL과 네트워크 연결을 확인하세요.",
"tailscaleNoDevices": "테일넷에서 장치를 찾을 수 없습니다.",
"tailscaleDeviceAutoFill": "장치를 선택하면 호스트 IP 주소가 자동으로 채워집니다.",
"tailscaleAddHost": "호스트 추가",
"tailscaleCopyIp": "Tailscale IP 복사",
"tailscaleIpCopied": "IP 복사됨",
"tailscaleIpCopyFailed": "IP 주소를 복사할 수 없습니다.",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale 인증이 필요합니다.",
"tailscaleCheckDescription": "Tailscale SSH는 추가 인증이 필요합니다. 계속하려면 브라우저에서 인증하십시오.",
"tailscaleCheckOpenBrowser": "브라우저로 인증하기",
"tailscaleCheckWaiting": "Tailscale 인증을 기다리는 중...",
"tailscaleCheckTimeout": "Tailscale 인증 시간이 초과되었습니다. 다시 시도해 주세요."
},
"manager": {
"title": "Tailscale",
"refresh": "새로 고침",
"retry": "재시도",
"sudoHint": "관리자 권한으로 작업을 수행할 수 있도록 이 호스트에 sudo 암호를 설정하십시오.",
"noData": "데이터 없음",
"filter": "필터...",
"tsNotInstalled": "이 호스트에 Tailscale이 설치되어 있지 않습니다",
"tsRunning": "실행 중",
"tsStopped": "중지됨",
"tsEnable": "연결",
"tsDisable": "연결 끊기",
"tsEnabling": "Tailscale에 연결 중...",
"tsDisabling": "Tailscale 연결 해제 중...",
"tsEnabled": "Tailscale 연결됨",
"tsDisabled": "Tailscale 연결 해제됨",
"tsExitNode": "출구 노드",
"tsExitNodeActive": "출구 노드 활성"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "터미널에 연결",
"usernameLabel": "사용자 이름"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Select Tailscale device",
"tailscaleDeviceSelectPlaceholder": "Select a device...",
"tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.",
"tailscaleDocsLink": "View docs",
"tailscaleLoadingDevices": "Loading devices...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "No devices found in your tailnet.",
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Open Browser to Authenticate",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Refresh",
"retry": "Retry",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "No data available",
"filter": "Filter...",
"tsNotInstalled": "Tailscale is not installed on this host",
"tsRunning": "Running",
"tsStopped": "Stopped",
"tsEnable": "Connect",
"tsDisable": "Disconnect",
"tsEnabling": "Connecting to Tailscale...",
"tsDisabling": "Disconnecting from Tailscale...",
"tsEnabled": "Tailscale connected",
"tsDisabled": "Tailscale disconnected",
"tsExitNode": "Exit node",
"tsExitNodeActive": "Exit node active"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connect to Terminal",
"usernameLabel": "Username"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Select Tailscale device",
"tailscaleDeviceSelectPlaceholder": "Select a device...",
"tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.",
"tailscaleDocsLink": "View docs",
"tailscaleLoadingDevices": "Loading devices...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "No devices found in your tailnet.",
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Open Browser to Authenticate",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Oppdater",
"retry": "Prøv igjen",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "No data available",
"filter": "Filter...",
"tsNotInstalled": "Tailscale is not installed on this host",
"tsRunning": "Kjører",
"tsStopped": "Stopped",
"tsEnable": "Koble til",
"tsDisable": "Koble fra",
"tsEnabling": "Connecting to Tailscale...",
"tsDisabling": "Disconnecting from Tailscale...",
"tsEnabled": "Tailscale connected",
"tsDisabled": "Tailscale disconnected",
"tsExitNode": "Exit node",
"tsExitNodeActive": "Exit node active"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connect to Terminal",
"usernameLabel": "Brukernavn"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "Klucz API",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "Musi to być użytkownik systemu Unix, któremu Twoja tożsamość Tailscale została przyznana w ACL SSH sieci tailnet, niekoniecznie root.",
"tailscaleDeviceSelect": "Wybierz urządzenie Tailscale",
"tailscaleDeviceSelectPlaceholder": "Wybierz urządzenie...",
"tailscaleNoApiKey": "Nie skonfigurowano klucza API Tailscale. Dodaj go w Ustawieniach administratora, aby włączyć wykrywanie urządzeń.",
"tailscaleDocsLink": "Zobacz dokumentację",
"tailscaleLoadingDevices": "Ładowanie urządzeń...",
"tailscaleDeviceLoadFailed": "Nie udało się załadować urządzeń Tailscale. Sprawdź adres URL API i połączenie sieciowe.",
"tailscaleNoDevices": "Nie znaleziono urządzeń w Twojej sieci tailnet.",
"tailscaleDeviceAutoFill": "Wybranie urządzenia spowoduje automatyczne wypełnienie adresu IP hosta.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Wymagane uwierzytelnienie Tailscale",
"tailscaleCheckDescription": "Tailscale SSH wymaga dodatkowej weryfikacji. Uwierzytelnij się w przeglądarce, aby kontynuować.",
"tailscaleCheckOpenBrowser": "Otwórz przeglądarkę, aby uwierzytelnić",
"tailscaleCheckWaiting": "Oczekiwanie na uwierzytelnienie Tailscale...",
"tailscaleCheckTimeout": "Upłynął limit czasu uwierzytelniania Tailscale. Spróbuj ponownie."
},
"manager": {
"title": "Tailscale",
"refresh": "Odśwież",
"retry": "Ponów",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "Brak dostępnych danych",
"filter": "Filtr...",
"tsNotInstalled": "Tailscale nie jest zainstalowany na tym hoście",
"tsRunning": "Działa",
"tsStopped": "Zatrzymany",
"tsEnable": "Połączenie",
"tsDisable": "Odłącz",
"tsEnabling": "Łączenie z Tailscale...",
"tsDisabling": "Rozłączanie z Tailscale...",
"tsEnabled": "Tailscale połączony",
"tsDisabled": "Tailscale rozłączony",
"tsExitNode": "Węzeł wyjściowy",
"tsExitNodeActive": "Węzeł wyjściowy aktywny"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Połącz z terminalem",
"usernameLabel": "Nazwa użytkownika"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Selecionar dispositivo Tailscale",
"tailscaleDeviceSelectPlaceholder": "Selecione um dispositivo...",
"tailscaleNoApiKey": "Nenhuma chave de API Tailscale configurada. Adicione uma nas configurações do Admin para habilitar a descoberta do dispositivo.",
"tailscaleDocsLink": "Documentação",
"tailscaleLoadingDevices": "Carregando dispositivos...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "Nenhum dispositivo encontrado na sua rede tail.",
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Open Browser to Authenticate",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Atualizar",
"retry": "Retry",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "No data available",
"filter": "Filter...",
"tsNotInstalled": "Tailscale is not installed on this host",
"tsRunning": "Running",
"tsStopped": "Stopped",
"tsEnable": "Conectar",
"tsDisable": "Desconectar",
"tsEnabling": "Connecting to Tailscale...",
"tsDisabling": "Disconnecting from Tailscale...",
"tsEnabled": "Tailscale connected",
"tsDisabled": "Tailscale disconnected",
"tsExitNode": "Exit node",
"tsExitNodeActive": "Exit node active"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connect to Terminal",
"usernameLabel": "Nome de usuário"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Selecionar dispositivo Tailscale",
"tailscaleDeviceSelectPlaceholder": "Selecionar um dispositivo...",
"tailscaleNoApiKey": "Nenhuma chave de API Tailscale configurada. Adicione uma nas Definições de Administrador para ativar a descoberta de dispositivos.",
"tailscaleDocsLink": "Ver documentação",
"tailscaleLoadingDevices": "A carregar dispositivos...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "Nenhum dispositivo encontrado na sua tailnet.",
"tailscaleDeviceAutoFill": "Selecionar um dispositivo preencherá automaticamente o endereço IP do host.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Abrir navegador para autenticar",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Atualizar",
"retry": "Repetir",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "Sem dados disponíveis",
"filter": "Filtrar...",
"tsNotInstalled": "O Tailscale não está instalado neste host",
"tsRunning": "Em execução",
"tsStopped": "Parado",
"tsEnable": "Ligar",
"tsDisable": "Desligar",
"tsEnabling": "A ligar ao Tailscale...",
"tsDisabling": "A desligar do Tailscale...",
"tsEnabled": "Tailscale ligado",
"tsDisabled": "Tailscale desligado",
"tsExitNode": "Nó de saída",
"tsExitNodeActive": "Nó de saída ativo"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Ligar ao Terminal",
"usernameLabel": "Nome de utilizador"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "Vizualizați dispozitivele tailnet",
"description": "Vedeți dispozitivele de pe rețeaua conectată."
}
},
"settings": {
"group": {
"api": "API la scară mică",
"devices": "Plasă de coadă"
},
"apiKey": {
"label": "Cheie API",
"description": "Folosit pentru a lista dispozitivele din rețeaua tailnet. Funcționează cu o tastă Tailscale sau Headscale.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "URL-ul de bază al API-ului",
"description": "Lăsați câmpul gol pentru Tailscale. Setați acest câmp să indice o instanță Headscale.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Listă de dispozitive",
"checking": "Verificarea plasei de coadă...",
"noKey": "Nu a fost setată încă nicio cheie API.",
"error": "Nu s-a putut accesa rețeaua de coardă. Verificați cheia API.",
"count": "{{count}} device reachable",
"count_other": "{{count}} dispozitive accesibile",
"refresh": "Verifica"
}
},
"nav": {
"tailscale": "Scară de coadă"
},
"hosts": {
"tailscaleUsernameHint": "Acesta trebuie să fie un utilizator Unix căruia îi este acordată identitatea Tailscale în ACL-ul SSH al rețelei tailnet, nu neapărat root.",
"tailscaleDeviceSelect": "Selectați dispozitivul Tailscale",
"tailscaleDeviceSelectPlaceholder": "Selectați un dispozitiv...",
"tailscaleNoApiKey": "Nu este configurată nicio cheie API Tailscale. Adăugați una în Setările de administrare pentru a activa descoperirea dispozitivelor.",
"tailscaleDocsLink": "Vizualizați documentele",
"tailscaleLoadingDevices": "Se încarcă dispozitivele...",
"tailscaleDeviceLoadFailed": "Nu s-au putut încărca dispozitivele Tailscale. Verificați adresa URL API și conexiunea la rețea.",
"tailscaleNoDevices": "Nu s-au găsit dispozitive în rețeaua ta de coadă.",
"tailscaleDeviceAutoFill": "Selectarea unui dispozitiv va completa automat adresa IP a gazdei.",
"tailscaleAddHost": "Adăugați gazdă",
"tailscaleCopyIp": "Copiază IP-ul la scară mică",
"tailscaleIpCopied": "IP-ul a fost copiat",
"tailscaleIpCopyFailed": "Nu s-a putut copia adresa IP",
"filterAuthTailscale": "Scară de coadă"
},
"terminal": {
"tailscaleCheckRequired": "Autentificare la scară mică necesară",
"tailscaleCheckDescription": "SSH-ul Tailscale necesită o verificare suplimentară. Autentificați-vă în browser pentru a continua.",
"tailscaleCheckOpenBrowser": "Deschideți browserul pentru autentificare",
"tailscaleCheckWaiting": "Se așteaptă autentificarea Tailscale...",
"tailscaleCheckTimeout": "Autentificarea la scară mică a expirat. Vă rugăm să încercați din nou."
},
"manager": {
"title": "Scară de coadă",
"refresh": "Reîmprospăta",
"retry": "Reîncercați",
"sudoHint": "Setați o parolă sudo pentru această gazdă pentru a permite acțiuni cu privilegii ridicate.",
"noData": "Nu sunt disponibile date",
"filter": "Filtra...",
"tsNotInstalled": "Tailscale nu este instalat pe această gazdă",
"tsRunning": "Funcţionare",
"tsStopped": "Oprit",
"tsEnable": "Conectează-te",
"tsDisable": "Deconecta",
"tsEnabling": "Conectare la Tailscale...",
"tsDisabling": "Deconectare de la Tailscale...",
"tsEnabled": "Scală de coadă conectată",
"tsDisabled": "Scală detașată",
"tsExitNode": "Nod de ieșire",
"tsExitNodeActive": "Nod de ieșire activ"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Conectați-vă la terminal",
"usernameLabel": "Nume de utilizator"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Выберите устройство Tailscale",
"tailscaleDeviceSelectPlaceholder": "Выберите устройство...",
"tailscaleNoApiKey": "Ключ API Tailscale не настроен. Добавьте его в настройках администратора, чтобы включить обнаружение устройств.",
"tailscaleDocsLink": "Документация",
"tailscaleLoadingDevices": "Загрузка устройств...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "В вашем tailnet не найдено устройств.",
"tailscaleDeviceAutoFill": "При выборе устройства IP-адрес хоста будет заполнен автоматически.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Открыть браузер для аутентификации",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Обновить",
"retry": "Повторить",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "Нет данных",
"filter": "Фильтр...",
"tsNotInstalled": "Tailscale не установлен на этом хосте",
"tsRunning": "Запущен",
"tsStopped": "Остановлен",
"tsEnable": "Соединить",
"tsDisable": "Отключить",
"tsEnabling": "Подключение к Tailscale...",
"tsDisabling": "Отключение от Tailscale...",
"tsEnabled": "Tailscale подключён",
"tsDisabled": "Tailscale отключён",
"tsExitNode": "Выходной узел",
"tsExitNodeActive": "Выходной узел активен"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Подключиться к терминалу",
"usernameLabel": "Имя пользователя"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Select Tailscale device",
"tailscaleDeviceSelectPlaceholder": "Select a device...",
"tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.",
"tailscaleDocsLink": "View docs",
"tailscaleLoadingDevices": "Loading devices...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "No devices found in your tailnet.",
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Open Browser to Authenticate",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Refresh",
"retry": "Retry",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "No data available",
"filter": "Filter...",
"tsNotInstalled": "Tailscale is not installed on this host",
"tsRunning": "Running",
"tsStopped": "Stopped",
"tsEnable": "Connect",
"tsDisable": "Disconnect",
"tsEnabling": "Connecting to Tailscale...",
"tsDisabling": "Disconnecting from Tailscale...",
"tsEnabled": "Tailscale connected",
"tsDisabled": "Tailscale disconnected",
"tsExitNode": "Exit node",
"tsExitNodeActive": "Exit node active"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connect to Terminal",
"usernameLabel": "Username"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Select Tailscale device",
"tailscaleDeviceSelectPlaceholder": "Select a device...",
"tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.",
"tailscaleDocsLink": "View docs",
"tailscaleLoadingDevices": "Loading devices...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "No devices found in your tailnet.",
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Open Browser to Authenticate",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Refresh",
"retry": "Retry",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "No data available",
"filter": "Filter...",
"tsNotInstalled": "Tailscale is not installed on this host",
"tsRunning": "Running",
"tsStopped": "Stopped",
"tsEnable": "Connect",
"tsDisable": "Disconnect",
"tsEnabling": "Connecting to Tailscale...",
"tsDisabling": "Disconnecting from Tailscale...",
"tsEnabled": "Tailscale connected",
"tsDisabled": "Tailscale disconnected",
"tsExitNode": "Exit node",
"tsExitNodeActive": "Exit node active"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connect to Terminal",
"usernameLabel": "Username"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Select Tailscale device",
"tailscaleDeviceSelectPlaceholder": "Select a device...",
"tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.",
"tailscaleDocsLink": "View docs",
"tailscaleLoadingDevices": "Loading devices...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "No devices found in your tailnet.",
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Open Browser to Authenticate",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Refresh",
"retry": "Retry",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "No data available",
"filter": "Filter...",
"tsNotInstalled": "Tailscale is not installed on this host",
"tsRunning": "Running",
"tsStopped": "Stopped",
"tsEnable": "Connect",
"tsDisable": "Disconnect",
"tsEnabling": "Connecting to Tailscale...",
"tsDisabling": "Disconnecting from Tailscale...",
"tsEnabled": "Tailscale connected",
"tsDisabled": "Tailscale disconnected",
"tsExitNode": "Exit node",
"tsExitNodeActive": "Exit node active"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connect to Terminal",
"usernameLabel": "Username"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Tailscale cihazı seçin",
"tailscaleDeviceSelectPlaceholder": "Bir cihaz seçin...",
"tailscaleNoApiKey": "Tailscale API anahtarı yapılandırılmamış. Cihaz keşfini etkinleştirmek için Yönetici Ayarları'ndan bir tane ekleyin.",
"tailscaleDocsLink": "Dokümanları görüntüle",
"tailscaleLoadingDevices": "Aygıtlar yükleniyor...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "Tailnet'inizde aygıt bulunamadı.",
"tailscaleDeviceAutoFill": "Bir aygıt seçmek, host IP adresini otomatik dolduracaktır.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Kimlik Doğrulaması için Tarayıcı Aç",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Yenile",
"retry": "Yeniden dene",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "Veri yok",
"filter": "Filtrele...",
"tsNotInstalled": "Bu sunucuda Tailscale yüklü değil",
"tsRunning": "Çalışıyor",
"tsStopped": "Durduruldu",
"tsEnable": "Bağlan",
"tsDisable": "Bağlantıyı kes",
"tsEnabling": "Tailscale'e bağlanıyor...",
"tsDisabling": "Tailscale bağlantısı kesiliyor...",
"tsEnabled": "Tailscale bağlandı",
"tsDisabled": "Tailscale bağlantısı kesildi",
"tsExitNode": "Çıkış düğümü",
"tsExitNodeActive": "Çıkış düğümü etkin"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Terminale Bağlan",
"usernameLabel": "Kullanıcı Adı"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Select Tailscale device",
"tailscaleDeviceSelectPlaceholder": "Select a device...",
"tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.",
"tailscaleDocsLink": "View docs",
"tailscaleLoadingDevices": "Loading devices...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "No devices found in your tailnet.",
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Open Browser to Authenticate",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Refresh",
"retry": "Retry",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "No data available",
"filter": "Filter...",
"tsNotInstalled": "Tailscale is not installed on this host",
"tsRunning": "Running",
"tsStopped": "Stopped",
"tsEnable": "Connect",
"tsDisable": "Disconnect",
"tsEnabling": "Connecting to Tailscale...",
"tsDisabling": "Disconnecting from Tailscale...",
"tsEnabled": "Tailscale connected",
"tsDisabled": "Tailscale disconnected",
"tsExitNode": "Exit node",
"tsExitNodeActive": "Exit node active"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Connect to Terminal",
"usernameLabel": "Username"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "Chọn thiết bị Tailscale",
"tailscaleDeviceSelectPlaceholder": "Chọn thiết bị...",
"tailscaleNoApiKey": "Chưa cấu hình khóa API Tailscale. Thêm một khóa trong Cài đặt Quản trị để bật khám phá thiết bị.",
"tailscaleDocsLink": "Xem tài liệu",
"tailscaleLoadingDevices": "Đang tải thiết bị...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "Không tìm thấy thiết bị nào trong tailnet của bạn.",
"tailscaleDeviceAutoFill": "Chọn thiết bị sẽ tự động điền địa chỉ IP máy chủ.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "Mở trình duyệt để xác thực",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "Làm mới",
"retry": "Thử lại",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "Không có dữ liệu",
"filter": "Lọc...",
"tsNotInstalled": "Tailscale chưa được cài đặt trên máy chủ này",
"tsRunning": "Đang chạy",
"tsStopped": "Đã dừng",
"tsEnable": "Kết nối",
"tsDisable": "Ngắt kết nối",
"tsEnabling": "Đang kết nối đến Tailscale...",
"tsDisabling": "Đang ngắt kết nối khỏi Tailscale...",
"tsEnabled": "Đã kết nối Tailscale",
"tsDisabled": "Đã ngắt kết nối Tailscale",
"tsExitNode": "Nút thoát",
"tsExitNodeActive": "Nút thoát đang hoạt động"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "Kết nối đến Terminal",
"usernameLabel": "Tên đăng nhập"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API 密钥",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "选择 Tailscale 设备",
"tailscaleDeviceSelectPlaceholder": "选择设备...",
"tailscaleNoApiKey": "未配置 Tailscale API 密钥。请在管理设置中添加一个以启用设备发现。",
"tailscaleDocsLink": "查看文档",
"tailscaleLoadingDevices": "正在加载设备...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "在您的 tailnet 中未找到设备。",
"tailscaleDeviceAutoFill": "选择设备将自动填充主机 IP 地址。",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "打开浏览器进行认证",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale 身份验证超时,请重试。"
},
"manager": {
"title": "Tailscale",
"refresh": "刷新",
"retry": "重试",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "暂无数据",
"filter": "筛选...",
"tsNotInstalled": "此主机未安装 Tailscale",
"tsRunning": "运行中",
"tsStopped": "已停止",
"tsEnable": "连接",
"tsDisable": "断开连接",
"tsEnabling": "正在连接到 Tailscale...",
"tsDisabling": "正在断开 Tailscale 连接...",
"tsEnabled": "Tailscale 已连接",
"tsDisabled": "Tailscale 已断开连接",
"tsExitNode": "出口节点",
"tsExitNodeActive": "出口节点活跃"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "连接到终端",
"usernameLabel": "用户名"
}
}
}
}
@@ -0,0 +1,86 @@
{
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
},
"nav": {
"tailscale": "Tailscale"
},
"hosts": {
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
"tailscaleDeviceSelect": "選取 Tailscale 裝置",
"tailscaleDeviceSelectPlaceholder": "選取裝置...",
"tailscaleNoApiKey": "未設定 Tailscale API 金鑰。請在管理設定中新增一個以啟用裝置探索。",
"tailscaleDocsLink": "檢視文件",
"tailscaleLoadingDevices": "正在載入裝置...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "在您的 tailnet 中找不到裝置。",
"tailscaleDeviceAutoFill": "選取裝置將自動填入主機 IP 位址。",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP",
"filterAuthTailscale": "Tailscale"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
"tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.",
"tailscaleCheckOpenBrowser": "開啟瀏覽器以進行驗證",
"tailscaleCheckWaiting": "Waiting for Tailscale authentication...",
"tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again."
},
"manager": {
"title": "Tailscale",
"refresh": "重新整理",
"retry": "重試",
"sudoHint": "Set a sudo password for this host to allow elevated actions.",
"noData": "無可用資料",
"filter": "篩選...",
"tsNotInstalled": "此主機上未安裝 Tailscale",
"tsRunning": "執行中",
"tsStopped": "已停止",
"tsEnable": "連線",
"tsDisable": "中斷連線",
"tsEnabling": "正在連線至 Tailscale...",
"tsDisabling": "正在中斷 Tailscale 連線...",
"tsEnabled": "Tailscale 已連線",
"tsDisabled": "Tailscale 已中斷連線",
"tsExitNode": "出口節點",
"tsExitNodeActive": "出口節點作用中"
},
"newUi": {
"sidebar": {
"quickConnect": {
"connectToTerminal": "連線至終端機",
"usernameLabel": "使用者名稱"
}
}
}
}
+81
View File
@@ -0,0 +1,81 @@
{
"id": "tailscale",
"name": "Tailscale",
"version": "1.0.0",
"description": "Browse and connect to devices on your tailnet, and handle Tailscale SSH sign-in checks.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Networking",
"icon": "Network",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"hosts:read",
"settings:read-core",
"network:outbound",
"network:serve",
"auth:provide",
"ssh:connect",
"credentials:use",
"credentials:read",
"ui:surface"
],
"optionalDependencies": {
"host-metrics": "^1.0.0"
},
"contributes": {
"panels": [
{
"id": "tailscale",
"titleKey": "nav.tailscale",
"icon": "Radar"
}
],
"permissions": [
{
"name": "devices.view",
"titleKey": "permissions.devices.view.title",
"descriptionKey": "permissions.devices.view.description",
"defaultRoles": ["admin"]
}
],
"auth": {
"sshAuthTypes": ["tailscale"]
},
"settings": {
"admin": [
{
"key": "apiKey",
"type": "secret",
"labelKey": "settings.apiKey.label",
"descriptionKey": "settings.apiKey.description",
"placeholderKey": "settings.apiKey.placeholder",
"group": "settings.group.api"
},
{
"key": "apiBaseUrl",
"type": "string",
"labelKey": "settings.apiBaseUrl.label",
"descriptionKey": "settings.apiBaseUrl.description",
"placeholderKey": "settings.apiBaseUrl.placeholder",
"default": "",
"group": "settings.group.api"
},
{
"key": "devices",
"type": "custom",
"component": "devices",
"group": "settings.group.devices"
}
]
},
"uses": ["host-metrics.managers", "terminal.overlay"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@termix-plugin/tailscale",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8",
"ssh2": "^1.17.0"
}
}
@@ -0,0 +1,259 @@
import type { Router } from "express";
import type { Request, Response } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { withSshConnection } from "./ssh.js";
export type TailscaleAction = "up" | "down";
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
return a === "up" || a === "down";
}
export interface TailscalePeer {
hostname: string;
tailscaleIPs: string[];
online: boolean;
isExitNode: boolean;
}
export interface TailscaleData {
installed: boolean;
running: boolean;
tailscaleIPs: string[];
hostname: string | null;
peers: TailscalePeer[];
exitNodeInUse: boolean;
}
const PROBE_CMD = [
"command -v tailscale >/dev/null 2>&1 && echo ts_installed=1 || echo ts_installed=0",
"tailscale status --json 2>/dev/null",
].join("; ");
export function parseTailscaleData(output: string): TailscaleData {
const notInstalled: TailscaleData = {
installed: false,
running: false,
tailscaleIPs: [],
hostname: null,
peers: [],
exitNodeInUse: false,
};
if (output.includes("ts_installed=0")) return notInstalled;
const lines = output.split("\n");
const jsonLines = lines.filter(
(l) => !l.startsWith("ts_installed=") && l.trim() !== "",
);
const jsonStr = jsonLines.join("\n");
try {
const parsed = JSON.parse(jsonStr) as {
BackendState?: string;
Self?: { HostName?: string; TailscaleIPs?: string[] };
Peer?: Record<
string,
{
HostName?: string;
TailscaleIPs?: string[];
Online?: boolean;
ExitNode?: boolean;
}
>;
CurrentExitNode?: string;
};
const peers: TailscalePeer[] = Object.values(parsed.Peer ?? {}).map(
(p) => ({
hostname: p.HostName ?? "",
tailscaleIPs: p.TailscaleIPs ?? [],
online: p.Online ?? false,
isExitNode: p.ExitNode ?? false,
}),
);
return {
installed: true,
running: parsed.BackendState === "Running",
tailscaleIPs: parsed.Self?.TailscaleIPs ?? [],
hostname: parsed.Self?.HostName ?? null,
peers,
exitNodeInUse:
typeof parsed.CurrentExitNode === "string" &&
parsed.CurrentExitNode !== "",
};
} catch {
return {
installed: true,
running: false,
tailscaleIPs: [],
hostname: null,
peers: [],
exitNodeInUse: false,
};
}
}
class AccessDeniedError extends Error {
constructor(message = "No access to this host") {
super(message);
this.name = "AccessDeniedError";
}
}
class ManagerInputError extends Error {
constructor(message: string) {
super(message);
this.name = "ManagerInputError";
}
}
interface ManagerHost {
id: number;
userId: string;
sudoPassword?: string;
}
/**
* The host as the acting user may reach it. The resolved host carries the
* owner's sudo password even for a shared user: core decrypts it with the
* owner's key.
*/
async function resolveManagerHost(
ctx: PluginContext,
hostId: number,
): Promise<ManagerHost> {
const access = await ctx.hosts.checkAccess(hostId, "connect");
if (!access.hasAccess) throw new AccessDeniedError();
const host = await ctx.ssh.resolveHost(hostId);
if (!host) throw new AccessDeniedError("Host not found");
return {
id: hostId,
userId: String(host.userId ?? ""),
sudoPassword: (host.sudoPassword as string | undefined) || undefined,
};
}
function managerErrorResponse(
res: Response,
operation: string,
error: unknown,
) {
if (error instanceof ManagerInputError) {
return res.status(400).json({ error: error.message });
}
if (error instanceof AccessDeniedError) {
return res.status(403).json({ error: error.message });
}
const message = error instanceof Error ? error.message : String(error);
return res.status(500).json({ error: message || `${operation} failed` });
}
export function registerTailscaleHostMetricsManager(
router: Router,
ctx: PluginContext,
): void {
/**
* @openapi
* /plugin-api/tailscale/host-metrics-manager/{id}:
* get:
* summary: Get Tailscale status and IPs for a host
* tags:
* - Tailscale
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Tailscale installation status, running state, IPs, and peer count.
*/
router.get(
"/host-metrics-manager/:id",
async (req: Request, res: Response) => {
const hostId = parseInt(String(req.params.id), 10);
try {
const host = await resolveManagerHost(ctx, hostId);
const data = await withSshConnection(
host.id,
{ pool: "tailscale", purpose: "metrics" },
async (client) => {
const { stdout } = await execCommand(client, PROBE_CMD, 15000);
return parseTailscaleData(stdout);
},
);
res.json(data);
} catch (error) {
ctx.log.error(
"Failed to read Tailscale status",
error instanceof Error ? error : undefined,
);
managerErrorResponse(res, "tailscale_read", error);
}
},
);
/**
* @openapi
* /plugin-api/tailscale/host-metrics-manager/{id}/action:
* post:
* summary: Connect or disconnect Tailscale on a host
* tags:
* - Tailscale
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* action:
* type: string
* enum: [up, down]
* responses:
* 200:
* description: Action result.
*/
router.post(
"/host-metrics-manager/:id/action",
async (req: Request, res: Response) => {
const hostId = parseInt(String(req.params.id), 10);
try {
const host = await resolveManagerHost(ctx, hostId);
const { action } = req.body as { action: unknown };
if (!isValidTailscaleAction(action)) {
throw new ManagerInputError("Invalid action, must be 'up' or 'down'");
}
const result = await withSshConnection(
host.id,
{ pool: "tailscale", purpose: "metrics" },
(client) =>
execElevated(client, `tailscale ${action}`, host.sudoPassword, {
forceSudo: false,
timeoutMs: 30000,
}),
);
res.json({
success: result.code === 0,
output: (result.stdout + result.stderr).trim(),
});
} catch (error) {
ctx.log.error(
"Failed to run Tailscale action",
error instanceof Error ? error : undefined,
);
managerErrorResponse(res, "tailscale_action", error);
}
},
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startTailscaleService, stopTailscaleService } from "./routes.js";
import { registerTailscaleSshAuth } from "./ssh-auth-provider.js";
import { registerTailscaleHostMetricsManager } from "./host-metrics-manager.js";
import { setPluginSsh } from "./ssh.js";
export async function activate(ctx: PluginContext) {
const router = ctx.http.router<Router>();
startTailscaleService(router, ctx);
registerTailscaleHostMetricsManager(router, ctx);
setPluginSsh(ctx.ssh);
registerTailscaleSshAuth(ctx);
ctx.log.info("Tailscale routes mounted at /plugin-api/tailscale");
}
export async function deactivate() {
setPluginSsh(null);
stopTailscaleService();
}
+151
View File
@@ -0,0 +1,151 @@
import { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
interface TailscaleDevice {
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
}
interface TailscaleAPIDevice {
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
nodeId?: string;
}
const DEFAULT_TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2";
function normalizeApiBase(raw: string | null): string {
const trimmed = (raw ?? "").trim().replace(/\/+$/, "");
return trimmed || DEFAULT_TAILSCALE_API_BASE;
}
/**
* Set by activate(). Dropped on deactivate so a request arriving mid-teardown
* cannot read settings through a context the runtime has already torn down.
*/
let context: PluginContext | null = null;
/** Called from activate(). Mounts the plugin's routes on ctx.http.router(). */
export function startTailscaleService(
mountOn: Router,
ctx: PluginContext,
): void {
context = ctx;
/**
* @openapi
* /plugin-api/tailscale/devices:
* get:
* summary: List Tailscale devices
* description: >
* Returns the devices in the configured tailnet using the stored admin
* API key. Requires the tailscale.devices.view permission, because the
* response is a full inventory of the tailnet.
* tags:
* - Tailscale
* responses:
* 200:
* description: List of tailnet devices.
* 403:
* description: The caller lacks tailscale.devices.view.
* content:
* application/json:
* schema:
* type: object
* properties:
* devices:
* type: array
* items:
* type: object
* 500:
* description: Failed to fetch Tailscale devices.
*/
mountOn.get(
"/devices",
ctx.rbac.require("devices.view") as never,
async (_req, res) => {
try {
if (!context) {
return res
.status(503)
.json({ error: "Tailscale is not available", devices: [] });
}
const apiKey = (await context.settings.get<string>("apiKey")) ?? "";
if (!apiKey) {
return res.json({ devices: [], hasApiKey: false });
}
const apiBase = normalizeApiBase(
(await context.settings.get<string>("apiBaseUrl")) ?? null,
);
const url = `${apiBase}/tailnet/-/devices?fields=all`;
// The base is admin-set and may be a LAN Headscale; listing it also
// routes the call through the configured outbound proxy.
const response = await context.fetch(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
"User-Agent": "Termix/1.0",
},
allowPrivateHosts: [new URL(apiBase).hostname],
});
if (!response.ok) {
context.log.warn(
`Tailscale API returned non-OK status ${response.status}`,
);
if (response.status === 401 || response.status === 403) {
return res.status(401).json({
error: "Invalid Tailscale API key",
devices: [],
hasApiKey: true,
});
}
return res.status(502).json({
error: "Tailscale API error",
devices: [],
hasApiKey: true,
});
}
const data = (await response.json()) as {
devices: TailscaleAPIDevice[];
};
const devices: TailscaleDevice[] = (data.devices ?? []).map((d) => ({
id: d.id,
name: d.name,
hostname: d.hostname,
addresses: d.addresses ?? [],
os: d.os,
lastSeen: d.lastSeen,
}));
res.json({ devices, hasApiKey: true });
} catch (err) {
context?.log.error(
"Failed to fetch Tailscale devices",
err instanceof Error ? err : undefined,
);
res.status(500).json({
error: "Failed to fetch Tailscale devices",
devices: [],
hasApiKey: true,
});
}
},
);
}
/** Called from deactivate(). /plugin-api/tailscale/* falls back to 404 until
* reactivated. */
export function stopTailscaleService(): void {
context = null;
}
@@ -0,0 +1,76 @@
import type {
PluginContext,
PluginSshAuthProvider,
} from "@termix/plugin-sdk/backend";
import {
isTailscaleCheckCompleteBanner,
parseTailscaleCheckBanner,
TAILSCALE_CHECK_TIMEOUT_MS,
} from "./tailscale-check.js";
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
export const tailscaleSshAuthProvider: PluginSshAuthProvider = {
type: "tailscale",
labelKey: "hosts.filterAuthTailscale",
// Nothing host-specific to look up, so it works for an unsaved host.
quickConnect: true,
connectOptions: (_host, purpose) =>
purpose === "terminal"
? {
tryKeyboard: false,
readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS,
// The socket sits idle while a check-mode login is pending.
timeout: TAILSCALE_CHECK_TIMEOUT_MS,
}
: { tryKeyboard: false },
prepare: async (_config, _host, env) => {
env.log("info", "Using Tailscale SSH");
return { status: "ready" };
},
onBanner: (banner) => {
const check = parseTailscaleCheckBanner(banner);
if (check) {
return {
action: "hold",
timeoutMs: TAILSCALE_CHECK_TIMEOUT_MS,
message: `Tailscale SSH requires an additional check. Waiting for browser authentication at ${check.url}`,
details: { url: check.url, message: check.message },
};
}
if (isTailscaleCheckCompleteBanner(banner)) {
return { action: "release" };
}
return undefined;
},
onAuthFailed: (host, _env, context) => {
const failed =
context.methodNotAvailable ||
AUTH_FAILED_PATTERN.test(context.error.message);
if (!failed) return;
// Tailscale documents the "+password" suffix for clients that mishandle a
// successful "none" reply. The password value is ignored.
if (context.retries === 0 && context.canRetry) {
return {
status: "retry",
message: "Retrying Tailscale SSH in forced password mode",
patch: {
username: `${host.username}+password`,
password: "termix",
tryKeyboard: false,
},
};
}
return {
status: "error",
code: "failed",
message: `Tailscale SSH authentication failed for user "${host.username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${host.username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`,
};
},
};
export function registerTailscaleSshAuth(ctx: PluginContext): void {
ctx.auth.registerSshAuthProvider(tailscaleSshAuthProvider);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
@@ -0,0 +1,45 @@
// Tailscale SSH "check mode" sends its re-authentication prompt as an SSH auth
// banner during the "none" auth method, then long-polls its control plane while
// the connection stays open. The banner text itself comes from the control plane
// (it is not in the tailscaled source), so match on the login URL rather than the
// surrounding wording, which can change without notice.
const TAILSCALE_CHECK_URL = /https:\/\/login\.tailscale\.com\/a\/[A-Za-z0-9]+/;
const CHECK_COMPLETE = /authentication checked/i;
/** How long a Tailscale check-mode login may sit waiting for approval. */
export const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000;
export interface TailscaleCheckBanner {
url: string;
message: string;
}
function stripCommentMarkers(banner: string): string {
return banner
.split(/\r?\n/)
.map((line) => line.replace(/^\s*#\s?/, "").trim())
.filter((line) => line.length > 0)
.join("\n")
.trim();
}
export function parseTailscaleCheckBanner(
banner: string,
): TailscaleCheckBanner | null {
if (!banner) return null;
const match = banner.match(TAILSCALE_CHECK_URL);
if (!match) return null;
return {
url: match[0],
message: stripCommentMarkers(banner),
};
}
export function isTailscaleCheckCompleteBanner(banner: string): boolean {
if (!banner) return false;
return CHECK_COMPLETE.test(banner);
}
@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import {
useTranslation,
type SshAuthEditorProps,
} from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import { getTailscaleDevices } from "./tailscale-api";
interface Device {
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
}
function tailnetAddress(device: Device): string {
return (
device.addresses.find((address) => address.startsWith("100.")) ??
device.addresses[0] ??
""
);
}
/**
* The host editor's "tailscale" auth method: pick a tailnet device and its
* address fills the host's IP.
*/
export function TailscaleAuthEditor({ form, setField }: SshAuthEditorProps) {
const { t } = useTranslation();
const [devices, setDevices] = useState<Device[]>([]);
const [hasApiKey, setHasApiKey] = useState(false);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setFailed(false);
getTailscaleDevices()
.then((result) => {
if (cancelled) return;
setDevices(result?.devices ?? []);
setHasApiKey(result?.hasApiKey ?? false);
setFailed(!!result?.error);
})
.catch(() => {
if (!cancelled) setFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const ip = String(form.ip ?? "");
return (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.tailscaleDeviceSelect")}
</label>
<a
href="https://docs.termix.site/features/networking/tailscale"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.tailscaleDocsLink")}
</a>
</div>
{loading ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleLoadingDevices")}
</p>
) : failed ? (
<p className="text-[10px] text-destructive">
{t("hosts.tailscaleDeviceLoadFailed")}
</p>
) : !hasApiKey ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleNoApiKey")}
</p>
) : devices.length === 0 ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleNoDevices")}
</p>
) : (
<>
<Select2
className="w-full border border-border bg-background text-foreground text-xs px-2 py-1.5 focus:outline-none focus:border-accent-brand/50"
value={
devices.find((device) => device.addresses.includes(ip))?.id ?? ""
}
onChange={(event) => {
const device = devices.find(
(item) => item.id === event.target.value,
);
if (device) setField("ip", tailnetAddress(device));
}}
>
<option value="" disabled>
{t("hosts.tailscaleDeviceSelectPlaceholder")}
</option>
{devices.map((device) => (
<option key={device.id} value={device.id}>
{device.hostname} ({tailnetAddress(device)})
</option>
))}
</Select2>
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleDeviceAutoFill")}
</p>
</>
)}
</div>
);
}
@@ -0,0 +1,89 @@
import React from "react";
import { Button } from "@termix/plugin-sdk/ui";
import { Shield, ExternalLink, Loader2 } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface TailscaleCheckDialogProps {
isOpen: boolean;
authUrl: string;
message?: string;
stage: "prompt" | "waiting";
onCancel: () => void;
onOpenUrl: () => void;
backgroundColor?: string;
}
export function TailscaleCheckDialog({
isOpen,
authUrl,
message,
stage,
onCancel,
onOpenUrl,
backgroundColor,
}: TailscaleCheckDialogProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<Shield className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{t("terminal.tailscaleCheckRequired")}
</h3>
</div>
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
{t("terminal.tailscaleCheckDescription")}
</p>
</div>
<div className="p-4 flex flex-col gap-4">
{message && (
<p className="text-xs text-muted-foreground whitespace-pre-wrap break-words">
{message}
</p>
)}
{stage === "prompt" && authUrl && (
<Button
type="button"
variant="outline"
onClick={onOpenUrl}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2"
>
<ExternalLink className="size-3.5" />
{t("terminal.tailscaleCheckOpenBrowser")}
</Button>
)}
{stage === "waiting" && (
<div className="flex items-center gap-3 py-2">
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
<p className="text-xs text-muted-foreground">
{t("terminal.tailscaleCheckWaiting")}
</p>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
/** What the ssh-terminal plugin hands a "terminal.overlay" component. */
interface TerminalOverlayProps {
host: { id: number | string; [key: string]: unknown } | undefined;
backgroundColor?: string;
subscribe: (
listener: (message: { type: string; [key: string]: unknown }) => void,
) => () => void;
holdConnectTimeout: (held: boolean) => void;
fail: (message: string) => void;
disconnect: () => void;
}
import { TailscaleCheckDialog } from "./TailscaleCheckDialog";
/** How long Tailscale may hold a connection open waiting on approval. */
const CHECK_TIMEOUT_MS = 30 * 60 * 1000;
interface CheckState {
authUrl: string;
message?: string;
stage: "prompt" | "waiting";
}
/**
* Tailscale SSH can hold a connection open until the user approves it in a
* browser. The terminal's server messages say when, and this pauses the
* connect timeout and shows the approval link meanwhile.
*/
export function TailscaleCheckOverlay({
subscribe,
holdConnectTimeout,
fail,
disconnect,
backgroundColor,
}: TerminalOverlayProps) {
const { t } = useTranslation();
const [check, setCheck] = useState<CheckState | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const heldRef = useRef(false);
useEffect(() => {
const release = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
setCheck(null);
};
const unsubscribe = subscribe((message) => {
if (message.type === "tailscale_check_required") {
if (!heldRef.current) {
heldRef.current = true;
holdConnectTimeout(true);
}
setCheck({
authUrl: typeof message.url === "string" ? message.url : "",
message:
typeof message.message === "string" ? message.message : undefined,
stage: "prompt",
});
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
release();
fail(t("terminal.tailscaleCheckTimeout"));
}, CHECK_TIMEOUT_MS);
} else if (
message.type === "tailscale_check_completed" ||
message.type === "session_closed"
) {
release();
}
});
return () => {
unsubscribe();
release();
};
}, [subscribe, holdConnectTimeout, fail, t]);
if (!check) return null;
return (
<TailscaleCheckDialog
isOpen
authUrl={check.authUrl}
message={check.message}
stage={check.stage}
onCancel={() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
setCheck(null);
disconnect();
}}
onOpenUrl={() => {
window.open(check.authUrl, "_blank");
setCheck((current) =>
current ? { ...current, stage: "waiting" } : null,
);
}}
backgroundColor={backgroundColor}
/>
);
}
@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import {
useToast,
useTranslation,
type HostDraft,
} from "@termix/plugin-sdk/frontend";
import { Copy, Loader2, Plus, RefreshCw, Terminal } from "lucide-react";
import { Button } from "@termix/plugin-sdk/ui";
import { Input } from "@termix/plugin-sdk/ui";
import type { PluginHostRecord as Host } from "@termix/plugin-sdk/frontend";
import { getTailscaleDevices } from "./tailscale-api";
import { createQuickConnectHost } from "@termix/plugin-sdk/ui";
interface TailscaleDevice {
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
}
interface TailscaleDevicesPanelProps {
onConnect: (host: Host, type: "terminal") => void;
/** Absent when the shell cannot open the host editor. */
onAddHost?: (draft: HostDraft) => void;
}
function deviceIp(device: TailscaleDevice): string {
return (
device.addresses.find((a) => a.startsWith("100.")) ??
device.addresses[0] ??
""
);
}
export function TailscaleDevicesPanel({
onConnect,
onAddHost,
}: TailscaleDevicesPanelProps) {
const { t } = useTranslation();
const toast = useToast();
const [devices, setDevices] = useState<TailscaleDevice[]>([]);
const [hasApiKey, setHasApiKey] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [usernames, setUsernames] = useState<Record<string, string>>({});
function load() {
setLoading(true);
setError(false);
getTailscaleDevices()
.then((res) => {
setDevices(res.devices);
setHasApiKey(res.hasApiKey);
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}
useEffect(load, []);
function usernameFor(device: TailscaleDevice): string {
return usernames[device.id]?.trim() || "root";
}
function copyIp(ip: string) {
navigator.clipboard
?.writeText(ip)
.then(() => toast.success(t("hosts.tailscaleIpCopied")))
.catch(() => toast.error(t("hosts.tailscaleIpCopyFailed")));
}
function addHost(device: TailscaleDevice) {
const ip = deviceIp(device);
if (!ip || !onAddHost) return;
onAddHost({
name: device.hostname || device.name,
ip,
port: 22,
username: usernameFor(device),
authType: "tailscale",
});
}
function connect(device: TailscaleDevice) {
const ip = deviceIp(device);
if (!ip) return;
const username = usernameFor(device);
const host = createQuickConnectHost({
ip,
port: 22,
username,
authType: "tailscale",
}) as unknown as Host;
onConnect(host, "terminal");
}
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("nav.tailscale")}
</span>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={load}
disabled={loading}
>
<RefreshCw className={`size-3 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
<div className="flex flex-col gap-2 p-3">
{loading && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t("hosts.tailscaleLoadingDevices")}
</div>
)}
{!loading && !hasApiKey && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleNoApiKey")}
</p>
)}
{!loading && hasApiKey && error && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleDeviceLoadFailed")}
</p>
)}
{!loading && hasApiKey && !error && devices.length === 0 && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleNoDevices")}
</p>
)}
{!loading &&
hasApiKey &&
!error &&
devices.map((device) => {
const ip = deviceIp(device);
return (
<div
key={device.id}
className="flex flex-col gap-1.5 border border-border p-2"
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-semibold min-w-0 break-all">
{device.hostname || device.name}
</span>
{device.os && (
<span className="shrink-0 border border-border px-1 text-[10px] text-muted-foreground">
{device.os}
</span>
)}
</div>
{ip && (
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground font-mono">
{ip}
</span>
<Button
variant="ghost"
size="icon"
className="size-5 text-muted-foreground hover:text-foreground"
title={t("hosts.tailscaleCopyIp")}
aria-label={t("hosts.tailscaleCopyIp")}
onClick={() => copyIp(ip)}
>
<Copy className="size-3" />
</Button>
</div>
)}
<Input
placeholder={t("newUi.sidebar.quickConnect.usernameLabel")}
value={usernames[device.id] ?? "root"}
onChange={(e) =>
setUsernames((prev) => ({
...prev,
[device.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") connect(device);
}}
className="h-7 text-xs"
/>
<div className="flex gap-1.5">
<Button
onClick={() => connect(device)}
disabled={!ip}
className="flex flex-1 items-center justify-center gap-1.5 h-7 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
variant="outline"
>
<Terminal className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToTerminal")}
</Button>
{onAddHost && (
<Button
onClick={() => addHost(device)}
disabled={!ip}
variant="outline"
className="h-7 gap-1 text-xs shrink-0"
>
<Plus className="size-3.5" />
{t("hosts.tailscaleAddHost")}
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
export default TailscaleDevicesPanel;
@@ -0,0 +1,86 @@
/**
* The tailnet status shown on the Tailscale settings page.
*
* Confirms the saved key actually works, which is the question anyone has just
* after entering one. Connecting to a device stays in the Tailscale rail panel;
* a settings page is for configuring, not for operating.
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Loader2, RefreshCw } from "lucide-react";
import { Button } from "@termix/plugin-sdk/ui";
import type { SettingsComponentProps } from "@termix/plugin-sdk/frontend";
import { getTailscaleDevices } from "./tailscale-api";
type Status =
| { kind: "loading" }
| { kind: "no-key" }
| { kind: "error" }
| { kind: "ok"; count: number };
export function TailscaleDevicesStatus({ running }: SettingsComponentProps) {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>({ kind: "loading" });
const load = useCallback(async () => {
if (!running) {
setStatus({ kind: "no-key" });
return;
}
setStatus({ kind: "loading" });
try {
const result = await getTailscaleDevices();
if (!result.hasApiKey) {
setStatus({ kind: "no-key" });
return;
}
if (result.error) {
setStatus({ kind: "error" });
return;
}
setStatus({ kind: "ok", count: result.devices?.length ?? 0 });
} catch {
setStatus({ kind: "error" });
}
}, [running]);
useEffect(() => {
void load();
}, [load]);
const label = (key: string) => t(key);
return (
<div className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium leading-snug">
{label("settings.devices.label")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{status.kind === "loading" && label("settings.devices.checking")}
{status.kind === "no-key" && label("settings.devices.noKey")}
{status.kind === "error" && label("settings.devices.error")}
{status.kind === "ok" &&
t("settings.devices.count", {
count: status.count,
})}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs shrink-0"
disabled={!running || status.kind === "loading"}
onClick={() => void load()}
>
{status.kind === "loading" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
<span className="ml-1.5">{label("settings.devices.refresh")}</span>
</Button>
</div>
);
}
@@ -0,0 +1,161 @@
import { useState } from "react";
import { Power } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useTailscaleData, useTailscaleAction } from "./useTailscaleManager";
import { ManagerCardShell, ManagerSearch } from "@termix/plugin-sdk/ui";
interface TailscalePeer {
hostname: string;
tailscaleIPs: string[];
online: boolean;
isExitNode: boolean;
}
interface TailscaleData {
installed: boolean;
running: boolean;
tailscaleIPs: string[];
hostname: string | null;
peers: TailscalePeer[];
exitNodeInUse: boolean;
}
export function TailscaleManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } =
useTailscaleData<TailscaleData>(hostId);
const { busy, run } = useTailscaleAction(hostId);
const [query, setQuery] = useState("");
const handleToggle = async () => {
if (!data) return;
const action = data.running ? "down" : "up";
await run(action, {
loadingMsg: data.running
? t("manager.tsDisabling")
: t("manager.tsEnabling"),
successMsg: data.running
? t("manager.tsDisabled")
: t("manager.tsEnabled"),
onDone: refresh,
});
};
const filteredPeers = (data?.peers ?? []).filter(
(p) =>
!query ||
p.hostname.toLowerCase().includes(query.toLowerCase()) ||
p.tailscaleIPs.some((ip) => ip.includes(query)),
);
const isNotInstalled = data && !data.installed;
return (
<ManagerCardShell
title={t("manager.title")}
icon={<Power className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!!isNotInstalled}
emptyMessage={t("manager.tsNotInstalled")}
>
{data && (
<div className="flex flex-col">
{/* Status + toggle row */}
<div className="flex items-center justify-between border-b border-border/50 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<span
className={`size-1.5 shrink-0 rounded-full ${data.running ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold">
{data.running
? t("manager.tsRunning")
: t("manager.tsStopped")}
</span>
{data.hostname && (
<span className="truncate font-mono text-[10px] text-muted-foreground">
{data.hostname}
</span>
)}
{data.tailscaleIPs.map((ip) => (
<span
key={ip}
className="truncate font-mono text-[10px] text-muted-foreground/60"
>
{ip}
</span>
))}
</div>
</div>
<div className="flex shrink-0 items-center">
<button
onClick={handleToggle}
disabled={busy || !data.installed}
title={
data.running ? t("manager.tsDisable") : t("manager.tsEnable")
}
className={`flex size-6 items-center justify-center transition-colors disabled:opacity-40 ${
data.running
? "text-accent-brand hover:bg-muted hover:text-destructive"
: "text-muted-foreground hover:bg-muted hover:text-accent-brand"
}`}
>
<Power className="size-3.5" />
</button>
</div>
</div>
{/* Exit node badge */}
{data.exitNodeInUse && (
<div className="border-b border-border/50 px-0 py-1.5">
<span className="text-[10px] text-blue-500">
{t("manager.tsExitNodeActive")}
</span>
</div>
)}
{/* Peers */}
{(data.peers?.length ?? 0) > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={filteredPeers.length}
/>
)}
{filteredPeers.map((peer) => (
<div
key={peer.hostname}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 items-center gap-2">
<span
className={`size-1.5 shrink-0 rounded-full ${peer.online ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-semibold">
{peer.hostname}
</span>
{peer.tailscaleIPs.map((ip) => (
<span
key={ip}
className="truncate font-mono text-[10px] text-muted-foreground"
>
{ip}
</span>
))}
</div>
</div>
{peer.isExitNode && (
<span className="shrink-0 text-[10px] text-blue-500">
{t("manager.tsExitNode")}
</span>
)}
</div>
))}
</div>
)}
</ManagerCardShell>
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { ComponentType } from "react";
import { Radar } from "lucide-react";
import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
import type { PluginHostRecord as Host } from "@termix/plugin-sdk/frontend";
import { TailscaleDevicesPanel } from "./TailscaleDevicesPanel";
import { setTailscaleApi } from "./tailscale-api";
import { TailscaleDevicesStatus } from "./TailscaleDevicesStatus";
import { TailscaleAuthEditor } from "./TailscaleAuthEditor";
import { TailscaleCheckOverlay } from "./TailscaleCheckOverlay";
import { TailscaleManagerCard } from "./TailscaleManagerCard";
function Panel({ shell }: PanelProps) {
return (
<TailscaleDevicesPanel
onConnect={(host: Host, type) =>
shell.openTab(
host as unknown as Parameters<typeof shell.openTab>[0],
type,
)
}
onAddHost={
shell.openHostEditor
? (draft) => shell.openHostEditor!(draft)
: undefined
}
/>
);
}
export function activate(app: TermixApp): void {
setTailscaleApi(app.api);
app.onDispose(() => setTailscaleApi(null));
app.registerRailItem({
id: "tailscale",
icon: Radar,
titleKey: "nav.tailscale",
after: "macros",
order: 20,
});
app.registerPanel("tailscale", Panel);
app.registerSettingsComponent("devices", TailscaleDevicesStatus);
// "tailscale" as a host's SSH auth method: pick a device from the tailnet.
app.registerSshAuthEditor({
authType: "tailscale",
titleKey: "nav.tailscale",
hintKey: "hosts.tailscaleUsernameHint",
component: TailscaleAuthEditor,
});
// Approval prompts while Tailscale SSH holds a connection open.
app.registerSlotContribution("terminal.overlay", {
actionId: "tailscale.check",
titleKey: "terminal.tailscaleCheckRequired",
kind: "component",
component: TailscaleCheckOverlay as unknown as ComponentType<
Record<string, unknown>
>,
});
// The Host Metrics manager card, present only when host-metrics is running.
app.registerSlotContribution("host-metrics.managers", {
actionId: "tailscale_manager",
titleKey: "manager.title",
kind: "component",
component: TailscaleManagerCard as unknown as ComponentType<
Record<string, unknown>
>,
});
}
@@ -0,0 +1,46 @@
// The API key and base URL are this plugin's own settings, read and written
// through the generic /plugins/tailscale/settings/admin routes that core
// renders from the manifest. Only the device list is left here, because it is
// this plugin's own route.
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
export interface TailscaleDevicesResponse {
devices: Array<{
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
}>;
hasApiKey: boolean;
error?: string;
}
let pluginApi: PluginApiClient | null = null;
/** Set from activate with app.api, cleared on deactivate. */
export function setTailscaleApi(api: PluginApiClient | null): void {
pluginApi = api;
}
export async function getTailscaleDevices(): Promise<TailscaleDevicesResponse> {
if (!pluginApi) throw new Error("The Tailscale plugin is not active");
try {
const response = await pluginApi.get<TailscaleDevicesResponse>("/devices");
return response.data;
} catch (error) {
// The route answers with the key state even when the tailnet call fails.
const data = (error as { response?: { data?: unknown } })?.response?.data;
if (
data &&
typeof data === "object" &&
"hasApiKey" in data &&
typeof (data as { hasApiKey: unknown }).hasApiKey === "boolean"
) {
return data as TailscaleDevicesResponse;
}
throw error;
}
}
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { usePluginApi } from "@termix/plugin-sdk/frontend";
export interface ManagerError {
message: string;
code?: string;
}
function extractError(err: unknown): ManagerError {
const e = err as {
response?: { data?: { error?: string; code?: string } };
message?: string;
};
return {
message: e?.response?.data?.error || e?.message || "Request failed",
code: e?.response?.data?.code,
};
}
/** Fetch the Tailscale manager card's status on mount + manual refresh. */
export function useTailscaleData<T>(hostId: number | null) {
const api = usePluginApi();
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ManagerError | null>(null);
const refresh = useCallback(async () => {
if (hostId == null) return;
setLoading(true);
setError(null);
try {
const res = await api.get<T>(`/host-metrics-manager/${hostId}`);
setData(res.data);
} catch (err) {
setError(extractError(err));
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId]);
useEffect(() => {
refresh();
}, [refresh]);
return { data, loading, error, refresh };
}
interface ActionResult {
success: boolean;
output?: string;
}
/** Runs the Tailscale manager card's up/down action with toast feedback. */
export function useTailscaleAction(hostId: number | null) {
const api = usePluginApi();
const [busy, setBusy] = useState(false);
const run = useCallback(
async (
action: "up" | "down",
opts: {
loadingMsg?: string;
successMsg?: string;
failMsg?: string;
onDone?: () => void;
} = {},
): Promise<ActionResult | null> => {
if (hostId == null) return null;
const id = `tailscale-manager-${hostId}`;
setBusy(true);
if (opts.loadingMsg) toast.loading(opts.loadingMsg, { id });
try {
const res = await api.post<ActionResult>(
`/host-metrics-manager/${hostId}/action`,
{ action },
);
if (res.data.success) {
if (opts.successMsg)
toast.success(opts.successMsg, {
id,
description: res.data.output?.slice(-200),
});
else toast.dismiss(id);
opts.onDone?.();
} else {
toast.error(opts.failMsg ?? res.data.output ?? "Action failed", {
id,
description: res.data.output?.slice(-200),
});
}
return res.data;
} catch (err) {
toast.error(extractError(err).message, { id });
return null;
} finally {
setBusy(false);
}
},
[hostId],
);
return { busy, run };
}
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { parseTailscaleData } from "../../src/backend/host-metrics-manager.js";
describe("parseTailscaleData", () => {
it("reports not installed when the probe finds no binary", () => {
const result = parseTailscaleData("ts_installed=0\n");
expect(result.installed).toBe(false);
expect(result.running).toBe(false);
});
it("parses a running tailscale status", () => {
const status = JSON.stringify({
BackendState: "Running",
Self: { HostName: "my-server", TailscaleIPs: ["100.1.2.3"] },
Peer: {
abc: {
HostName: "peer-1",
TailscaleIPs: ["100.1.2.4"],
Online: true,
ExitNode: false,
},
},
CurrentExitNode: "",
});
const result = parseTailscaleData(`ts_installed=1\n${status}`);
expect(result.installed).toBe(true);
expect(result.running).toBe(true);
expect(result.hostname).toBe("my-server");
expect(result.tailscaleIPs).toEqual(["100.1.2.3"]);
expect(result.peers).toHaveLength(1);
expect(result.peers[0]).toMatchObject({
hostname: "peer-1",
online: true,
});
expect(result.exitNodeInUse).toBe(false);
});
it("reports installed but not running on malformed JSON", () => {
const result = parseTailscaleData("ts_installed=1\nnot json");
expect(result.installed).toBe(true);
expect(result.running).toBe(false);
});
it("detects an active exit node", () => {
const status = JSON.stringify({
BackendState: "Running",
Self: {},
Peer: {},
CurrentExitNode: "abc123",
});
const result = parseTailscaleData(`ts_installed=1\n${status}`);
expect(result.exitNodeInUse).toBe(true);
});
});
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { parseManifest, qualifyPermission } from "@termix/plugin-sdk/manifest";
/**
* The permission ids an admin grants.
*
* Core registers each declared name as `<pluginId>.<name>`, so these strings
* are what end up stored in a role. They have to keep matching what tailscale
* shipped before the permissions moved out of the core catalog, or every role
* holding one would silently stop working.
*/
const EXPECTED = ["tailscale.devices.view"];
describe("tailscale permissions", () => {
const manifest = parseManifest(
JSON.parse(
fs.readFileSync(
path.resolve(import.meta.dirname, "../../manifest.json"),
"utf8",
),
),
);
it("parses cleanly", () => {
expect(manifest.errors).toEqual([]);
});
it("registers exactly the ids roles already hold", () => {
const ids = (manifest.manifest?.contributes?.permissions ?? []).map(
(permission) => qualifyPermission("tailscale", permission.name),
);
expect(ids).toEqual(EXPECTED);
});
it("declares a title and description for every permission", () => {
for (const permission of manifest.manifest?.contributes?.permissions ??
[]) {
expect(permission.titleKey).toBeTruthy();
expect(permission.descriptionKey).toBeTruthy();
}
});
// The device list is a full tailnet inventory, so only admins get it by
// default; everyone else needs an explicit grant.
it("grants devices.view to admins by default", () => {
const devicesView = manifest.manifest?.contributes?.permissions?.find(
(permission) => permission.name === "devices.view",
);
expect(devicesView?.defaultRoles).toEqual(["admin"]);
});
});
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { parseManifest } from "@termix/plugin-sdk/manifest";
import { createMockCtx } from "@termix/plugin-sdk/testing";
/**
* The API key and base URL are this plugin's own settings now.
*
* They used to live in the core `settings` table behind a core route that
* existed only for this plugin, with the key stored in plaintext. These tests
* pin the shape core renders the page from, and that reading a plugin's own
* settings needs no capability.
*/
const manifest = parseManifest(
JSON.parse(
fs.readFileSync(
path.resolve(import.meta.dirname, "../../manifest.json"),
"utf8",
),
),
);
describe("tailscale settings contribution", () => {
it("parses cleanly", () => {
expect(manifest.errors).toEqual([]);
});
it("declares the API key as a secret, so it is encrypted and redacted", () => {
const apiKey = manifest.manifest?.contributes?.settings?.admin?.find(
(field) => field.key === "apiKey",
);
expect(apiKey?.type).toBe("secret");
});
it("declares the base URL as plain text, because it is not a secret", () => {
const baseUrl = manifest.manifest?.contributes?.settings?.admin?.find(
(field) => field.key === "apiBaseUrl",
);
expect(baseUrl?.type).toBe("string");
expect(baseUrl?.default).toBe("");
});
it("puts both fields on the admin scope, not the user one", () => {
const settings = manifest.manifest?.contributes?.settings;
expect(settings?.admin?.map((field) => field.key)).toContain("apiKey");
expect(settings?.user).toBeUndefined();
});
it("keeps settings:read-core, which it still holds for core keys", () => {
expect(manifest.manifest?.capabilities).toContain("settings:read-core");
});
});
describe("reading its own settings", () => {
it("needs no capability", async () => {
// Nothing granted at all: a plugin reading its own configuration must not
// have to declare anything for it.
const { ctx } = createMockCtx({
pluginId: "tailscale",
capabilities: [],
settings: { apiKey: "tskey-api-test", apiBaseUrl: "" },
});
await expect(ctx.settings.get("apiKey")).resolves.toBe("tskey-api-test");
});
it("can write and read a value back", async () => {
const { ctx } = createMockCtx({ pluginId: "tailscale", capabilities: [] });
await ctx.settings.set("apiBaseUrl", "https://headscale.example");
await expect(ctx.settings.get("apiBaseUrl")).resolves.toBe(
"https://headscale.example",
);
});
it("still needs settings:read-core to reach a core key", async () => {
const { ctx } = createMockCtx({ pluginId: "tailscale", capabilities: [] });
await expect(ctx.settings.readCore("app_name")).rejects.toThrow(
/settings:read-core/,
);
});
});
@@ -0,0 +1,181 @@
import { describe, it, expect } from "vitest";
import { createFakeContext } from "@termix/plugin-sdk/testing";
import { registerTailscaleSshAuth } from "../../src/backend/ssh-auth-provider.js";
import { TAILSCALE_CHECK_TIMEOUT_MS } from "../../src/backend/tailscale-check.js";
function register() {
const { ctx, auth } = createFakeContext({ pluginId: "tailscale" });
registerTailscaleSshAuth(ctx);
return auth.sshAuthProviders.find(
(provider) => provider.type === "tailscale",
)!;
}
describe("tailscale SSH auth provider", () => {
it("registers as type tailscale", () => {
expect(register()).toBeTruthy();
});
it("turns off keyboard-interactive, with a long timeout for the terminal", () => {
const provider = register();
const terminal = provider.connectOptions?.(
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
"terminal",
);
expect(terminal).toMatchObject({
tryKeyboard: false,
readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS,
timeout: TAILSCALE_CHECK_TIMEOUT_MS,
});
const fleet = provider.connectOptions?.(
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
"fleet",
);
expect(fleet).toEqual({ tryKeyboard: false });
});
it("prepare always reports ready", async () => {
const provider = register();
const outcome = await provider.prepare(
{},
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
{
client: {},
userId: "u1",
hostId: 1,
purpose: "terminal",
interactive: true,
log: () => {},
},
);
expect(outcome).toEqual({ status: "ready" });
});
describe("onBanner", () => {
it("holds the connection when the banner carries a check URL", () => {
const provider = register();
const decision = provider.onBanner?.(
"# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/abc123\n",
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
{
client: {},
userId: "u1",
hostId: 1,
purpose: "terminal",
interactive: true,
log: () => {},
},
);
expect(decision).toMatchObject({
action: "hold",
details: { url: "https://login.tailscale.com/a/abc123" },
});
});
it("releases the hold on the completion banner", () => {
const provider = register();
const decision = provider.onBanner?.(
"Authentication checked with Tailscale SSH.",
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
{
client: {},
userId: "u1",
hostId: 1,
purpose: "terminal",
interactive: true,
log: () => {},
},
);
expect(decision).toEqual({ action: "release" });
});
it("ignores an ordinary MOTD banner", () => {
const provider = register();
const decision = provider.onBanner?.(
"Welcome to Ubuntu 24.04 LTS\n",
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
{
client: {},
userId: "u1",
hostId: 1,
purpose: "terminal",
interactive: true,
log: () => {},
},
);
expect(decision).toBeUndefined();
});
});
describe("onAuthFailed", () => {
it("retries once in forced password mode", () => {
const provider = register();
const outcome = provider.onAuthFailed?.(
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
{
client: {},
userId: "u1",
hostId: 1,
purpose: "terminal",
interactive: true,
log: () => {},
},
{
error: new Error("All configured authentication methods failed"),
retries: 0,
canRetry: true,
methodNotAvailable: false,
},
);
expect(outcome).toMatchObject({
status: "retry",
patch: { username: "root+password" },
});
});
it("fails after a retry has already happened", () => {
const provider = register();
const outcome = provider.onAuthFailed?.(
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
{
client: {},
userId: "u1",
hostId: 1,
purpose: "terminal",
interactive: true,
log: () => {},
},
{
error: new Error("All configured authentication methods failed"),
retries: 1,
canRetry: true,
methodNotAvailable: false,
},
);
expect(outcome).toMatchObject({ status: "error", code: "failed" });
});
it("does nothing for an unrelated failure", () => {
const provider = register();
const outcome = provider.onAuthFailed?.(
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
{
client: {},
userId: "u1",
hostId: 1,
purpose: "terminal",
interactive: true,
log: () => {},
},
{
error: new Error("connection reset"),
retries: 0,
canRetry: true,
methodNotAvailable: false,
},
);
expect(outcome).toBeUndefined();
});
});
});
@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import {
parseTailscaleCheckBanner,
isTailscaleCheckCompleteBanner,
} from "../../src/backend/tailscale-check.js";
describe("parseTailscaleCheckBanner", () => {
it("extracts the login URL from a real check-mode banner", () => {
const banner =
"# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/lefcb2f3377403\n";
const result = parseTailscaleCheckBanner(banner);
expect(result).not.toBeNull();
expect(result?.url).toBe("https://login.tailscale.com/a/lefcb2f3377403");
});
it("strips comment markers from the message it returns", () => {
const banner =
"# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/abc123\n";
const result = parseTailscaleCheckBanner(banner);
expect(result?.message).toBe(
"Tailscale SSH requires an additional check.\nTo authenticate, visit: https://login.tailscale.com/a/abc123",
);
});
it("returns null for an ordinary MOTD banner", () => {
const banner =
"Welcome to Ubuntu 24.04 LTS\nLast login: Tue Aug 5 09:12:03 2026\n";
expect(parseTailscaleCheckBanner(banner)).toBeNull();
});
it("returns null for a lookalike URL on another host", () => {
const banner =
"# To authenticate, visit: https://login.tailscale.com.evil.example/a/abc123\n";
expect(parseTailscaleCheckBanner(banner)).toBeNull();
});
it("returns null for empty input", () => {
expect(parseTailscaleCheckBanner("")).toBeNull();
});
});
describe("isTailscaleCheckCompleteBanner", () => {
it("recognises the completion banner", () => {
expect(
isTailscaleCheckCompleteBanner(
"# Authentication checked with Tailscale SSH.",
),
).toBe(true);
});
it("recognises the completion banner with a time suffix", () => {
expect(
isTailscaleCheckCompleteBanner(
"Authentication checked with Tailscale SSH. Time since last authentication: 0s",
),
).toBe(true);
});
it("does not match the check-required banner", () => {
expect(
isTailscaleCheckCompleteBanner(
"# Tailscale SSH requires an additional check.",
),
).toBe(false);
});
it("does not match empty input", () => {
expect(isTailscaleCheckCompleteBanner("")).toBe(false);
});
});
@@ -0,0 +1,148 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const getTailscaleDevicesMock = vi.hoisted(() => vi.fn());
vi.mock("../../src/frontend/tailscale-api", () => ({
getTailscaleDevices: getTailscaleDevicesMock,
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<object>()),
useTranslation: () => ({ t: (key: string) => key }),
}));
import { TailscaleDevicesPanel } from "../../src/frontend/TailscaleDevicesPanel";
beforeEach(() => {
vi.clearAllMocks();
});
describe("TailscaleDevicesPanel", () => {
it("shows the no-api-key message when hasApiKey is false", async () => {
getTailscaleDevicesMock.mockResolvedValue({
devices: [],
hasApiKey: false,
});
render(<TailscaleDevicesPanel onConnect={vi.fn()} />);
await waitFor(() =>
expect(screen.getByText("hosts.tailscaleNoApiKey")).toBeTruthy(),
);
});
it("shows the empty-devices message when the tailnet has no devices", async () => {
getTailscaleDevicesMock.mockResolvedValue({ devices: [], hasApiKey: true });
render(<TailscaleDevicesPanel onConnect={vi.fn()} />);
await waitFor(() =>
expect(screen.getByText("hosts.tailscaleNoDevices")).toBeTruthy(),
);
});
it("renders devices and connects with an ephemeral tailscale host", async () => {
const user = userEvent.setup();
getTailscaleDevicesMock.mockResolvedValue({
hasApiKey: true,
devices: [
{
id: "d1",
name: "box",
hostname: "box",
addresses: ["100.64.0.1"],
os: "linux",
lastSeen: "2026-01-01T00:00:00Z",
},
],
});
const onConnect = vi.fn();
render(<TailscaleDevicesPanel onConnect={onConnect} />);
await waitFor(() => expect(screen.getByText("box")).toBeTruthy());
await user.click(
screen.getByText("newUi.sidebar.quickConnect.connectToTerminal"),
);
expect(onConnect).toHaveBeenCalledOnce();
const [host, type] = onConnect.mock.calls[0];
expect(type).toBe("terminal");
expect(host.ip).toBe("100.64.0.1");
expect(host.authType).toBe("tailscale");
expect(host.username).toBe("root");
});
const device = {
id: "d1",
name: "box.tailnet.ts.net",
hostname: "box",
addresses: ["100.64.0.1", "fd7a::1"],
os: "linux",
lastSeen: "2026-01-01T00:00:00Z",
};
it("opens the host editor with the device filled in", async () => {
const user = userEvent.setup();
getTailscaleDevicesMock.mockResolvedValue({
hasApiKey: true,
devices: [device],
});
const onAddHost = vi.fn();
render(<TailscaleDevicesPanel onConnect={vi.fn()} onAddHost={onAddHost} />);
await waitFor(() => expect(screen.getByText("box")).toBeTruthy());
await user.clear(
screen.getByPlaceholderText("newUi.sidebar.quickConnect.usernameLabel"),
);
await user.type(
screen.getByPlaceholderText("newUi.sidebar.quickConnect.usernameLabel"),
"luke",
);
await user.click(screen.getByText("hosts.tailscaleAddHost"));
expect(onAddHost).toHaveBeenCalledWith({
name: "box",
ip: "100.64.0.1",
port: 22,
username: "luke",
authType: "tailscale",
});
});
it("hides Add host when the shell cannot open the editor", async () => {
getTailscaleDevicesMock.mockResolvedValue({
hasApiKey: true,
devices: [device],
});
render(<TailscaleDevicesPanel onConnect={vi.fn()} />);
await waitFor(() => expect(screen.getByText("box")).toBeTruthy());
expect(screen.queryByText("hosts.tailscaleAddHost")).toBeNull();
});
it("copies the tailnet IP", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
getTailscaleDevicesMock.mockResolvedValue({
hasApiKey: true,
devices: [device],
});
render(<TailscaleDevicesPanel onConnect={vi.fn()} />);
await waitFor(() => expect(screen.getByText("box")).toBeTruthy());
expect(screen.getByText("linux")).toBeTruthy();
await user.click(screen.getByLabelText("hosts.tailscaleCopyIp"));
expect(writeText).toHaveBeenCalledWith("100.64.0.1");
});
});
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it } from "vitest";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const declared = (views?: Array<{ id: string }>) =>
(views ?? []).map((view) => view.id).sort();
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("activates and registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const contributes = manifest.contributes as unknown as Record<
string,
Array<{ id: string }> | undefined
>;
const tabs = declared(contributes.tabs);
// A rail panel may reuse one of the plugin's tab ids.
const panels = [...declared(contributes.panels), ...tabs];
const cards = declared(contributes.dashboardCards);
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
for (const id of rendered.registered.dashboardCards()) {
expect(cards).toContain(id);
}
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.hostActions()).toEqual([]);
expect(app.registered.hostEditorSections()).toEqual([]);
expect(app.registered.dashboardCards()).toEqual([]);
expect(app.registered.settingsComponents()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
});
});
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getTailscaleDevices,
setTailscaleApi,
} from "../../src/frontend/tailscale-api";
const api = { get: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
setTailscaleApi(api as never);
});
describe("getTailscaleDevices", () => {
it("reads the plugin's own devices route", async () => {
api.get.mockResolvedValue({ data: { devices: [], hasApiKey: false } });
await expect(getTailscaleDevices()).resolves.toEqual({
devices: [],
hasApiKey: false,
});
expect(api.get).toHaveBeenCalledWith("/devices");
});
it("preserves configured-key state when discovery fails", async () => {
api.get.mockRejectedValue({
isAxiosError: true,
response: {
data: {
devices: [],
hasApiKey: true,
error: "Failed to fetch Tailscale devices",
},
},
});
await expect(getTailscaleDevices()).resolves.toEqual({
devices: [],
hasApiKey: true,
error: "Failed to fetch Tailscale devices",
});
});
it("rethrows when no structured state is available", async () => {
const error = { isAxiosError: true, response: { data: {} } };
api.get.mockRejectedValue(error);
await expect(getTailscaleDevices()).rejects.toBe(error);
});
it("refuses while the plugin is off", async () => {
setTailscaleApi(null);
await expect(getTailscaleDevices()).rejects.toThrow(/not active/);
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);