mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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:
4405 files changed
+1341026
-289722
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
First release, bundled with Termix 2.9.0.
|
||||
|
||||
Manages Docker and Podman containers over SSH. List, inspect, start, stop and remove containers, read their logs and open a console inside them.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Docker
|
||||
|
||||
Manage Docker and Podman containers over SSH, with logs and a container console.
|
||||
|
||||
## Features
|
||||
|
||||
- List, inspect, start, stop, restart, pause and remove containers.
|
||||
- Read and download container logs.
|
||||
- Open an interactive console inside a container.
|
||||
- Works with Docker or Podman.
|
||||
- Adds a Docker widget to the homepage.
|
||||
|
||||
## Settings
|
||||
|
||||
### Host
|
||||
|
||||
- Enable Docker: show the Docker tab for this host.
|
||||
- Container Runtime: Docker or Podman.
|
||||
|
||||
## Permissions
|
||||
|
||||
- `docker.use`: Use Docker. Admins and users have it by default.
|
||||
|
||||
## Services
|
||||
|
||||
Provides to other plugins:
|
||||
|
||||
- `docker.containers`: list containers and start, stop, restart, pause, unpause or remove them.
|
||||
- `docker.events`: get notified when a container starts, exits, restarts or turns unhealthy.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run build # build into dist/
|
||||
npm run test # run this plugin's tests
|
||||
npm run typecheck # type-check this plugin
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker is not enabled for this host",
|
||||
"validating": "Validating Docker...",
|
||||
"connecting": "Connecting...",
|
||||
"error": "Error",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Failed to connect to Docker",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container {{name}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||
"image": "Image",
|
||||
"ports": "Ports",
|
||||
"noPorts": "No ports",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||
"loadingContainers": "Loading containers...",
|
||||
"manager": "Docker Manager",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
"timestamps": "Timestamps",
|
||||
"lines": "Lines",
|
||||
"filterLogs": "Filter logs...",
|
||||
"refresh": "Refresh",
|
||||
"download": "Download",
|
||||
"clear": "Clear",
|
||||
"logsDownloaded": "Logs downloaded successfully",
|
||||
"last50": "Last 50",
|
||||
"last100": "Last 100",
|
||||
"last500": "Last 500",
|
||||
"last1000": "Last 1000",
|
||||
"allLogs": "All Logs",
|
||||
"noLogsMatching": "No logs matching \"{{query}}\"",
|
||||
"noLogsAvailable": "No logs available",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||
"searchPlaceholder": "Search containers...",
|
||||
"allStatuses": "All Statuses",
|
||||
"stateRunning": "Running",
|
||||
"statePaused": "Paused",
|
||||
"stateExited": "Exited",
|
||||
"stateRestarting": "Restarting",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||
"failedToFetchStats": "Failed to fetch container statistics",
|
||||
"containerNotRunning": "Container not running",
|
||||
"startContainerToViewStats": "Start the container to view statistics",
|
||||
"loadingStats": "Loading statistics...",
|
||||
"errorLoadingStats": "Error loading statistics",
|
||||
"noStatsAvailable": "No statistics available",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"current": "Current",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"networkIo": "Network I/O",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"blockIo": "Block I/O",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Container Information",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"containerMustBeRunning": "Container must be running to access console",
|
||||
"verificationCodePrompt": "Enter verification code",
|
||||
"totpVerificationFailed": "TOTP verification failed. Please try again.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Disconnected",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "Failed to connect to container",
|
||||
"console": "Console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click connect to start a shell session",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to List",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console",
|
||||
"noHostSelected": "No host selected",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Retry",
|
||||
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
||||
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Enable Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Choose the CLI used for container management on this host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker is nie vir hierdie gasheer geaktiveer nie",
|
||||
"validating": "Valideer Docker...",
|
||||
"connecting": "Verbind...",
|
||||
"error": "Fout",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Kon nie aan Docker koppel nie",
|
||||
"containerStarted": "Houer {{name}} het begin",
|
||||
"failedToStartContainer": "Kon nie houer {{name}} begin nie",
|
||||
"containerStopped": "Houer {{name}} gestop",
|
||||
"failedToStopContainer": "Kon nie houer {{name}} stop nie",
|
||||
"containerRestarted": "Houer {{name}} herbegin",
|
||||
"failedToRestartContainer": "Kon nie houer {{name}} herbegin nie",
|
||||
"containerPaused": "Houer {{name}} gepouseer",
|
||||
"containerUnpaused": "Houer {{name}} ontpoos",
|
||||
"failedToTogglePauseContainer": "Kon nie pousestatus vir houer {{name}} wissel nie",
|
||||
"containerRemoved": "Houer {{name}} is verwyder",
|
||||
"failedToRemoveContainer": "Kon nie houer {{name}} verwyder nie",
|
||||
"image": "Beeld",
|
||||
"ports": "Hawens",
|
||||
"noPorts": "Geen poorte nie",
|
||||
"start": "Begin",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Aksies",
|
||||
"cardView": "Kaartaansig",
|
||||
"listView": "Lysaansig",
|
||||
"confirmRemoveContainer": "Is jy seker jy wil die houer '{{name}}' verwyder? Hierdie aksie kan nie ongedaan gemaak word nie.",
|
||||
"runningContainerWarning": "Waarskuwing: Hierdie houer loop tans. As dit verwyder word, sal die houer eers gestop word.",
|
||||
"loadingContainers": "Laai houers...",
|
||||
"manager": "Docker Bestuurder",
|
||||
"autoRefresh": "Outomatiese Herlaai",
|
||||
"timestamps": "Tydstempels",
|
||||
"lines": "Lyne",
|
||||
"filterLogs": "Filtreer logboeke...",
|
||||
"refresh": "Verfris",
|
||||
"download": "Laai af",
|
||||
"clear": "Duidelik",
|
||||
"logsDownloaded": "Logboeke suksesvol afgelaai",
|
||||
"last50": "Laaste 50",
|
||||
"last100": "Laaste 100",
|
||||
"last500": "Laaste 500",
|
||||
"last1000": "Laaste 1000",
|
||||
"allLogs": "Alle logboeke",
|
||||
"noLogsMatching": "Geen logboeke wat ooreenstem met \"{{query}}\"",
|
||||
"noLogsAvailable": "Geen logboeke beskikbaar nie",
|
||||
"noContainersFound": "Geen houers gevind nie",
|
||||
"noContainersFoundHint": "Geen Docker-houers is op hierdie gasheer beskikbaar nie",
|
||||
"searchPlaceholder": "Soek houers...",
|
||||
"allStatuses": "Alle statusse",
|
||||
"stateRunning": "Hardloop",
|
||||
"statePaused": "Onderbreek",
|
||||
"stateExited": "Uitgegaan",
|
||||
"stateRestarting": "Herbegin",
|
||||
"noContainersMatchFilters": "Geen houers stem ooreen met jou filters nie",
|
||||
"noContainersMatchFiltersHint": "Probeer om jou soek- of filterkriteria aan te pas",
|
||||
"failedToFetchStats": "Kon nie houerstatistieke haal nie",
|
||||
"containerNotRunning": "Houer loop nie",
|
||||
"startContainerToViewStats": "Begin die houer om statistieke te sien",
|
||||
"loadingStats": "Laai statistieke...",
|
||||
"errorLoadingStats": "Fout by die laai van statistieke",
|
||||
"noStatsAvailable": "Geen statistieke beskikbaar nie",
|
||||
"cpuUsage": "SVE-gebruik",
|
||||
"current": "Huidige",
|
||||
"memoryUsage": "Geheuegebruik",
|
||||
"networkIo": "Netwerk I/O",
|
||||
"input": "Invoer",
|
||||
"output": "Uitset",
|
||||
"blockIo": "Blok I/O",
|
||||
"read": "Lees",
|
||||
"write": "Skryf",
|
||||
"pids": "PID's",
|
||||
"containerInformation": "Houerinligting",
|
||||
"name": "Naam",
|
||||
"id": "ID",
|
||||
"state": "Staat",
|
||||
"containerMustBeRunning": "Die houer moet loop om toegang tot die konsole te verkry",
|
||||
"verificationCodePrompt": "Voer verifikasiekode in",
|
||||
"totpVerificationFailed": "TOTP-verifikasie het misluk. Probeer asseblief weer.",
|
||||
"browserSignInFailed": "Aanmelding het misluk. Probeer asseblief weer.",
|
||||
"connectedTo": "Gekoppel aan {{containerName}}",
|
||||
"disconnected": "Ontkoppel",
|
||||
"consoleError": "Konsolefout",
|
||||
"errorMessage": "Fout: {{message}}",
|
||||
"failedToConnect": "Kon nie aan houer koppel nie",
|
||||
"console": "Konsole",
|
||||
"selectShell": "Kies dop",
|
||||
"bash": "Bash",
|
||||
"sh": "sj",
|
||||
"ash": "as",
|
||||
"connect": "Verbind",
|
||||
"disconnect": "Ontkoppel",
|
||||
"notConnected": "Nie gekoppel nie",
|
||||
"clickToConnect": "Klik verbind om 'n dopsessie te begin",
|
||||
"connectingTo": "Verbind met {{containerName}}...",
|
||||
"containerNotFound": "Houer nie gevind nie",
|
||||
"backToList": "Terug na lys",
|
||||
"logs": "Logboeke",
|
||||
"stats": "Statistiek",
|
||||
"consoleTab": "Konsole",
|
||||
"startContainerToAccess": "Begin die houer om toegang tot die konsole te verkry",
|
||||
"noHostSelected": "Geen gasheer gekies nie",
|
||||
"failedToFetchLogs": "Kon nie logboeke haal nie: {{error}}",
|
||||
"failedToDownloadLogs": "Kon nie logboeke aflaai nie: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Sien houers, lees logs en kyk na statistieke op enige gasheer wat Docker gebruik."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Probeer weer",
|
||||
"clipboardReadFailed": "Kon nie vanaf knipbord lees nie. Maak seker dat knipbordtoestemmings toegestaan is.",
|
||||
"clipboardWriteFailed": "Kon nie na knipbord kopieer nie. Maak seker dat die bladsy oor HTTPS of localhost bedien word."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Gebruik Docker",
|
||||
"description": "Maak die Docker-bestuurder en houerkonsoles op gashere oop met Docker geaktiveer."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Aktiveer Docker",
|
||||
"description": "Bestuur houers op hierdie gasheer."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Houerlooptyd",
|
||||
"description": "Kies die CLI wat vir houerbestuur op hierdie gasheer gebruik word",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Afstandbediener word benodig. Koppel 'n afstandbediener in Instellings om hierdie verbindingstipe te gebruik."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Bekyk dokumente",
|
||||
"hostNotFound": "Gasheer nie gevind nie",
|
||||
"loadingHost": "Laai gasheer..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker غير مفعّل لهذا المضيف",
|
||||
"validating": "جارٍ التحقق من Docker...",
|
||||
"connecting": "جارٍ الاتصال...",
|
||||
"error": "خطأ",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "فشل الاتصال بـ Docker",
|
||||
"containerStarted": "بدأت الحاوية {{name}}",
|
||||
"failedToStartContainer": "فشل تشغيل الحاوية {{name}}",
|
||||
"containerStopped": "توقفت الحاوية {{name}}",
|
||||
"failedToStopContainer": "فشل إيقاف الحاوية {{name}}",
|
||||
"containerRestarted": "أعيد تشغيل الحاوية {{name}}",
|
||||
"failedToRestartContainer": "فشل إعادة تشغيل الحاوية {{name}}",
|
||||
"containerPaused": "تم إيقاف الحاوية {{name}} مؤقتاً",
|
||||
"containerUnpaused": "تم استئناف الحاوية {{name}}",
|
||||
"failedToTogglePauseContainer": "فشل تبديل حالة الإيقاف المؤقت للحاوية {{name}}",
|
||||
"containerRemoved": "تمت إزالة الحاوية {{name}}",
|
||||
"failedToRemoveContainer": "فشلت إزالة الحاوية {{name}}",
|
||||
"image": "صورة",
|
||||
"ports": "منافذ",
|
||||
"noPorts": "لا توجد منافذ",
|
||||
"start": "ابدأ",
|
||||
"stop": "التوقف",
|
||||
"status": "الحالة",
|
||||
"actions": "الإجراءات",
|
||||
"cardView": "عرض البطاقة",
|
||||
"listView": "عرض القائمة",
|
||||
"confirmRemoveContainer": "هل أنت متأكد من رغبتك في إزالة الحاوية '{{name}}'؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"runningContainerWarning": "تحذير: هذه الحاوية قيد التشغيل حالياً. ستؤدي إزالتها إلى إيقافها أولاً.",
|
||||
"loadingContainers": "جارٍ تحميل الحاويات...",
|
||||
"manager": "مدير Docker",
|
||||
"autoRefresh": "تحديث تلقائي",
|
||||
"timestamps": "الطوابع الزمنية",
|
||||
"lines": "الأسطر",
|
||||
"filterLogs": "تصفية السجلات...",
|
||||
"refresh": "تحديث",
|
||||
"download": "تنزيل",
|
||||
"clear": "مسح",
|
||||
"logsDownloaded": "تم تنزيل السجلات بنجاح",
|
||||
"last50": "آخر 50",
|
||||
"last100": "آخر 100",
|
||||
"last500": "آخر 500",
|
||||
"last1000": "آخر 1000",
|
||||
"allLogs": "كل السجلات",
|
||||
"noLogsMatching": "لا توجد سجلات تطابق \"{{query}}\"",
|
||||
"noLogsAvailable": "لا توجد سجلات متاحة",
|
||||
"noContainersFound": "لا توجد حاويات",
|
||||
"noContainersFoundHint": "لا توجد حاويات Docker متاحة على هذا المضيف",
|
||||
"searchPlaceholder": "البحث عن حاويات...",
|
||||
"allStatuses": "جميع الحالات",
|
||||
"stateRunning": "قيد التشغيل",
|
||||
"statePaused": "معلق",
|
||||
"stateExited": "متوقف",
|
||||
"stateRestarting": "إعادة التشغيل",
|
||||
"noContainersMatchFilters": "لا توجد حاويات تطابق عوامل التصفية الخاصة بك",
|
||||
"noContainersMatchFiltersHint": "جرّب تعديل معايير البحث أو التصفية",
|
||||
"failedToFetchStats": "فشل جلب إحصائيات الحاوية",
|
||||
"containerNotRunning": "الحاوية غير قيد التشغيل",
|
||||
"startContainerToViewStats": "ابدأ الحاوية لعرض الإحصائيات",
|
||||
"loadingStats": "جارٍ تحميل الإحصائيات...",
|
||||
"errorLoadingStats": "خطأ في تحميل الإحصائيات",
|
||||
"noStatsAvailable": "لا توجد إحصائيات متاحة",
|
||||
"cpuUsage": "استخدام CPU",
|
||||
"current": "الحالي",
|
||||
"memoryUsage": "استخدام الذاكرة",
|
||||
"networkIo": "الإدخال/الإخراج للشبكة",
|
||||
"input": "إدخال",
|
||||
"output": "المخرجات",
|
||||
"blockIo": "الإدخال/الإخراج الكتلي",
|
||||
"read": "قراءة",
|
||||
"write": "كتابة",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "معلومات الحاوية",
|
||||
"name": "الاسم",
|
||||
"id": "المعرف",
|
||||
"state": "الحالة",
|
||||
"containerMustBeRunning": "يجب أن تكون الحاوية قيد التشغيل للوصول إلى وحدة التحكم",
|
||||
"verificationCodePrompt": "أدخل رمز التحقق",
|
||||
"totpVerificationFailed": "فشل التحقق من TOTP. يرجى المحاولة مرة أخرى.",
|
||||
"browserSignInFailed": "فشل تسجيل الدخول. يرجى المحاولة مرة أخرى.",
|
||||
"connectedTo": "متصل بـ {{containerName}}",
|
||||
"disconnected": "تم قطع الاتصال",
|
||||
"consoleError": "خطأ في وحدة التحكم",
|
||||
"errorMessage": "خطأ: {{message}}",
|
||||
"failedToConnect": "فشل الاتصال بالحاوية",
|
||||
"console": "وحدة التحكم",
|
||||
"selectShell": "اختر الصدفة",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "اتصل",
|
||||
"disconnect": "قطع الاتصال",
|
||||
"notConnected": "غير متصل",
|
||||
"clickToConnect": "انقر فوق 'اتصل' لبدء جلسة صدفة",
|
||||
"connectingTo": "جارٍ الاتصال بـ {{containerName}}...",
|
||||
"containerNotFound": "الحاوية غير موجودة",
|
||||
"backToList": "عودة إلى القائمة",
|
||||
"logs": "السجلات",
|
||||
"stats": "الإحصائيات",
|
||||
"consoleTab": "وحدة التحكم",
|
||||
"startContainerToAccess": "ابدأ تشغيل الحاوية للوصول إلى وحدة التحكم",
|
||||
"noHostSelected": "لم يتم تحديد أي جهاز مضيف",
|
||||
"failedToFetchLogs": "فشل في جلب السجلات: {{error}}",
|
||||
"failedToDownloadLogs": "فشل تنزيل السجلات: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "اطلع على الحاويات، واقرأ السجلات، وراقب الإحصائيات على أي مضيف يعمل بنظام Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "إعادة المحاولة",
|
||||
"clipboardReadFailed": "فشلت القراءة من الحافظة. تأكد من منح أذونات الحافظة.",
|
||||
"clipboardWriteFailed": "فشل النسخ إلى الحافظة. تأكد من تقديم الصفحة عبر HTTPS أو localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "استخدم Docker",
|
||||
"description": "افتح مدير Docker ووحدات تحكم الحاويات على المضيفين الذين تم تمكين Docker عليهم."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "تفعيل Docker",
|
||||
"description": "إدارة الحاويات على هذا المضيف."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "بيئة تشغيل الحاويات",
|
||||
"description": "اختر واجهة سطر الأوامر المستخدمة لإدارة الحاويات على هذا المضيف",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "يلزم وجود خادم بعيد. قم بتوصيل خادم بعيد في الإعدادات لاستخدام نوع الاتصال هذا."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "عرض المستندات",
|
||||
"hostNotFound": "المضيف غير موجود",
|
||||
"loadingHost": "جارٍ تحميل المضيف..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker не е активиран за този хост",
|
||||
"validating": "Валидиране на Docker...",
|
||||
"connecting": "Свързване...",
|
||||
"error": "Грешка",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Неуспешно свързване с Docker",
|
||||
"containerStarted": "Контейнерът {{name}} е стартиран",
|
||||
"failedToStartContainer": "Неуспешно стартиране на контейнера {{name}}",
|
||||
"containerStopped": "Контейнерът {{name}} е спрян",
|
||||
"failedToStopContainer": "Неуспешно спиране на контейнера {{name}}",
|
||||
"containerRestarted": "Контейнерът {{name}} е рестартиран",
|
||||
"failedToRestartContainer": "Рестартирането на контейнера не бе успешно {{name}}",
|
||||
"containerPaused": "Контейнерът {{name}} е на пауза",
|
||||
"containerUnpaused": "Контейнерът {{name}} е възстановен",
|
||||
"failedToTogglePauseContainer": "Неуспешно превключване на състоянието на пауза за контейнер {{name}}",
|
||||
"containerRemoved": "Контейнерът {{name}} е премахнат",
|
||||
"failedToRemoveContainer": "Неуспешно премахване на контейнера {{name}}",
|
||||
"image": "Изображение",
|
||||
"ports": "Портове",
|
||||
"noPorts": "Няма портове",
|
||||
"start": "Старт",
|
||||
"stop": "Спри",
|
||||
"status": "Статус",
|
||||
"actions": "Действия",
|
||||
"cardView": "Изглед на картата",
|
||||
"listView": "Списъчен изглед",
|
||||
"confirmRemoveContainer": "Сигурни ли сте, че искате да премахнете контейнера „{{name}}“? Това действие не може да бъде отменено.",
|
||||
"runningContainerWarning": "Предупреждение: Този контейнер в момента работи. Премахването му първо ще спре контейнера.",
|
||||
"loadingContainers": "Зареждане на контейнери...",
|
||||
"manager": "Докер мениджър",
|
||||
"autoRefresh": "Автоматично опресняване",
|
||||
"timestamps": "Времеви печати",
|
||||
"lines": "Линии",
|
||||
"filterLogs": "Филтриране на лог файлове...",
|
||||
"refresh": "Обновяване",
|
||||
"download": "Изтегляне",
|
||||
"clear": "Изчисти",
|
||||
"logsDownloaded": "Дневниците са изтеглени успешно",
|
||||
"last50": "Последните 50",
|
||||
"last100": "Последните 100",
|
||||
"last500": "Последните 500",
|
||||
"last1000": "Последни 1000",
|
||||
"allLogs": "Всички лог файлове",
|
||||
"noLogsMatching": "Няма регистрационни файлове, съответстващи на „{{query}}“",
|
||||
"noLogsAvailable": "Няма налични лог файлове",
|
||||
"noContainersFound": "Не са намерени контейнери",
|
||||
"noContainersFoundHint": "На този хост няма налични Docker контейнери",
|
||||
"searchPlaceholder": "Търсене на контейнери...",
|
||||
"allStatuses": "Всички статуси",
|
||||
"stateRunning": "Бягане",
|
||||
"statePaused": "Пауза",
|
||||
"stateExited": "Изход",
|
||||
"stateRestarting": "Рестартиране",
|
||||
"noContainersMatchFilters": "Няма контейнери, които да отговарят на вашите филтри",
|
||||
"noContainersMatchFiltersHint": "Опитайте да коригирате критериите си за търсене или филтриране",
|
||||
"failedToFetchStats": "Извличането на статистически данни за контейнера не бе успешно",
|
||||
"containerNotRunning": "Контейнерът не работи",
|
||||
"startContainerToViewStats": "Стартирайте контейнера, за да видите статистиката",
|
||||
"loadingStats": "Зареждане на статистика...",
|
||||
"errorLoadingStats": "Грешка при зареждане на статистиката",
|
||||
"noStatsAvailable": "Няма налична статистика",
|
||||
"cpuUsage": "Използване на процесора",
|
||||
"current": "Текущ",
|
||||
"memoryUsage": "Използване на паметта",
|
||||
"networkIo": "Мрежов вход/изход",
|
||||
"input": "Вход",
|
||||
"output": "Изход",
|
||||
"blockIo": "Блокиране на входно/изходни данни",
|
||||
"read": "Прочетете",
|
||||
"write": "Пишете",
|
||||
"pids": "PID-ове",
|
||||
"containerInformation": "Информация за контейнера",
|
||||
"name": "Име",
|
||||
"id": "Идентификационен номер",
|
||||
"state": "Щат",
|
||||
"containerMustBeRunning": "Контейнерът трябва да е работещ, за да има достъп до конзолата",
|
||||
"verificationCodePrompt": "Въведете код за потвърждение",
|
||||
"totpVerificationFailed": "Проверката на TOTP не бе успешна. Моля, опитайте отново.",
|
||||
"browserSignInFailed": "Влизането не бе успешно. Моля, опитайте отново.",
|
||||
"connectedTo": "Свързано с {{containerName}}",
|
||||
"disconnected": "Изключен",
|
||||
"consoleError": "Грешка в конзолата",
|
||||
"errorMessage": "Грешка: {{message}}",
|
||||
"failedToConnect": "Неуспешно свързване с контейнера",
|
||||
"console": "Конзола",
|
||||
"selectShell": "Изберете черупка",
|
||||
"bash": "Баш",
|
||||
"sh": "ш",
|
||||
"ash": "пепел",
|
||||
"connect": "Свържете се",
|
||||
"disconnect": "Изключване",
|
||||
"notConnected": "Няма връзка",
|
||||
"clickToConnect": "Щракнете върху „Свързване“, за да стартирате сесия на shell",
|
||||
"connectingTo": "Свързване към {{containerName}}...",
|
||||
"containerNotFound": "Контейнерът не е намерен",
|
||||
"backToList": "Обратно към списъка",
|
||||
"logs": "Дневници",
|
||||
"stats": "Статистика",
|
||||
"consoleTab": "Конзола",
|
||||
"startContainerToAccess": "Стартирайте контейнера, за да получите достъп до конзолата",
|
||||
"noHostSelected": "Няма избран хост",
|
||||
"failedToFetchLogs": "Неуспешно извличане на лог файлове: {{error}}",
|
||||
"failedToDownloadLogs": "Неуспешно изтегляне на лог файлове: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Докер"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Докер",
|
||||
"feature_docker_desc": "Вижте контейнери, прочетете лог файлове и наблюдавайте статистики на всеки хост, на който работи Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Опитай отново",
|
||||
"clipboardReadFailed": "Четенето от клипборда не бе успешно. Уверете се, че са предоставени разрешения за достъп до клипборда.",
|
||||
"clipboardWriteFailed": "Копирането в клипборда не бе успешно. Уверете се, че страницата се предоставя през HTTPS или localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Използвайте Докер",
|
||||
"description": "Отворете Docker manager-а и конзолите за контейнери на хостове с активиран Docker."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Активиране на Докер",
|
||||
"description": "Управлявайте контейнери на този хост."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Изпълнение на контейнера",
|
||||
"description": "Изберете CLI, използван за управление на контейнери на този хост",
|
||||
"docker": "Докер",
|
||||
"podman": "Подман"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Изисква се отдалечен сървър. Свържете се с отдалечен сървър в „Настройки“, за да използвате този тип връзка."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Преглед на документи",
|
||||
"hostNotFound": "Хостът не е намерен",
|
||||
"loadingHost": "Зареждане на хоста..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker is not enabled for this host",
|
||||
"validating": "Validating Docker...",
|
||||
"connecting": "Connecting...",
|
||||
"error": "Error",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Failed to connect to Docker",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container {{name}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||
"image": "Image",
|
||||
"ports": "Ports",
|
||||
"noPorts": "No ports",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||
"loadingContainers": "Loading containers...",
|
||||
"manager": "Docker Manager",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
"timestamps": "Timestamps",
|
||||
"lines": "Lines",
|
||||
"filterLogs": "Filter logs...",
|
||||
"refresh": "Refresh",
|
||||
"download": "Download",
|
||||
"clear": "Clear",
|
||||
"logsDownloaded": "Logs downloaded successfully",
|
||||
"last50": "Last 50",
|
||||
"last100": "Last 100",
|
||||
"last500": "Last 500",
|
||||
"last1000": "Last 1000",
|
||||
"allLogs": "All Logs",
|
||||
"noLogsMatching": "No logs matching \"{{query}}\"",
|
||||
"noLogsAvailable": "No logs available",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||
"searchPlaceholder": "Search containers...",
|
||||
"allStatuses": "All Statuses",
|
||||
"stateRunning": "Running",
|
||||
"statePaused": "Paused",
|
||||
"stateExited": "Exited",
|
||||
"stateRestarting": "Restarting",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||
"failedToFetchStats": "Failed to fetch container statistics",
|
||||
"containerNotRunning": "Container not running",
|
||||
"startContainerToViewStats": "Start the container to view statistics",
|
||||
"loadingStats": "Loading statistics...",
|
||||
"errorLoadingStats": "Error loading statistics",
|
||||
"noStatsAvailable": "No statistics available",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"current": "Current",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"networkIo": "Network I/O",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"blockIo": "Block I/O",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Container Information",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"containerMustBeRunning": "Container must be running to access console",
|
||||
"verificationCodePrompt": "Enter verification code",
|
||||
"totpVerificationFailed": "TOTP verification failed. Please try again.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Disconnected",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "Failed to connect to container",
|
||||
"console": "Console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click connect to start a shell session",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to List",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console",
|
||||
"noHostSelected": "No host selected",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Retry",
|
||||
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
||||
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Enable Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Choose the CLI used for container management on this host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker no està habilitat per a aquest amfitrió",
|
||||
"validating": "Validant Docker...",
|
||||
"connecting": "Connectant...",
|
||||
"error": "Error",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "No s'ha pogut connectar a Docker",
|
||||
"containerStarted": "Contenidor {{name}} iniciat",
|
||||
"failedToStartContainer": "No s'ha pogut iniciar el contenidor {{name}}",
|
||||
"containerStopped": "El contenidor {{name}} s'ha aturat",
|
||||
"failedToStopContainer": "No s'ha pogut aturar el contenidor {{name}}",
|
||||
"containerRestarted": "El contenidor {{name}} s'ha reiniciat",
|
||||
"failedToRestartContainer": "No s'ha pogut reiniciar el contenidor {{name}}",
|
||||
"containerPaused": "Contenidor {{name}} en pausa",
|
||||
"containerUnpaused": "Contenidor {{name}} reactivat",
|
||||
"failedToTogglePauseContainer": "No s'ha pogut activar o desactivar l'estat de pausa per al contenidor {{name}}",
|
||||
"containerRemoved": "Contenidor {{name}} eliminat",
|
||||
"failedToRemoveContainer": "No s'ha pogut eliminar el contenidor {{name}}",
|
||||
"image": "Imatge",
|
||||
"ports": "Ports",
|
||||
"noPorts": "Sense ports",
|
||||
"start": "Inici",
|
||||
"stop": "Atura",
|
||||
"status": "Estat",
|
||||
"actions": "Accions",
|
||||
"cardView": "Vista de targeta",
|
||||
"listView": "Vista de llista",
|
||||
"confirmRemoveContainer": "Esteu segur que voleu eliminar el contenidor '{{name}}'? Aquesta acció no es pot desfer.",
|
||||
"runningContainerWarning": "Avís: Aquest contenidor està en execució. Si el suprimiu, primer s'aturarà el contenidor.",
|
||||
"loadingContainers": "Carregant contenidors...",
|
||||
"manager": "Gestor de Docker",
|
||||
"autoRefresh": "Actualització automàtica",
|
||||
"timestamps": "Marques de temps",
|
||||
"lines": "Línies",
|
||||
"filterLogs": "Filtra els registres...",
|
||||
"refresh": "Actualitza",
|
||||
"download": "Descarrega",
|
||||
"clear": "Clar",
|
||||
"logsDownloaded": "Registres descarregats correctament",
|
||||
"last50": "Últims 50",
|
||||
"last100": "Últims 100",
|
||||
"last500": "Últims 500",
|
||||
"last1000": "Últims 1000",
|
||||
"allLogs": "Tots els registres",
|
||||
"noLogsMatching": "No hi ha registres que coincideixin amb \"{{query}}\"",
|
||||
"noLogsAvailable": "No hi ha registres disponibles",
|
||||
"noContainersFound": "No s'han trobat contenidors",
|
||||
"noContainersFoundHint": "No hi ha contenidors Docker disponibles en aquest amfitrió.",
|
||||
"searchPlaceholder": "Cerca contenidors...",
|
||||
"allStatuses": "Tots els estats",
|
||||
"stateRunning": "Córrer",
|
||||
"statePaused": "En pausa",
|
||||
"stateExited": "Sortit",
|
||||
"stateRestarting": "Reinici",
|
||||
"noContainersMatchFilters": "No hi ha contenidors que coincideixin amb els vostres filtres",
|
||||
"noContainersMatchFiltersHint": "Prova d'ajustar els criteris de cerca o de filtre",
|
||||
"failedToFetchStats": "No s'han pogut obtenir les estadístiques del contenidor",
|
||||
"containerNotRunning": "El contenidor no funciona",
|
||||
"startContainerToViewStats": "Inicia el contenidor per veure les estadístiques",
|
||||
"loadingStats": "S'estan carregant les estadístiques...",
|
||||
"errorLoadingStats": "S'ha produït un error en carregar les estadístiques",
|
||||
"noStatsAvailable": "No hi ha estadístiques disponibles",
|
||||
"cpuUsage": "Ús de la CPU",
|
||||
"current": "Actual",
|
||||
"memoryUsage": "Ús de memòria",
|
||||
"networkIo": "E/S de xarxa",
|
||||
"input": "Entrada",
|
||||
"output": "Sortida",
|
||||
"blockIo": "Bloc d'E/S",
|
||||
"read": "Llegir",
|
||||
"write": "Escriure",
|
||||
"pids": "PID",
|
||||
"containerInformation": "Informació del contenidor",
|
||||
"name": "Nom",
|
||||
"id": "ID",
|
||||
"state": "Estat",
|
||||
"containerMustBeRunning": "El contenidor ha d'estar en execució per accedir a la consola.",
|
||||
"verificationCodePrompt": "Introduïu el codi de verificació",
|
||||
"totpVerificationFailed": "La verificació TOTP ha fallat. Torna-ho a provar.",
|
||||
"browserSignInFailed": "L'inici de sessió ha fallat. Torna-ho a provar.",
|
||||
"connectedTo": "Connectat a {{containerName}}",
|
||||
"disconnected": "Desconnectat",
|
||||
"consoleError": "Error de consola",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "No s'ha pogut connectar al contenidor",
|
||||
"console": "Consola",
|
||||
"selectShell": "Selecciona l'intèrpret d'ordres",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "cendra",
|
||||
"connect": "Connecta",
|
||||
"disconnect": "Desconnecta",
|
||||
"notConnected": "No connectat",
|
||||
"clickToConnect": "Feu clic a Connecta per iniciar una sessió de shell",
|
||||
"connectingTo": "Connectant a {{containerName}}...",
|
||||
"containerNotFound": "No s'ha trobat el contenidor",
|
||||
"backToList": "Torna a la llista",
|
||||
"logs": "Registres",
|
||||
"stats": "Estadístiques",
|
||||
"consoleTab": "Consola",
|
||||
"startContainerToAccess": "Inicieu el contenidor per accedir a la consola",
|
||||
"noHostSelected": "No s'ha seleccionat cap amfitrió",
|
||||
"failedToFetchLogs": "No s'han pogut obtenir els registres: {{error}}",
|
||||
"failedToDownloadLogs": "No s'han pogut descarregar els registres: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Veure contenidors, llegir registres i observar estadístiques de qualsevol host que executi Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Torna-ho a intentar",
|
||||
"clipboardReadFailed": "No s'ha pogut llegir del porta-retalls. Assegureu-vos que els permisos del porta-retalls estiguin concedits.",
|
||||
"clipboardWriteFailed": "No s'ha pogut copiar al porta-retalls. Assegureu-vos que la pàgina es serveix per HTTPS o localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Utilitza Docker",
|
||||
"description": "Obriu el gestor de Docker i les consoles de contenidors als hosts amb Docker habilitat."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Activa Docker",
|
||||
"description": "Gestiona els contenidors en aquest amfitrió."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Temps d'execució del contenidor",
|
||||
"description": "Trieu la CLI utilitzada per a la gestió de contenidors en aquest amfitrió",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Cal un servidor remot. Connecteu un servidor remot a Configuració per utilitzar aquest tipus de connexió."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Veure documents",
|
||||
"hostNotFound": "No s'ha trobat l'amfitrió",
|
||||
"loadingHost": "S'està carregant l'amfitrió..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker není pro tohoto hostitele povolen.",
|
||||
"validating": "Ověřování Dockeru...",
|
||||
"connecting": "Spojovací...",
|
||||
"error": "Chyba",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Nepodařilo se připojit k Dockeru",
|
||||
"containerStarted": "Kontejner {{name}} spuštěn",
|
||||
"failedToStartContainer": "Nepodařilo se spustit kontejner {{name}}",
|
||||
"containerStopped": "Kontejner {{name}} zastaven",
|
||||
"failedToStopContainer": "Nepodařilo se zastavit kontejner {{name}}",
|
||||
"containerRestarted": "Kontejner {{name}} restartován",
|
||||
"failedToRestartContainer": "Nepodařilo se restartovat kontejner {{name}}",
|
||||
"containerPaused": "Kontejner {{name}} pozastaven",
|
||||
"containerUnpaused": "Kontejner {{name}} pozastaven",
|
||||
"failedToTogglePauseContainer": "Nepodařilo se přepnout stav pozastavení pro kontejner {{name}}",
|
||||
"containerRemoved": "Kontejner {{name}} byl odstraněn",
|
||||
"failedToRemoveContainer": "Nepodařilo se odstranit kontejner {{name}}",
|
||||
"image": "Obraz",
|
||||
"ports": "Porty",
|
||||
"noPorts": "Žádné porty",
|
||||
"start": "Start",
|
||||
"stop": "Zastávka",
|
||||
"status": "Postavení",
|
||||
"actions": "Akce",
|
||||
"cardView": "Zobrazení karty",
|
||||
"listView": "Seznam",
|
||||
"confirmRemoveContainer": "Opravdu chcete odstranit kontejner '{{name}}'? Tuto akci nelze vrátit zpět.",
|
||||
"runningContainerWarning": "Varování: Tento kontejner je aktuálně spuštěný. Jeho odstraněním se nejprve zastaví.",
|
||||
"loadingContainers": "Nakládání kontejnerů...",
|
||||
"manager": "Správce Dockeru",
|
||||
"autoRefresh": "Automatické obnovení",
|
||||
"timestamps": "Časová razítka",
|
||||
"lines": "Čáry",
|
||||
"filterLogs": "Filtrovat protokoly...",
|
||||
"refresh": "Obnovit",
|
||||
"download": "Stáhnout",
|
||||
"clear": "Jasný",
|
||||
"logsDownloaded": "Protokoly byly úspěšně staženy",
|
||||
"last50": "Posledních 50",
|
||||
"last100": "Posledních 100",
|
||||
"last500": "Posledních 500",
|
||||
"last1000": "Posledních 1000",
|
||||
"allLogs": "Všechny protokoly",
|
||||
"noLogsMatching": "Žádné protokoly neodpovídají „{{query}}“",
|
||||
"noLogsAvailable": "Žádné protokoly nejsou k dispozici",
|
||||
"noContainersFound": "Nenalezeny žádné kontejnery",
|
||||
"noContainersFoundHint": "Na tomto hostiteli nejsou k dispozici žádné kontejnery Docker.",
|
||||
"searchPlaceholder": "Hledat kontejnery...",
|
||||
"allStatuses": "Všechny stavy",
|
||||
"stateRunning": "Běh",
|
||||
"statePaused": "Pozastaveno",
|
||||
"stateExited": "Ukončeno",
|
||||
"stateRestarting": "Restartování",
|
||||
"noContainersMatchFilters": "Žádné kontejnery neodpovídají vašim filtrům",
|
||||
"noContainersMatchFiltersHint": "Zkuste upravit kritéria vyhledávání nebo filtru",
|
||||
"failedToFetchStats": "Nepodařilo se načíst statistiky kontejneru",
|
||||
"containerNotRunning": "Kontejner neběží",
|
||||
"startContainerToViewStats": "Spusťte kontejner pro zobrazení statistik",
|
||||
"loadingStats": "Načítání statistik...",
|
||||
"errorLoadingStats": "Chyba při načítání statistik",
|
||||
"noStatsAvailable": "Žádné statistiky nejsou k dispozici",
|
||||
"cpuUsage": "Využití CPU",
|
||||
"current": "Proud",
|
||||
"memoryUsage": "Využití paměti",
|
||||
"networkIo": "Síťové I/O",
|
||||
"input": "Vstup",
|
||||
"output": "Výstup",
|
||||
"blockIo": "Blok I/O",
|
||||
"read": "Číst",
|
||||
"write": "Napsat",
|
||||
"pids": "PIDy",
|
||||
"containerInformation": "Informace o kontejneru",
|
||||
"name": "Jméno",
|
||||
"id": "Průkaz totožnosti",
|
||||
"state": "Stát",
|
||||
"containerMustBeRunning": "Pro přístup ke konzoli musí být kontejner spuštěný.",
|
||||
"verificationCodePrompt": "Zadejte ověřovací kód",
|
||||
"totpVerificationFailed": "Ověření TOTP se nezdařilo. Zkuste to prosím znovu.",
|
||||
"browserSignInFailed": "Přihlášení se nezdařilo. Zkuste to prosím znovu.",
|
||||
"connectedTo": "Připojeno k {{containerName}}",
|
||||
"disconnected": "Odpojeno",
|
||||
"consoleError": "Chyba konzole",
|
||||
"errorMessage": "Chyba: {{message}}",
|
||||
"failedToConnect": "Nepodařilo se připojit ke kontejneru",
|
||||
"console": "Utěšit",
|
||||
"selectShell": "Vyberte skořápku",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "popel",
|
||||
"connect": "Připojit",
|
||||
"disconnect": "Odpojit",
|
||||
"notConnected": "Nepřipojeno",
|
||||
"clickToConnect": "Kliknutím na tlačítko Připojit spusťte relaci shellu.",
|
||||
"connectingTo": "Připojování k {{containerName}}...",
|
||||
"containerNotFound": "Kontejner nenalezen",
|
||||
"backToList": "Zpět na seznam",
|
||||
"logs": "Protokoly",
|
||||
"stats": "Statistiky",
|
||||
"consoleTab": "Utěšit",
|
||||
"startContainerToAccess": "Spusťte kontejner pro přístup ke konzoli",
|
||||
"noHostSelected": "Nebyl vybrán žádný hostitel",
|
||||
"failedToFetchLogs": "Nepodařilo se načíst protokoly: {{error}}",
|
||||
"failedToDownloadLogs": "Stažení protokolů se nezdařilo: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Přístavní dělník"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Přístavní dělník",
|
||||
"feature_docker_desc": "Zobrazujte kontejnery, čtěte protokoly a sledujte statistiky na jakémkoli hostiteli s Dockerem."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Zkusit znovu",
|
||||
"clipboardReadFailed": "Čtení ze schránky se nezdařilo. Ujistěte se, že máte povolena přístup ke schránce.",
|
||||
"clipboardWriteFailed": "Kopírování do schránky se nezdařilo. Ujistěte se, že stránka je zobrazována přes HTTPS nebo localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Použijte Docker",
|
||||
"description": "Otevřete správce Dockeru a konzole kontejnerů na hostitelích s povoleným Dockerem."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Povolit Docker",
|
||||
"description": "Spravovat kontejnery na tomto hostiteli."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Běh kontejneru",
|
||||
"description": "Vyberte rozhraní CLI používané pro správu kontejnerů na tomto hostiteli",
|
||||
"docker": "Přístavní dělník",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Je vyžadován vzdálený server. Pro použití tohoto typu připojení se připojte ke vzdálenému serveru v Nastavení."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Zobrazit dokumenty",
|
||||
"hostNotFound": "Hostitel nenalezen",
|
||||
"loadingHost": "Načítání hostitele..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker er ikke aktiveret for denne vært",
|
||||
"validating": "Validerer Docker...",
|
||||
"connecting": "Forbinder...",
|
||||
"error": "Fejl",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Kunne ikke oprette forbindelse til Docker",
|
||||
"containerStarted": "Container {{name}} startet",
|
||||
"failedToStartContainer": "Kunne ikke starte containeren {{name}}",
|
||||
"containerStopped": "Container {{name}} stoppet",
|
||||
"failedToStopContainer": "Kunne ikke stoppe containeren {{name}}",
|
||||
"containerRestarted": "Container {{name}} genstartet",
|
||||
"failedToRestartContainer": "Kunne ikke genstarte containeren {{name}}",
|
||||
"containerPaused": "Container {{name}} sat på pause",
|
||||
"containerUnpaused": "Container {{name}} genoptaget",
|
||||
"failedToTogglePauseContainer": "Kunne ikke slå pausetilstand til og fra for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} fjernet",
|
||||
"failedToRemoveContainer": "Kunne ikke fjerne containeren {{name}}",
|
||||
"image": "Billede",
|
||||
"ports": "Havne",
|
||||
"noPorts": "Ingen porte",
|
||||
"start": "Starte",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Handlinger",
|
||||
"cardView": "Kortvisning",
|
||||
"listView": "Listevisning",
|
||||
"confirmRemoveContainer": "Er du sikker på, at du vil fjerne containeren '{{name}}'? Denne handling kan ikke fortrydes.",
|
||||
"runningContainerWarning": "Advarsel: Denne container kører i øjeblikket. Fjernelse af den vil stoppe containeren først.",
|
||||
"loadingContainers": "Indlæser containere...",
|
||||
"manager": "Docker Manager",
|
||||
"autoRefresh": "Automatisk opdatering",
|
||||
"timestamps": "Tidsstempler",
|
||||
"lines": "Linjer",
|
||||
"filterLogs": "Filtrer logfiler...",
|
||||
"refresh": "Opfriske",
|
||||
"download": "Download",
|
||||
"clear": "Klar",
|
||||
"logsDownloaded": "Logfilerne er downloadet",
|
||||
"last50": "Sidste 50",
|
||||
"last100": "Sidste 100",
|
||||
"last500": "Sidste 500",
|
||||
"last1000": "Sidste 1000",
|
||||
"allLogs": "Alle logfiler",
|
||||
"noLogsMatching": "Ingen logfiler matcher \"{{query}}\"",
|
||||
"noLogsAvailable": "Ingen logfiler tilgængelige",
|
||||
"noContainersFound": "Ingen containere fundet",
|
||||
"noContainersFoundHint": "Der er ingen Docker-containere tilgængelige på denne vært",
|
||||
"searchPlaceholder": "Søg i containere...",
|
||||
"allStatuses": "Alle statusser",
|
||||
"stateRunning": "Løb",
|
||||
"statePaused": "Pausesat",
|
||||
"stateExited": "Afsluttet",
|
||||
"stateRestarting": "Genstart",
|
||||
"noContainersMatchFilters": "Ingen containere matcher dine filtre",
|
||||
"noContainersMatchFiltersHint": "Prøv at justere dine søge- eller filterkriterier",
|
||||
"failedToFetchStats": "Kunne ikke hente containerstatistik",
|
||||
"containerNotRunning": "Containeren kører ikke",
|
||||
"startContainerToViewStats": "Start containeren for at se statistik",
|
||||
"loadingStats": "Indlæser statistik...",
|
||||
"errorLoadingStats": "Fejl ved indlæsning af statistik",
|
||||
"noStatsAvailable": "Ingen statistik tilgængelig",
|
||||
"cpuUsage": "CPU-forbrug",
|
||||
"current": "Strøm",
|
||||
"memoryUsage": "Hukommelsesforbrug",
|
||||
"networkIo": "Netværks-I/O",
|
||||
"input": "Input",
|
||||
"output": "Produktion",
|
||||
"blockIo": "Blok I/O",
|
||||
"read": "Læse",
|
||||
"write": "Skrive",
|
||||
"pids": "PID'er",
|
||||
"containerInformation": "Containeroplysninger",
|
||||
"name": "Navn",
|
||||
"id": "ID",
|
||||
"state": "Tilstand",
|
||||
"containerMustBeRunning": "Containeren skal køre for at få adgang til konsollen",
|
||||
"verificationCodePrompt": "Indtast bekræftelseskode",
|
||||
"totpVerificationFailed": "TOTP-bekræftelse mislykkedes. Prøv igen.",
|
||||
"browserSignInFailed": "Log ind mislykkedes. Prøv igen.",
|
||||
"connectedTo": "Forbundet til {{containerName}}",
|
||||
"disconnected": "Afbrudt",
|
||||
"consoleError": "Konsolfejl",
|
||||
"errorMessage": "Fejl: {{message}}",
|
||||
"failedToConnect": "Kunne ikke oprette forbindelse til containeren",
|
||||
"console": "Konsol",
|
||||
"selectShell": "Vælg skal",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "aske",
|
||||
"connect": "Forbinde",
|
||||
"disconnect": "Afbryde",
|
||||
"notConnected": "Ikke forbundet",
|
||||
"clickToConnect": "Klik på Opret forbindelse for at starte en shell-session",
|
||||
"connectingTo": "Forbinder til {{containerName}}...",
|
||||
"containerNotFound": "Beholderen blev ikke fundet",
|
||||
"backToList": "Tilbage til listen",
|
||||
"logs": "Logfiler",
|
||||
"stats": "Statistik",
|
||||
"consoleTab": "Konsol",
|
||||
"startContainerToAccess": "Start containeren for at få adgang til konsollen",
|
||||
"noHostSelected": "Ingen vært valgt",
|
||||
"failedToFetchLogs": "Kunne ikke hente logfiler: {{error}}",
|
||||
"failedToDownloadLogs": "Kunne ikke downloade logfiler: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Se containere, læs logfiler og se statistikker på enhver vært, der kører Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Prøv igen",
|
||||
"clipboardReadFailed": "Kunne ikke læse fra udklipsholderen. Sørg for, at der er givet tilladelse til udklipsholderen.",
|
||||
"clipboardWriteFailed": "Kunne ikke kopiere til udklipsholder. Sørg for, at siden serveres via HTTPS eller localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Brug Docker",
|
||||
"description": "Åbn Docker-manageren og containerkonsollerne på værter med Docker aktiveret."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Aktivér Docker",
|
||||
"description": "Administrer containere på denne vært."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Containerkørselstid",
|
||||
"description": "Vælg den CLI, der bruges til containeradministration på denne vært",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Fjernserver kræves. Opret forbindelse til en fjernserver i Indstillinger for at bruge denne forbindelsestype."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Se dokumenter",
|
||||
"hostNotFound": "Værten blev ikke fundet",
|
||||
"loadingHost": "Indlæser vært..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker ist für diesen Host nicht aktiviert",
|
||||
"validating": "Docker wird überprüft...",
|
||||
"connecting": "Verbindung wird hergestellt...",
|
||||
"error": "Fehler",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Verbindung zu Docker fehlgeschlagen",
|
||||
"containerStarted": "Container {{name}} gestartet",
|
||||
"failedToStartContainer": "Starten des Containers {{name}} fehlgeschlagen",
|
||||
"containerStopped": "Container {{name}} gestoppt",
|
||||
"failedToStopContainer": "Stoppen des Containers {{name}} fehlgeschlagen",
|
||||
"containerRestarted": "Container {{name}} neu gestartet",
|
||||
"failedToRestartContainer": "Neustart des Containers {{name}} fehlgeschlagen",
|
||||
"containerPaused": "Container {{name}} pausiert",
|
||||
"containerUnpaused": "Container {{name}} fortgesetzt",
|
||||
"failedToTogglePauseContainer": "Umschalten des Pause-Zustands für Container {{name}} fehlgeschlagen",
|
||||
"containerRemoved": "Container {{name}} entfernt",
|
||||
"failedToRemoveContainer": "Entfernen des Containers {{name}} fehlgeschlagen",
|
||||
"image": "Image",
|
||||
"ports": "Ports",
|
||||
"noPorts": "Keine Ports",
|
||||
"start": "Start",
|
||||
"stop": "Stoppen",
|
||||
"status": "Status",
|
||||
"actions": "Aktionen",
|
||||
"cardView": "Kartenansicht",
|
||||
"listView": "Listenansicht",
|
||||
"confirmRemoveContainer": "Sind Sie sicher, dass Sie den Container '{{name}}' entfernen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"runningContainerWarning": "Warnung: Dieser Container wird gerade ausgeführt. Beim Entfernen wird der Container zuerst gestoppt.",
|
||||
"loadingContainers": "Container werden geladen...",
|
||||
"manager": "Docker-Manager",
|
||||
"autoRefresh": "Automatische Aktualisierung",
|
||||
"timestamps": "Zeitstempel",
|
||||
"lines": "Zeilen",
|
||||
"filterLogs": "Logs filtern...",
|
||||
"refresh": "Aktualisieren",
|
||||
"download": "Herunterladen",
|
||||
"clear": "Löschen",
|
||||
"logsDownloaded": "Logs erfolgreich heruntergeladen",
|
||||
"last50": "Letzte 50",
|
||||
"last100": "Letzte 100",
|
||||
"last500": "Letzte 500",
|
||||
"last1000": "Letzte 1000",
|
||||
"allLogs": "Alle Logs",
|
||||
"noLogsMatching": "Keine Logs passend zu \"{{query}}\"",
|
||||
"noLogsAvailable": "Keine Logs verfügbar",
|
||||
"noContainersFound": "Keine Container gefunden",
|
||||
"noContainersFoundHint": "Auf diesem Host sind keine Docker-Container verfügbar",
|
||||
"searchPlaceholder": "Container durchsuchen...",
|
||||
"allStatuses": "Alle Status",
|
||||
"stateRunning": "Läuft",
|
||||
"statePaused": "Pausiert",
|
||||
"stateExited": "Beendet",
|
||||
"stateRestarting": "Wird neu gestartet",
|
||||
"noContainersMatchFilters": "Keine Container entsprechen Ihren Filtern",
|
||||
"noContainersMatchFiltersHint": "Versuchen Sie, Ihre Such- oder Filterkriterien anzupassen.",
|
||||
"failedToFetchStats": "Container-Statistiken konnten nicht abgerufen werden",
|
||||
"containerNotRunning": "Container läuft nicht",
|
||||
"startContainerToViewStats": "Starten Sie den Container, um Statistiken anzuzeigen",
|
||||
"loadingStats": "Statistiken werden geladen...",
|
||||
"errorLoadingStats": "Fehler beim Laden der Statistiken",
|
||||
"noStatsAvailable": "Keine Statistiken verfügbar",
|
||||
"cpuUsage": "CPU-Auslastung",
|
||||
"current": "Aktuell",
|
||||
"memoryUsage": "Speicherauslastung",
|
||||
"networkIo": "Netzwerk-I/O",
|
||||
"input": "Eingabe",
|
||||
"output": "Ausgabe",
|
||||
"blockIo": "Block-I/O",
|
||||
"read": "Lesen",
|
||||
"write": "Schreiben",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Container-Informationen",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "Status",
|
||||
"containerMustBeRunning": "Der Container muss ausgeführt werden, um auf die Konsole zuzugreifen",
|
||||
"verificationCodePrompt": "Bestätigungscode eingeben",
|
||||
"totpVerificationFailed": "TOTP-Überprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"browserSignInFailed": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"connectedTo": "Verbunden mit {{containerName}}",
|
||||
"disconnected": "Getrennt",
|
||||
"consoleError": "Konsolenfehler",
|
||||
"errorMessage": "Fehler: {{message}}",
|
||||
"failedToConnect": "Verbindung zum Container fehlgeschlagen",
|
||||
"console": "Konsole",
|
||||
"selectShell": "Shell auswählen",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Verbinden",
|
||||
"disconnect": "Trennen",
|
||||
"notConnected": "Nicht verbunden",
|
||||
"clickToConnect": "Klicken Sie auf 'Verbinden', um eine Shell-Sitzung zu starten",
|
||||
"connectingTo": "Verbinden mit {{containerName}}...",
|
||||
"containerNotFound": "Container nicht gefunden",
|
||||
"backToList": "Zurück zur Liste",
|
||||
"logs": "Protokolle",
|
||||
"stats": "Statistiken",
|
||||
"consoleTab": "Konsole",
|
||||
"startContainerToAccess": "Starten Sie den Container, um auf die Konsole zuzugreifen",
|
||||
"noHostSelected": "Kein Host ausgewählt",
|
||||
"failedToFetchLogs": "Protokolle konnten nicht abgerufen werden: {{error}}",
|
||||
"failedToDownloadLogs": "Protokolle konnten nicht heruntergeladen werden: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Container anzeigen, Protokolle lesen und Statistiken auf jedem Host, auf dem Docker ausgeführt wird, einsehen."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Erneut versuchen",
|
||||
"clipboardReadFailed": "Lesen aus der Zwischenablage fehlgeschlagen. Stellen Sie sicher, dass die Zwischenablageberechtigungen erteilt wurden.",
|
||||
"clipboardWriteFailed": "Kopieren in die Zwischenablage fehlgeschlagen. Stellen Sie sicher, dass die Seite über HTTPS oder localhost bereitgestellt wird."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Docker verwenden",
|
||||
"description": "Öffnen Sie die Docker Manager- und Containerkonsolen auf Hosts, auf denen Docker aktiviert ist."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker aktivieren",
|
||||
"description": "Container auf diesem Host verwalten."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container-Runtime",
|
||||
"description": "Wählen Sie die CLI für die Container-Verwaltung auf diesem Host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote-Server erforderlich. Um diesen Verbindungstyp zu verwenden, verbinden Sie einen Remote-Server in den Einstellungen."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Dokumentation anzeigen",
|
||||
"hostNotFound": "Host nicht gefunden",
|
||||
"loadingHost": "Host wird geladen..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Το Docker δεν είναι ενεργοποιημένο για αυτόν τον κεντρικό υπολογιστή",
|
||||
"validating": "Επικύρωση Docker...",
|
||||
"connecting": "Συνδετικός...",
|
||||
"error": "Σφάλμα",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Αποτυχία σύνδεσης με το Docker",
|
||||
"containerStarted": "Το κοντέινερ {{name}} ξεκίνησε",
|
||||
"failedToStartContainer": "Αποτυχία εκκίνησης κοντέινερ {{name}}",
|
||||
"containerStopped": "Το κοντέινερ {{name}} σταμάτησε",
|
||||
"failedToStopContainer": "Αποτυχία διακοπής του κοντέινερ {{name}}",
|
||||
"containerRestarted": "Το κοντέινερ {{name}} επανεκκινήθηκε",
|
||||
"failedToRestartContainer": "Αποτυχία επανεκκίνησης κοντέινερ {{name}}",
|
||||
"containerPaused": "Το κοντέινερ {{name}} τέθηκε σε παύση",
|
||||
"containerUnpaused": "Το κοντέινερ {{name}} δεν έπαυσε να λειτουργεί",
|
||||
"failedToTogglePauseContainer": "Αποτυχία εναλλαγής κατάστασης παύσης για το κοντέινερ {{name}}",
|
||||
"containerRemoved": "Το κοντέινερ {{name}} αφαιρέθηκε",
|
||||
"failedToRemoveContainer": "Αποτυχία κατάργησης του κοντέινερ {{name}}",
|
||||
"image": "Εικών",
|
||||
"ports": "Λιμάνια",
|
||||
"noPorts": "Δεν υπάρχουν θύρες",
|
||||
"start": "Αρχή",
|
||||
"stop": "Στάση",
|
||||
"status": "Κατάσταση",
|
||||
"actions": "Ενέργειες",
|
||||
"cardView": "Προβολή κάρτας",
|
||||
"listView": "Προβολή λίστας",
|
||||
"confirmRemoveContainer": "Είστε βέβαιοι ότι θέλετε να καταργήσετε το κοντέινερ '{{name}}'; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
|
||||
"runningContainerWarning": "Προειδοποίηση: Αυτό το κοντέινερ εκτελείται αυτήν τη στιγμή. Η αφαίρεσή του θα διακόψει πρώτα τη λειτουργία του.",
|
||||
"loadingContainers": "Φόρτωση κοντέινερ...",
|
||||
"manager": "Διαχειριστής Docker",
|
||||
"autoRefresh": "Αυτόματη Ανανέωση",
|
||||
"timestamps": "Χρονικές σημάνσεις",
|
||||
"lines": "Γραμμές",
|
||||
"filterLogs": "Φιλτράρισμα αρχείων καταγραφής...",
|
||||
"refresh": "Φρεσκάρω",
|
||||
"download": "Λήψη",
|
||||
"clear": "Σαφής",
|
||||
"logsDownloaded": "Τα αρχεία καταγραφής λήφθηκαν με επιτυχία",
|
||||
"last50": "Τελευταία 50",
|
||||
"last100": "Τελευταία 100",
|
||||
"last500": "Τελευταία 500",
|
||||
"last1000": "Τελευταία 1000",
|
||||
"allLogs": "Όλα τα αρχεία καταγραφής",
|
||||
"noLogsMatching": "Δεν υπάρχουν αρχεία καταγραφής που να ταιριάζουν με το \"{{query}}\"",
|
||||
"noLogsAvailable": "Δεν υπάρχουν διαθέσιμα αρχεία καταγραφής",
|
||||
"noContainersFound": "Δεν βρέθηκαν κοντέινερ",
|
||||
"noContainersFoundHint": "Δεν υπάρχουν διαθέσιμα κοντέινερ Docker σε αυτόν τον κεντρικό υπολογιστή",
|
||||
"searchPlaceholder": "Αναζήτηση κοντέινερ...",
|
||||
"allStatuses": "Όλες οι καταστάσεις",
|
||||
"stateRunning": "Τρέξιμο",
|
||||
"statePaused": "Σε παύση",
|
||||
"stateExited": "Έξοδος",
|
||||
"stateRestarting": "Επανεκκίνηση",
|
||||
"noContainersMatchFilters": "Δεν υπάρχουν κοντέινερ που να αντιστοιχούν στα φίλτρα σας",
|
||||
"noContainersMatchFiltersHint": "Δοκιμάστε να προσαρμόσετε τα κριτήρια αναζήτησης ή φιλτραρίσματος",
|
||||
"failedToFetchStats": "Αποτυχία ανάκτησης στατιστικών στοιχείων κοντέινερ",
|
||||
"containerNotRunning": "Το κοντέινερ δεν λειτουργεί",
|
||||
"startContainerToViewStats": "Ξεκινήστε το κοντέινερ για να δείτε στατιστικά στοιχεία",
|
||||
"loadingStats": "Φόρτωση στατιστικών στοιχείων...",
|
||||
"errorLoadingStats": "Σφάλμα φόρτωσης στατιστικών στοιχείων",
|
||||
"noStatsAvailable": "Δεν υπάρχουν διαθέσιμα στατιστικά στοιχεία",
|
||||
"cpuUsage": "Χρήση CPU",
|
||||
"current": "Ρεύμα",
|
||||
"memoryUsage": "Χρήση μνήμης",
|
||||
"networkIo": "Είσοδος/Έξοδος Δικτύου",
|
||||
"input": "Εισαγωγή",
|
||||
"output": "Παραγωγή",
|
||||
"blockIo": "Είσοδος/Έξοδος μπλοκ",
|
||||
"read": "Ανάγνωση",
|
||||
"write": "Γράφω",
|
||||
"pids": "PID",
|
||||
"containerInformation": "Πληροφορίες κοντέινερ",
|
||||
"name": "Ονομα",
|
||||
"id": "ταυτότητα",
|
||||
"state": "Κατάσταση",
|
||||
"containerMustBeRunning": "Το κοντέινερ πρέπει να εκτελείται για πρόσβαση στην κονσόλα",
|
||||
"verificationCodePrompt": "Εισαγάγετε τον κωδικό επαλήθευσης",
|
||||
"totpVerificationFailed": "Η επαλήθευση TOTP απέτυχε. Δοκιμάστε ξανά.",
|
||||
"browserSignInFailed": "Η σύνδεση απέτυχε. Δοκιμάστε ξανά.",
|
||||
"connectedTo": "Συνδεδεμένο με {{containerName}}",
|
||||
"disconnected": "Ασύνδετος",
|
||||
"consoleError": "Σφάλμα κονσόλας",
|
||||
"errorMessage": "Σφάλμα: {{message}}",
|
||||
"failedToConnect": "Αποτυχία σύνδεσης με το κοντέινερ",
|
||||
"console": "Κονσόλα",
|
||||
"selectShell": "Επιλογή κελύφους",
|
||||
"bash": "Βίαιο χτύπημα",
|
||||
"sh": "σς",
|
||||
"ash": "φλαμουριά",
|
||||
"connect": "Συνδέω",
|
||||
"disconnect": "Αποσυνδέω",
|
||||
"notConnected": "Δεν είναι συνδεδεμένο",
|
||||
"clickToConnect": "Κάντε κλικ στη σύνδεση για να ξεκινήσετε μια συνεδρία κελύφους",
|
||||
"connectingTo": "Σύνδεση με {{containerName}}...",
|
||||
"containerNotFound": "Δεν βρέθηκε κοντέινερ",
|
||||
"backToList": "Επιστροφή στη λίστα",
|
||||
"logs": "Αρχεία καταγραφής",
|
||||
"stats": "Στατιστικά",
|
||||
"consoleTab": "Κονσόλα",
|
||||
"startContainerToAccess": "Ξεκινήστε το κοντέινερ για να αποκτήσετε πρόσβαση στην κονσόλα",
|
||||
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
|
||||
"failedToFetchLogs": "Αποτυχία ανάκτησης αρχείων καταγραφής: {{error}}",
|
||||
"failedToDownloadLogs": "Αποτυχία λήψης αρχείων καταγραφής: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Λιμενεργάτης"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Λιμενεργάτης",
|
||||
"feature_docker_desc": "Δείτε κοντέινερ, διαβάστε αρχεία καταγραφής και παρακολουθήστε στατιστικά στοιχεία σε οποιονδήποτε κεντρικό υπολογιστή που εκτελεί Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Δοκιμάζω πάλι",
|
||||
"clipboardReadFailed": "Αποτυχία ανάγνωσης από το πρόχειρο. Βεβαιωθείτε ότι έχουν εκχωρηθεί δικαιώματα στο πρόχειρο.",
|
||||
"clipboardWriteFailed": "Αποτυχία αντιγραφής στο πρόχειρο. Βεβαιωθείτε ότι η σελίδα προβάλλεται μέσω HTTPS ή localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Χρήση Docker",
|
||||
"description": "Ανοίξτε τον διαχειριστή Docker και τις κονσόλες κοντέινερ σε κεντρικούς υπολογιστές με ενεργοποιημένο το Docker."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Ενεργοποίηση Docker",
|
||||
"description": "Διαχείριση κοντέινερ σε αυτόν τον κεντρικό υπολογιστή."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Χρόνος εκτέλεσης κοντέινερ",
|
||||
"description": "Επιλέξτε το CLI που χρησιμοποιείται για τη διαχείριση κοντέινερ σε αυτόν τον κεντρικό υπολογιστή",
|
||||
"docker": "Λιμενεργάτης",
|
||||
"podman": "Πόντμαν"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Απαιτείται απομακρυσμένος διακομιστής. Συνδέστε έναν απομακρυσμένο διακομιστή στις Ρυθμίσεις για να χρησιμοποιήσετε αυτόν τον τύπο σύνδεσης."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Προβολή εγγράφων",
|
||||
"hostNotFound": "Δεν βρέθηκε κεντρικός υπολογιστής",
|
||||
"loadingHost": "Φόρτωση κεντρικού υπολογιστή..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker no está habilitado para este host",
|
||||
"validating": "Validando Docker...",
|
||||
"connecting": "Conectando...",
|
||||
"error": "Error",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Error al conectar con Docker",
|
||||
"containerStarted": "Contenedor {{name}} iniciado",
|
||||
"failedToStartContainer": "Error al iniciar el contenedor {{name}}",
|
||||
"containerStopped": "Contenedor {{name}} detenido",
|
||||
"failedToStopContainer": "Error al detener el contenedor {{name}}",
|
||||
"containerRestarted": "Contenedor {{name}} reiniciado",
|
||||
"failedToRestartContainer": "Error al reiniciar el contenedor {{name}}",
|
||||
"containerPaused": "Contenedor {{name}} pausado",
|
||||
"containerUnpaused": "Contenedor {{name}} reanudado",
|
||||
"failedToTogglePauseContainer": "Error al cambiar el estado de pausa del contenedor {{name}}",
|
||||
"containerRemoved": "Contenedor {{name}} eliminado",
|
||||
"failedToRemoveContainer": "Error al eliminar el contenedor {{name}}",
|
||||
"image": "Imagen",
|
||||
"ports": "Puertos",
|
||||
"noPorts": "Sin puertos",
|
||||
"start": "Iniciar",
|
||||
"stop": "Parada",
|
||||
"status": "Estado",
|
||||
"actions": "Comportamiento",
|
||||
"cardView": "Vista de la tarjeta",
|
||||
"listView": "Vista de lista",
|
||||
"confirmRemoveContainer": "¿Está seguro de que desea eliminar el contenedor '{{name}}'? Esta acción no se puede deshacer.",
|
||||
"runningContainerWarning": "Advertencia: Este contenedor está actualmente en ejecución. Al eliminarlo, se detendrá primero.",
|
||||
"loadingContainers": "Cargando contenedores...",
|
||||
"manager": "Administrador de Docker",
|
||||
"autoRefresh": "Actualización automática",
|
||||
"timestamps": "Marcas de tiempo",
|
||||
"lines": "Líneas",
|
||||
"filterLogs": "Filtrar registros...",
|
||||
"refresh": "Actualizar",
|
||||
"download": "Descargar",
|
||||
"clear": "Limpiar",
|
||||
"logsDownloaded": "Registros descargados correctamente",
|
||||
"last50": "Últimos 50",
|
||||
"last100": "Últimos 100",
|
||||
"last500": "Últimos 500",
|
||||
"last1000": "Últimos 1000",
|
||||
"allLogs": "Todos los registros",
|
||||
"noLogsMatching": "No hay registros que coincidan con \"{{query}}\"",
|
||||
"noLogsAvailable": "No hay registros disponibles",
|
||||
"noContainersFound": "No se encontraron contenedores",
|
||||
"noContainersFoundHint": "No hay contenedores Docker disponibles en este host",
|
||||
"searchPlaceholder": "Buscar contenedores...",
|
||||
"allStatuses": "Todos los estados",
|
||||
"stateRunning": "En ejecución",
|
||||
"statePaused": "En pausa",
|
||||
"stateExited": "Detenido",
|
||||
"stateRestarting": "Reiniciando",
|
||||
"noContainersMatchFilters": "Ningún contenedor coincide con los filtros",
|
||||
"noContainersMatchFiltersHint": "Intente ajustar los criterios de búsqueda o filtro",
|
||||
"failedToFetchStats": "Error al obtener las estadísticas del contenedor",
|
||||
"containerNotRunning": "El contenedor no está en ejecución",
|
||||
"startContainerToViewStats": "Inicie el contenedor para ver las estadísticas",
|
||||
"loadingStats": "Cargando estadísticas...",
|
||||
"errorLoadingStats": "Error al cargar las estadísticas",
|
||||
"noStatsAvailable": "No hay estadísticas disponibles",
|
||||
"cpuUsage": "Uso de CPU",
|
||||
"current": "Actual",
|
||||
"memoryUsage": "Uso de memoria",
|
||||
"networkIo": "E/S de red",
|
||||
"input": "Entrada",
|
||||
"output": "Salida",
|
||||
"blockIo": "E/S de bloques",
|
||||
"read": "Lectura",
|
||||
"write": "Escritura",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Información del contenedor",
|
||||
"name": "Nombre",
|
||||
"id": "ID",
|
||||
"state": "Estado",
|
||||
"containerMustBeRunning": "El contenedor debe estar en ejecución para acceder a la consola",
|
||||
"verificationCodePrompt": "Introduzca el código de verificación",
|
||||
"totpVerificationFailed": "La verificación TOTP falló. Inténtelo de nuevo.",
|
||||
"browserSignInFailed": "Error al iniciar sesión. Inténtalo de nuevo.",
|
||||
"connectedTo": "Conectado a {{containerName}}",
|
||||
"disconnected": "Desconectado",
|
||||
"consoleError": "Error de consola",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "No se pudo conectar al contenedor",
|
||||
"console": "Consola",
|
||||
"selectShell": "Seleccionar shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Conectar",
|
||||
"disconnect": "Desconectar",
|
||||
"notConnected": "No conectado",
|
||||
"clickToConnect": "Haga clic en conectar para iniciar una sesión de shell",
|
||||
"connectingTo": "Conectando a {{containerName}}...",
|
||||
"containerNotFound": "Contenedor no encontrado",
|
||||
"backToList": "Volver a la lista",
|
||||
"logs": "Registros",
|
||||
"stats": "Estadísticas",
|
||||
"consoleTab": "Consola",
|
||||
"startContainerToAccess": "Inicie el contenedor para acceder a la consola",
|
||||
"noHostSelected": "Ningún host seleccionado",
|
||||
"failedToFetchLogs": "Error al obtener los registros: {{error}}",
|
||||
"failedToDownloadLogs": "Error al descargar los registros: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Vea los contenedores, lea los registros y observe las estadísticas en cualquier host que ejecute Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Reintentar",
|
||||
"clipboardReadFailed": "No se pudo leer del portapapeles. Asegúrese de que los permisos del portapapeles estén concedidos.",
|
||||
"clipboardWriteFailed": "Error al copiar al portapapeles. Asegúrese de que la página se sirva a través de HTTPS o en localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Utiliza Docker",
|
||||
"description": "Abra las consolas del administrador de Docker y de los contenedores en los hosts que tengan Docker habilitado."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Activar Docker",
|
||||
"description": "Administra los contenedores en este host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Runtime de contenedores",
|
||||
"description": "Elegir la CLI utilizada para la gestión de contenedores en este host.",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Se requiere un servidor remoto. Conecte un servidor remoto en Configuración para usar este tipo de conexión."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Ver documentación",
|
||||
"hostNotFound": "Host no encontrado",
|
||||
"loadingHost": "Cargando host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker ei ole käytössä tälle isännälle",
|
||||
"validating": "Dockerin validointi...",
|
||||
"connecting": "Yhdistetään...",
|
||||
"error": "Virhe",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Yhteyden muodostaminen Dockeriin epäonnistui",
|
||||
"containerStarted": "Säiliö {{name}} aloitettu",
|
||||
"failedToStartContainer": "Säiliön {{name}} käynnistäminen epäonnistui",
|
||||
"containerStopped": "Kontti {{name}} pysäytetty",
|
||||
"failedToStopContainer": "Säiliön {{name}} pysäyttäminen epäonnistui",
|
||||
"containerRestarted": "Säiliö {{name}} käynnistetty uudelleen",
|
||||
"failedToRestartContainer": "Säiliön uudelleenkäynnistys epäonnistui {{name}}",
|
||||
"containerPaused": "Säilö {{name}} keskeytetty",
|
||||
"containerUnpaused": "Säiliö {{name}} keskeytetty",
|
||||
"failedToTogglePauseContainer": "Säilön {{name}} taukotilan vaihtaminen epäonnistui",
|
||||
"containerRemoved": "Säiliö {{name}} poistettu",
|
||||
"failedToRemoveContainer": "Säilön {{name}} poistaminen epäonnistui",
|
||||
"image": "Kuva",
|
||||
"ports": "Portit",
|
||||
"noPorts": "Ei portteja",
|
||||
"start": "Aloita",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Toiminnot",
|
||||
"cardView": "Korttinäkymä",
|
||||
"listView": "Listanäkymä",
|
||||
"confirmRemoveContainer": "Haluatko varmasti poistaa säilön '{{name}}'? Tätä toimintoa ei voi perua.",
|
||||
"runningContainerWarning": "Varoitus: Tämä säilö on parhaillaan käynnissä. Sen poistaminen pysäyttää säilön ensin.",
|
||||
"loadingContainers": "Konttien lastaus...",
|
||||
"manager": "Docker-hallinta",
|
||||
"autoRefresh": "Automaattinen päivitys",
|
||||
"timestamps": "Aikaleimat",
|
||||
"lines": "Viivat",
|
||||
"filterLogs": "Suodata lokit...",
|
||||
"refresh": "Päivitä",
|
||||
"download": "Lataa",
|
||||
"clear": "Tyhjennä",
|
||||
"logsDownloaded": "Lokien lataus onnistui",
|
||||
"last50": "Viimeiset 50",
|
||||
"last100": "Viimeiset 100",
|
||||
"last500": "Viimeiset 500",
|
||||
"last1000": "Viimeiset 1000",
|
||||
"allLogs": "Kaikki lokit",
|
||||
"noLogsMatching": "Ei lokeja, jotka vastaavat \"{{query}}\"",
|
||||
"noLogsAvailable": "Ei lokeja saatavilla",
|
||||
"noContainersFound": "Ei löytynyt säiliöitä",
|
||||
"noContainersFoundHint": "Docker-kontteja ei ole saatavilla tällä isännöintipalvelimella",
|
||||
"searchPlaceholder": "Hae säilöjä...",
|
||||
"allStatuses": "Kaikki tilat",
|
||||
"stateRunning": "Juoksu",
|
||||
"statePaused": "Keskeytetty",
|
||||
"stateExited": "Poistui",
|
||||
"stateRestarting": "Uudelleenkäynnistys",
|
||||
"noContainersMatchFilters": "Yksikään säilö ei vastaa suodattimiasi",
|
||||
"noContainersMatchFiltersHint": "Kokeile muokata haku- tai suodatusehtojasi",
|
||||
"failedToFetchStats": "Säilön tilastojen nouto epäonnistui",
|
||||
"containerNotRunning": "Säiliö ei ole käynnissä",
|
||||
"startContainerToViewStats": "Käynnistä säilö tilastojen tarkastelemiseksi",
|
||||
"loadingStats": "Ladataan tilastoja...",
|
||||
"errorLoadingStats": "Virhe tilastojen lataamisessa",
|
||||
"noStatsAvailable": "Ei tilastoja saatavilla",
|
||||
"cpuUsage": "Suorittimen käyttö",
|
||||
"current": "Nykyinen",
|
||||
"memoryUsage": "Muistin käyttö",
|
||||
"networkIo": "Verkko-I/O",
|
||||
"input": "Syöttö",
|
||||
"output": "Lähtö",
|
||||
"blockIo": "Estä I/O",
|
||||
"read": "Lukea",
|
||||
"write": "Kirjoittaa",
|
||||
"pids": "PID-tunnukset",
|
||||
"containerInformation": "Säiliön tiedot",
|
||||
"name": "Nimi",
|
||||
"id": "Henkilöllisyystodistus",
|
||||
"state": "Osavaltio",
|
||||
"containerMustBeRunning": "Kontin on oltava käynnissä, jotta konsoliin pääsee käsiksi.",
|
||||
"verificationCodePrompt": "Syötä vahvistuskoodi",
|
||||
"totpVerificationFailed": "TOTP-vahvistus epäonnistui. Yritä uudelleen.",
|
||||
"browserSignInFailed": "Kirjautuminen epäonnistui. Yritä uudelleen.",
|
||||
"connectedTo": "Yhdistetty kohteeseen {{containerName}}",
|
||||
"disconnected": "Yhteys katkennut",
|
||||
"consoleError": "Konsolivirhe",
|
||||
"errorMessage": "Virhe: {{message}}",
|
||||
"failedToConnect": "Yhteyden muodostaminen säilöön epäonnistui",
|
||||
"console": "Konsoli",
|
||||
"selectShell": "Valitse komentotulkki",
|
||||
"bash": "Lyödä",
|
||||
"sh": "sh",
|
||||
"ash": "tuhka",
|
||||
"connect": "Yhdistä",
|
||||
"disconnect": "Katkaise yhteys",
|
||||
"notConnected": "Ei yhdistetty",
|
||||
"clickToConnect": "Aloita shell-istunto napsauttamalla Yhdistä",
|
||||
"connectingTo": "Yhdistetään kohteeseen {{containerName}}...",
|
||||
"containerNotFound": "Säiliötä ei löydy",
|
||||
"backToList": "Takaisin listaan",
|
||||
"logs": "Lokit",
|
||||
"stats": "Tilastot",
|
||||
"consoleTab": "Konsoli",
|
||||
"startContainerToAccess": "Käynnistä säilö päästäksesi konsoliin",
|
||||
"noHostSelected": "Ei valittua isäntää",
|
||||
"failedToFetchLogs": "Lokien nouto epäonnistui: {{error}}",
|
||||
"failedToDownloadLogs": "Lokien lataus epäonnistui: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Satamatyöläinen"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Satamatyöläinen",
|
||||
"feature_docker_desc": "Katso säilöjä, lue lokeja ja seuraa tilastoja millä tahansa Dockeria käyttävällä isännällä."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Yritä uudelleen",
|
||||
"clipboardReadFailed": "Leikepöydän lukeminen epäonnistui. Varmista, että leikepöydän käyttöoikeudet on myönnetty.",
|
||||
"clipboardWriteFailed": "Kopiointi leikepöydälle epäonnistui. Varmista, että sivua käytetään HTTPS:n tai paikallisen isännän kautta."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Käytä Dockeria",
|
||||
"description": "Avaa Docker-hallinta ja säilökonsolit isännillä, joissa Docker on käytössä."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Ota Docker käyttöön",
|
||||
"description": "Hallitse säilöjä tällä isännöinnillä."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Säilön suoritusaika",
|
||||
"description": "Valitse CLI, jota käytetään säilön hallintaan tässä isännässä",
|
||||
"docker": "Satamatyöläinen",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Etäpalvelin vaaditaan. Yhdistä etäpalvelin asetuksissa käyttääksesi tätä yhteystyyppiä."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Näytä dokumentit",
|
||||
"hostNotFound": "Isäntää ei löydy",
|
||||
"loadingHost": "Ladataan isäntää..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker n'est pas activé pour cet hôte",
|
||||
"validating": "Validation de Docker...",
|
||||
"connecting": "Connexion...",
|
||||
"error": "Erreur",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Échec de la connexion à Docker",
|
||||
"containerStarted": "Conteneur {{name}} démarré",
|
||||
"failedToStartContainer": "Échec du démarrage du conteneur {{name}}",
|
||||
"containerStopped": "Conteneur {{name}} arrêté",
|
||||
"failedToStopContainer": "Échec de l'arrêt du conteneur {{name}}",
|
||||
"containerRestarted": "Conteneur {{name}} redémarré",
|
||||
"failedToRestartContainer": "Échec du redémarrage du conteneur {{name}}",
|
||||
"containerPaused": "Conteneur {{name}} mis en pause",
|
||||
"containerUnpaused": "Conteneur {{name}} repris",
|
||||
"failedToTogglePauseContainer": "Échec du basculement de l'état de pause du conteneur {{name}}",
|
||||
"containerRemoved": "Conteneur {{name}} supprimé",
|
||||
"failedToRemoveContainer": "Échec de la suppression du conteneur {{name}}",
|
||||
"image": "Image",
|
||||
"ports": "Ports",
|
||||
"noPorts": "Aucun port",
|
||||
"start": "Démarrer",
|
||||
"stop": "Stop",
|
||||
"status": "État",
|
||||
"actions": "Actes",
|
||||
"cardView": "Vue de carte",
|
||||
"listView": "Vue liste",
|
||||
"confirmRemoveContainer": "Êtes-vous sûr de vouloir supprimer le conteneur '{{name}}' ? Cette action est irréversible.",
|
||||
"runningContainerWarning": "Attention : Ce conteneur est en cours d'exécution. Sa suppression l'arrêtera d'abord.",
|
||||
"loadingContainers": "Chargement des conteneurs...",
|
||||
"manager": "Gestionnaire Docker",
|
||||
"autoRefresh": "Actualisation automatique",
|
||||
"timestamps": "Horodatages",
|
||||
"lines": "Lignes",
|
||||
"filterLogs": "Filtrer les journaux...",
|
||||
"refresh": "Actualiser",
|
||||
"download": "Télécharger",
|
||||
"clear": "Effacer",
|
||||
"logsDownloaded": "Journaux téléchargés avec succès",
|
||||
"last50": "50 derniers",
|
||||
"last100": "100 derniers",
|
||||
"last500": "500 derniers",
|
||||
"last1000": "1 000 derniers",
|
||||
"allLogs": "Tous les journaux",
|
||||
"noLogsMatching": "Aucun journal correspondant à \"{{query}}\"",
|
||||
"noLogsAvailable": "Aucun journal disponible",
|
||||
"noContainersFound": "Aucun conteneur trouvé",
|
||||
"noContainersFoundHint": "Aucun conteneur Docker n’est disponible sur cet hôte",
|
||||
"searchPlaceholder": "Rechercher des conteneurs...",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"stateRunning": "En cours d’exécution",
|
||||
"statePaused": "En pause",
|
||||
"stateExited": "Arrêté",
|
||||
"stateRestarting": "Redémarrage en cours",
|
||||
"noContainersMatchFilters": "Aucun conteneur ne correspond à vos filtres",
|
||||
"noContainersMatchFiltersHint": "Essayez de modifier vos critères de recherche ou de filtrage",
|
||||
"failedToFetchStats": "Échec de la récupération des statistiques du conteneur",
|
||||
"containerNotRunning": "Conteneur arrêté",
|
||||
"startContainerToViewStats": "Démarrez le conteneur pour afficher les statistiques",
|
||||
"loadingStats": "Chargement des statistiques...",
|
||||
"errorLoadingStats": "Erreur lors du chargement des statistiques",
|
||||
"noStatsAvailable": "Aucune statistique disponible",
|
||||
"cpuUsage": "Utilisation CPU",
|
||||
"current": "Actuel",
|
||||
"memoryUsage": "Utilisation mémoire",
|
||||
"networkIo": "E/S réseau",
|
||||
"input": "Entrée",
|
||||
"output": "Sortie",
|
||||
"blockIo": "E/S de blocs",
|
||||
"read": "Lecture",
|
||||
"write": "Écriture",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Informations sur le conteneur",
|
||||
"name": "Nom",
|
||||
"id": "ID",
|
||||
"state": "État",
|
||||
"containerMustBeRunning": "Le conteneur doit être en cours d'exécution pour accéder à la console",
|
||||
"verificationCodePrompt": "Saisissez le code de vérification",
|
||||
"totpVerificationFailed": "La vérification TOTP a échoué. Veuillez réessayer.",
|
||||
"browserSignInFailed": "Échec de la connexion. Veuillez réessayer.",
|
||||
"connectedTo": "Connecté à {{containerName}}",
|
||||
"disconnected": "Déconnecté",
|
||||
"consoleError": "Erreur de la console",
|
||||
"errorMessage": "Erreur : {{message}}",
|
||||
"failedToConnect": "Échec de la connexion au conteneur",
|
||||
"console": "Console",
|
||||
"selectShell": "Sélectionner le shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Connecter",
|
||||
"disconnect": "Déconnecter",
|
||||
"notConnected": "Non connecté",
|
||||
"clickToConnect": "Cliquez sur Connecter pour démarrer une session shell",
|
||||
"connectingTo": "Connexion à {{containerName}}…",
|
||||
"containerNotFound": "Conteneur introuvable",
|
||||
"backToList": "Retour à la liste",
|
||||
"logs": "Journaux",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Démarrez le conteneur pour accéder à la console",
|
||||
"noHostSelected": "Aucun hôte sélectionné",
|
||||
"failedToFetchLogs": "Échec de la récupération des journaux : {{error}}",
|
||||
"failedToDownloadLogs": "Échec du téléchargement des journaux : {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Consultez les conteneurs, lisez les journaux et surveillez les statistiques sur n'importe quel hôte exécutant Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Réessayer",
|
||||
"clipboardReadFailed": "Échec de la lecture du presse-papiers. Assurez-vous que les autorisations du presse-papiers sont accordées.",
|
||||
"clipboardWriteFailed": "Échec de la copie dans le presse-papiers. Assurez-vous que la page est servie en HTTPS ou localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Utilisez Docker",
|
||||
"description": "Ouvrez les consoles du gestionnaire Docker et des conteneurs sur les hôtes sur lesquels Docker est activé."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Activer Docker",
|
||||
"description": "Gérer les conteneurs sur cet hôte."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Moteur d'exécution de conteneurs",
|
||||
"description": "Choisissez le CLI utilisé pour la gestion des conteneurs sur cet hôte",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Serveur distant requis. Connectez un serveur distant dans les paramètres pour utiliser ce type de connexion."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Voir la documentation",
|
||||
"hostNotFound": "Hôte introuvable",
|
||||
"loadingHost": "Chargement de l'hôte..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker אינו מופעל עבור מארח זה",
|
||||
"validating": "מאמת את Docker...",
|
||||
"connecting": "מְקַשֵׁר...",
|
||||
"error": "שְׁגִיאָה",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "נכשלה ההתחברות ל-Docker",
|
||||
"containerStarted": "מיכל {{name}} הופעל",
|
||||
"failedToStartContainer": "נכשל בהפעלת המכולה {{name}}",
|
||||
"containerStopped": "מיכל {{name}} נעצר",
|
||||
"failedToStopContainer": "נכשלה עצירת המכולה {{name}}",
|
||||
"containerRestarted": "המיכל {{name}} הופעל מחדש",
|
||||
"failedToRestartContainer": "נכשלה ההפעלה מחדש של המכולה {{name}}",
|
||||
"containerPaused": "מיכל {{name}} הושהה",
|
||||
"containerUnpaused": "השהייתה של המיכל {{name}} בוטלה",
|
||||
"failedToTogglePauseContainer": "נכשל הניסיון להחליף בין מצב השהייה עבור המכולה {{name}}",
|
||||
"containerRemoved": "המיכל {{name}} הוסר",
|
||||
"failedToRemoveContainer": "נכשלה הסרת המיכל {{name}}",
|
||||
"image": "תְמוּנָה",
|
||||
"ports": "נמלים",
|
||||
"noPorts": "אין יציאות",
|
||||
"start": "הַתחָלָה",
|
||||
"stop": "לְהַפְסִיק",
|
||||
"status": "סטָטוּס",
|
||||
"actions": "פעולות",
|
||||
"cardView": "תצוגת כרטיס",
|
||||
"listView": "תצוגת רשימה",
|
||||
"confirmRemoveContainer": "האם אתה בטוח שברצונך להסיר את המכולה '{{name}}'? לא ניתן לבטל פעולה זו.",
|
||||
"runningContainerWarning": "אזהרה: מיכל זה פועל כעת. הסרתו תעצור את המיכל תחילה.",
|
||||
"loadingContainers": "טוען מכולות...",
|
||||
"manager": "מנהל דוקר",
|
||||
"autoRefresh": "רענון אוטומטי",
|
||||
"timestamps": "חותמות זמן",
|
||||
"lines": "קווים",
|
||||
"filterLogs": "סנן יומני רישום...",
|
||||
"refresh": "לְרַעֲנֵן",
|
||||
"download": "הורדה",
|
||||
"clear": "בָּרוּר",
|
||||
"logsDownloaded": "יומני הרישום הורדו בהצלחה",
|
||||
"last50": "50 האחרונים",
|
||||
"last100": "100 האחרונים",
|
||||
"last500": "500 האחרונים",
|
||||
"last1000": "1000 האחרונים",
|
||||
"allLogs": "כל היומנים",
|
||||
"noLogsMatching": "אין יומנים התואמים ל-\"{{query}}\"",
|
||||
"noLogsAvailable": "אין יומנים זמינים",
|
||||
"noContainersFound": "לא נמצאו מכולות",
|
||||
"noContainersFoundHint": "אין מכולות Docker זמינות במארח זה",
|
||||
"searchPlaceholder": "חיפוש מכולות...",
|
||||
"allStatuses": "כל הסטטוסים",
|
||||
"stateRunning": "רִיצָה",
|
||||
"statePaused": "מושהה",
|
||||
"stateExited": "יצא",
|
||||
"stateRestarting": "הפעלה מחדש",
|
||||
"noContainersMatchFilters": "אין מכולות התואמות את המסננים שלך",
|
||||
"noContainersMatchFiltersHint": "נסה להתאים את קריטריוני החיפוש או הסינון שלך",
|
||||
"failedToFetchStats": "נכשל באחזור סטטיסטיקות המכולה",
|
||||
"containerNotRunning": "המכולה לא פועלת",
|
||||
"startContainerToViewStats": "הפעל את המכולה כדי להציג נתונים סטטיסטיים",
|
||||
"loadingStats": "טוען סטטיסטיקות...",
|
||||
"errorLoadingStats": "שגיאה בטעינת סטטיסטיקות",
|
||||
"noStatsAvailable": "אין סטטיסטיקות זמינות",
|
||||
"cpuUsage": "שימוש במעבד",
|
||||
"current": "נוֹכְחִי",
|
||||
"memoryUsage": "שימוש בזיכרון",
|
||||
"networkIo": "קלט/פלט של הרשת",
|
||||
"input": "קֶלֶט",
|
||||
"output": "תְפוּקָה",
|
||||
"blockIo": "בלוק קלט/פלט",
|
||||
"read": "לִקְרוֹא",
|
||||
"write": "לִכתוֹב",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "מידע על המכולה",
|
||||
"name": "שֵׁם",
|
||||
"id": "תְעוּדַת זֶהוּת",
|
||||
"state": "מְדִינָה",
|
||||
"containerMustBeRunning": "יש לפעול על מנת לגשת למסוף.",
|
||||
"verificationCodePrompt": "הזן קוד אימות",
|
||||
"totpVerificationFailed": "אימות TOTP נכשל. אנא נסה שוב.",
|
||||
"browserSignInFailed": "הכניסה נכשלה. אנא נסה שוב.",
|
||||
"connectedTo": "מחובר אל {{containerName}}",
|
||||
"disconnected": "מְנוּתָק",
|
||||
"consoleError": "שגיאת קונסולה",
|
||||
"errorMessage": "שגיאה: {{message}}",
|
||||
"failedToConnect": "נכשל החיבור למכולה",
|
||||
"console": "לְנַחֵם",
|
||||
"selectShell": "בחר מעטפת",
|
||||
"bash": "לַחֲבוֹט",
|
||||
"sh": "ש",
|
||||
"ash": "אֵפֶר",
|
||||
"connect": "לְחַבֵּר",
|
||||
"disconnect": "לְנַתֵק",
|
||||
"notConnected": "לא מחובר",
|
||||
"clickToConnect": "לחץ על התחבר כדי להתחיל סשן מעטפת",
|
||||
"connectingTo": "מתחבר אל {{containerName}}...",
|
||||
"containerNotFound": "המיכל לא נמצא",
|
||||
"backToList": "חזרה לרשימה",
|
||||
"logs": "יומני רישום",
|
||||
"stats": "סטטיסטיקות",
|
||||
"consoleTab": "לְנַחֵם",
|
||||
"startContainerToAccess": "הפעל את המכולה כדי לגשת לקונסולה",
|
||||
"noHostSelected": "לא נבחר מארח",
|
||||
"failedToFetchLogs": "נכשלה אחזור הלוגים: {{error}}",
|
||||
"failedToDownloadLogs": "הורדת יומני רישום נכשלה: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "דוקר"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "דוקר",
|
||||
"feature_docker_desc": "ראה קונטיינרים, קרא יומני רישום וצפה בסטטיסטיקות על כל מארח שמפעיל את Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "נסה שוב",
|
||||
"clipboardReadFailed": "הקריאה מהלוח נכשלה. ודא שהרשאות הלוח ניתנות.",
|
||||
"clipboardWriteFailed": "ההעתקה ללוח נכשלה. ודא שהדף מוגש דרך HTTPS או localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "השתמש ב-Docker",
|
||||
"description": "פתח את מנהל ה-Docker ואת קונסולות המכולות במחשבים מארחים שבהם Docker מופעל."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "הפעל את Docker",
|
||||
"description": "ניהול מכולות במארח זה."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "זמן ריצה של המכולה",
|
||||
"description": "בחר את ממשק שורת הפקודה (CLI) המשמש לניהול מכולות במארח זה",
|
||||
"docker": "דוקר",
|
||||
"podman": "פודמן"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "נדרש שרת מרוחק. יש להתחבר לשרת מרוחק בהגדרות כדי להשתמש בסוג חיבור זה."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "הצג מסמכים",
|
||||
"hostNotFound": "המארח לא נמצא",
|
||||
"loadingHost": "טוען מארח..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "इस होस्ट के लिए Docker सक्षम नहीं है",
|
||||
"validating": "Docker सत्यापित कर रहा है...",
|
||||
"connecting": "कनेक्ट हो रहा है...",
|
||||
"error": "त्रुटि",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Docker से कनेक्ट होने में विफल",
|
||||
"containerStarted": "कंटेनर {{name}} प्रारंभ हुआ",
|
||||
"failedToStartContainer": "कंटेनर {{name}} प्रारंभ करने में विफल",
|
||||
"containerStopped": "कंटेनर {{name}} रुका",
|
||||
"failedToStopContainer": "कंटेनर {{name}} रोकने में विफल",
|
||||
"containerRestarted": "कंटेनर {{name}} पुनरारंभ हुआ",
|
||||
"failedToRestartContainer": "कंटेनर {{name}} पुनरारंभ करने में विफल",
|
||||
"containerPaused": "कंटेनर {{name}} विरामित",
|
||||
"containerUnpaused": "कंटेनर {{name}} जारी",
|
||||
"failedToTogglePauseContainer": "कंटेनर {{name}} के लिए विराम/जारी स्थिति बदलने में विफल",
|
||||
"containerRemoved": "कंटेनर {{name}} हटा दिया गया",
|
||||
"failedToRemoveContainer": "कंटेनर {{name}} हटाने में विफल",
|
||||
"image": "इमेज",
|
||||
"ports": "पोर्ट",
|
||||
"noPorts": "कोई पोर्ट नहीं",
|
||||
"start": "प्रारंभ करें",
|
||||
"stop": "स्टॉप",
|
||||
"status": "स्थिति",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "क्या आप वाकई कंटेनर '{{name}}' हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
|
||||
"runningContainerWarning": "चेतावनी: यह कंटेनर वर्तमान में चल रहा है। हटाने पर पहले इसे रोका जाएगा।",
|
||||
"loadingContainers": "कंटेनर लोड हो रहे हैं...",
|
||||
"manager": "Docker प्रबंधक",
|
||||
"autoRefresh": "स्वतः ताज़ा करें",
|
||||
"timestamps": "टाइमस्टैम्प्स",
|
||||
"lines": "पंक्तियाँ",
|
||||
"filterLogs": "लॉग फ़िल्टर करें...",
|
||||
"refresh": "ताज़ा करें",
|
||||
"download": "डाउनलोड करें",
|
||||
"clear": "साफ़ करें",
|
||||
"logsDownloaded": "लॉग सफलतापूर्वक डाउनलोड हो गए",
|
||||
"last50": "अंतिम 50",
|
||||
"last100": "अंतिम 100",
|
||||
"last500": "अंतिम 500",
|
||||
"last1000": "अंतिम 1000",
|
||||
"allLogs": "सभी लॉग",
|
||||
"noLogsMatching": "\"{{query}}\" से मेल खाने वाले कोई लॉग नहीं हैं",
|
||||
"noLogsAvailable": "कोई लॉग उपलब्ध नहीं",
|
||||
"noContainersFound": "कोई कंटेनर नहीं मिले",
|
||||
"noContainersFoundHint": "इस होस्ट पर कोई Docker कंटेनर उपलब्ध नहीं हैं",
|
||||
"searchPlaceholder": "कंटेनर खोजें...",
|
||||
"allStatuses": "सभी स्टेटस",
|
||||
"stateRunning": "चालू",
|
||||
"statePaused": "रुका हुआ",
|
||||
"stateExited": "बंद",
|
||||
"stateRestarting": "रीस्टार्ट हो रहा है",
|
||||
"noContainersMatchFilters": "आपके फ़िल्टर से मेल खाने वाले कोई कंटेनर नहीं हैं",
|
||||
"noContainersMatchFiltersHint": "अपनी खोज या फ़िल्टर मापदंड बदलकर देखें",
|
||||
"failedToFetchStats": "कंटेनर के आँकड़े प्राप्त नहीं हो सके",
|
||||
"containerNotRunning": "कंटेनर चालू नहीं है",
|
||||
"startContainerToViewStats": "आँकड़े देखने के लिए कंटेनर चालू करें",
|
||||
"loadingStats": "आँकड़े लोड हो रहे हैं...",
|
||||
"errorLoadingStats": "आँकड़े लोड करने में त्रुटि",
|
||||
"noStatsAvailable": "कोई आँकड़े उपलब्ध नहीं",
|
||||
"cpuUsage": "CPU उपयोग",
|
||||
"current": "वर्तमान",
|
||||
"memoryUsage": "मेमोरी उपयोग",
|
||||
"networkIo": "नेटवर्क I/O",
|
||||
"input": "इनपुट",
|
||||
"output": "आउटपुट",
|
||||
"blockIo": "ब्लॉक I/O",
|
||||
"read": "रीड",
|
||||
"write": "राइट",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "कंटेनर जानकारी",
|
||||
"name": "नाम",
|
||||
"id": "आईडी",
|
||||
"state": "स्थिति",
|
||||
"containerMustBeRunning": "कंसोल तक पहुंचने के लिए कंटेनर चालू होना चाहिए",
|
||||
"verificationCodePrompt": "सत्यापन कोड दर्ज करें",
|
||||
"totpVerificationFailed": "TOTP सत्यापन विफल। कृपया पुनः प्रयास करें।",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "{{containerName}} से जुड़ा",
|
||||
"disconnected": "डिस्कनेक्टेड",
|
||||
"consoleError": "कंसोल त्रुटि",
|
||||
"errorMessage": "त्रुटि: {{message}}",
|
||||
"failedToConnect": "कंटेनर से जुड़ने में विफल",
|
||||
"console": "कंसोल",
|
||||
"selectShell": "शेल चुनें",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "कनेक्ट करें",
|
||||
"disconnect": "डिस्कनेक्ट करें",
|
||||
"notConnected": "कनेक्टेड नहीं",
|
||||
"clickToConnect": "शेल सत्र शुरू करने के लिए कनेक्ट पर क्लिक करें",
|
||||
"connectingTo": "{{containerName}} से कनेक्ट हो रहा है...",
|
||||
"containerNotFound": "कंटेनर नहीं मिला",
|
||||
"backToList": "सूची में वापस जाएं",
|
||||
"logs": "लॉग",
|
||||
"stats": "आंकड़े",
|
||||
"consoleTab": "कंसोल",
|
||||
"startContainerToAccess": "कंसोल तक पहुंचने के लिए कंटेनर चालू करें",
|
||||
"noHostSelected": "कोई होस्ट चयनित नहीं है",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "पुनः प्रयास करें",
|
||||
"clipboardReadFailed": "क्लिपबोर्ड से पढ़ने में विफल। सुनिश्चित करें कि क्लिपबोर्ड अनुमतियाँ दी गई हैं।",
|
||||
"clipboardWriteFailed": "क्लिपबोर्ड पर कॉपी करने में विफल। सुनिश्चित करें कि पेज HTTPS या लोकलहोस्ट पर सर्व किया गया है।"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker सक्षम करें",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "कंटेनर रनटाइम",
|
||||
"description": "इस होस्ट पर कंटेनर प्रबंधन के लिए उपयोग किए जाने वाले CLI को चुनें",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "दस्तावेज़ देखें",
|
||||
"hostNotFound": "होस्ट नहीं मिला",
|
||||
"loadingHost": "होस्ट लोड हो रहा है..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "A Docker nincs engedélyezve ehhez a gazdagéphez",
|
||||
"validating": "Docker érvényesítése...",
|
||||
"connecting": "Kapcsolódás...",
|
||||
"error": "Hiba",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Nem sikerült csatlakozni a Dockerhez",
|
||||
"containerStarted": "{{name}} konténer elindítva",
|
||||
"failedToStartContainer": "Nem sikerült elindítani a(z) {{name}} konténert",
|
||||
"containerStopped": "Konténer {{name}} leállítva",
|
||||
"failedToStopContainer": "Nem sikerült leállítani a(z) {{name}} konténert",
|
||||
"containerRestarted": "{{name}} konténer újraindítva",
|
||||
"failedToRestartContainer": "Nem sikerült újraindítani a konténert {{name}}",
|
||||
"containerPaused": "{{name}} konténer szünetel",
|
||||
"containerUnpaused": "{{name}} konténer szüneteltetése feloldva",
|
||||
"failedToTogglePauseContainer": "Nem sikerült a szüneteltetési állapotot be- és kikapcsolni a következő konténernél: {{name}}",
|
||||
"containerRemoved": "{{name}} konténer eltávolítva",
|
||||
"failedToRemoveContainer": "Nem sikerült eltávolítani a(z) {{name}} konténert",
|
||||
"image": "Kép",
|
||||
"ports": "kikötők",
|
||||
"noPorts": "Nincsenek portok",
|
||||
"start": "Indul",
|
||||
"stop": "Stop",
|
||||
"status": "Állapot",
|
||||
"actions": "Műveletek",
|
||||
"cardView": "Kártyanézet",
|
||||
"listView": "Lista nézet",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Figyelmeztetés: Ez a konténer jelenleg fut. Eltávolítása először a konténer leállítását jelenti.",
|
||||
"loadingContainers": "Konténerek betöltése...",
|
||||
"manager": "Docker-kezelő",
|
||||
"autoRefresh": "Automatikus frissítés",
|
||||
"timestamps": "Időbélyegek",
|
||||
"lines": "Vonalak",
|
||||
"filterLogs": "Naplók szűrése...",
|
||||
"refresh": "Frissítés",
|
||||
"download": "Letöltés",
|
||||
"clear": "Világos",
|
||||
"logsDownloaded": "Naplók sikeresen letöltve",
|
||||
"last50": "Utolsó 50",
|
||||
"last100": "Utolsó 100",
|
||||
"last500": "Utolsó 500",
|
||||
"last1000": "Utolsó 1000",
|
||||
"allLogs": "Minden napló",
|
||||
"noLogsMatching": "Nincsenek a következőnek megfelelő naplók: \"{{query}}\"",
|
||||
"noLogsAvailable": "Nincsenek elérhető naplók",
|
||||
"noContainersFound": "Nem találhatók konténerek",
|
||||
"noContainersFoundHint": "Nincsenek elérhető Docker konténerek ezen a gazdagépen",
|
||||
"searchPlaceholder": "Konténerek keresése...",
|
||||
"allStatuses": "Minden állapot",
|
||||
"stateRunning": "Futás",
|
||||
"statePaused": "Szüneteltetett",
|
||||
"stateExited": "Kilépett",
|
||||
"stateRestarting": "Újraindítás",
|
||||
"noContainersMatchFilters": "Egyetlen tároló sem felel meg a szűrőknek",
|
||||
"noContainersMatchFiltersHint": "Próbálja meg módosítani a keresési vagy szűrési feltételeket",
|
||||
"failedToFetchStats": "Nem sikerült lekérni a konténer statisztikáit",
|
||||
"containerNotRunning": "A konténer nem működik",
|
||||
"startContainerToViewStats": "Indítsa el a konténert a statisztikák megtekintéséhez",
|
||||
"loadingStats": "Statisztikák betöltése...",
|
||||
"errorLoadingStats": "Hiba a statisztikák betöltése során",
|
||||
"noStatsAvailable": "Nincsenek elérhető statisztikák",
|
||||
"cpuUsage": "CPU-használat",
|
||||
"current": "Jelenlegi",
|
||||
"memoryUsage": "Memóriahasználat",
|
||||
"networkIo": "Hálózati I/O",
|
||||
"input": "Bemenet",
|
||||
"output": "Kimenet",
|
||||
"blockIo": "Blokk I/O",
|
||||
"read": "Olvas",
|
||||
"write": "Írás",
|
||||
"pids": "PID-ek",
|
||||
"containerInformation": "Konténerinformációk",
|
||||
"name": "Név",
|
||||
"id": "azonosító",
|
||||
"state": "Állami",
|
||||
"containerMustBeRunning": "A konténernek futnia kell a konzol eléréséhez",
|
||||
"verificationCodePrompt": "Írja be az ellenőrző kódot",
|
||||
"totpVerificationFailed": "A TOTP ellenőrzése sikertelen. Próbáld újra.",
|
||||
"browserSignInFailed": "Sikertelen bejelentkezés. Kérjük, próbálja újra.",
|
||||
"connectedTo": "Csatlakoztatva ehhez: {{containerName}}",
|
||||
"disconnected": "Szétkapcsolt",
|
||||
"consoleError": "Konzolhiba",
|
||||
"errorMessage": "Hiba: {{message}}",
|
||||
"failedToConnect": "Nem sikerült csatlakozni a konténerhez",
|
||||
"console": "Konzol",
|
||||
"selectShell": "Válassza ki a héjat",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "hamu",
|
||||
"connect": "Csatlakozás",
|
||||
"disconnect": "Leválasztás",
|
||||
"notConnected": "Nincs csatlakoztatva",
|
||||
"clickToConnect": "Kattintson a Csatlakozás gombra a shell munkamenet elindításához",
|
||||
"connectingTo": "Kapcsolódás ehhez: {{containerName}}...",
|
||||
"containerNotFound": "A konténer nem található",
|
||||
"backToList": "Vissza a listához",
|
||||
"logs": "Naplók",
|
||||
"stats": "Statisztikák",
|
||||
"consoleTab": "Konzol",
|
||||
"startContainerToAccess": "Indítsa el a konténert a konzol eléréséhez",
|
||||
"noHostSelected": "Nincs kiválasztva gazdagép",
|
||||
"failedToFetchLogs": "Nem sikerült lekérni a naplókat: {{error}}",
|
||||
"failedToDownloadLogs": "Nem sikerült letölteni a naplókat: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Dokkmunkás"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Dokkmunkás",
|
||||
"feature_docker_desc": "Tekintsd meg a konténereket, olvasd el a naplókat és figyeld a statisztikákat bármelyik Dockert futtató gazdagépen."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Újrapróbálkozás",
|
||||
"clipboardReadFailed": "Nem sikerült beolvasni a vágólapról. Győződjön meg arról, hogy a vágólapra vonatkozó engedélyek meg vannak adva.",
|
||||
"clipboardWriteFailed": "Nem sikerült a vágólapra másolni. Győződjön meg arról, hogy az oldal HTTPS vagy localhost protokollon keresztül jelenik meg."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Docker használata",
|
||||
"description": "Nyissa meg a Docker Managert és a konténerkonzolokat olyan gazdagépeken, amelyeken engedélyezve van a Docker."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker engedélyezése",
|
||||
"description": "Konténerek kezelése ezen a gazdagépen."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Konténer futásideje",
|
||||
"description": "Válassza ki a gazdagépen a konténerkezeléshez használt parancssori felületet",
|
||||
"docker": "Dokkmunkás",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Távoli szerver szükséges. Csatlakoztasson egy távoli szervert a Beállításokban a kapcsolattípus használatához."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Dokumentumok megtekintése",
|
||||
"hostNotFound": "Nem található gazdagép",
|
||||
"loadingHost": "Gazdagép betöltése..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker is not enabled for this host",
|
||||
"validating": "Validating Docker...",
|
||||
"connecting": "Connecting...",
|
||||
"error": "Error",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Failed to connect to Docker",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container {{name}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||
"image": "Image",
|
||||
"ports": "Ports",
|
||||
"noPorts": "No ports",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||
"loadingContainers": "Loading containers...",
|
||||
"manager": "Docker Manager",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
"timestamps": "Timestamps",
|
||||
"lines": "Lines",
|
||||
"filterLogs": "Filter logs...",
|
||||
"refresh": "Refresh",
|
||||
"download": "Download",
|
||||
"clear": "Clear",
|
||||
"logsDownloaded": "Logs downloaded successfully",
|
||||
"last50": "Last 50",
|
||||
"last100": "Last 100",
|
||||
"last500": "Last 500",
|
||||
"last1000": "Last 1000",
|
||||
"allLogs": "All Logs",
|
||||
"noLogsMatching": "No logs matching \"{{query}}\"",
|
||||
"noLogsAvailable": "No logs available",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||
"searchPlaceholder": "Search containers...",
|
||||
"allStatuses": "All Statuses",
|
||||
"stateRunning": "Running",
|
||||
"statePaused": "Paused",
|
||||
"stateExited": "Exited",
|
||||
"stateRestarting": "Restarting",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||
"failedToFetchStats": "Failed to fetch container statistics",
|
||||
"containerNotRunning": "Container not running",
|
||||
"startContainerToViewStats": "Start the container to view statistics",
|
||||
"loadingStats": "Loading statistics...",
|
||||
"errorLoadingStats": "Error loading statistics",
|
||||
"noStatsAvailable": "No statistics available",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"current": "Current",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"networkIo": "Network I/O",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"blockIo": "Block I/O",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Container Information",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"containerMustBeRunning": "Container must be running to access console",
|
||||
"verificationCodePrompt": "Enter verification code",
|
||||
"totpVerificationFailed": "TOTP verification failed. Please try again.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Disconnected",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "Failed to connect to container",
|
||||
"console": "Console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click connect to start a shell session",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to List",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console",
|
||||
"noHostSelected": "No host selected",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Retry",
|
||||
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
||||
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Enable Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Choose the CLI used for container management on this host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker non è abilitato per questo host",
|
||||
"validating": "Convalida Docker in corso...",
|
||||
"connecting": "Connessione in corso...",
|
||||
"error": "Errore",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Connessione a Docker non riuscita",
|
||||
"containerStarted": "Container {{name}} avviato",
|
||||
"failedToStartContainer": "Impossibile avviare il container {{name}}",
|
||||
"containerStopped": "Container {{name}} arrestato",
|
||||
"failedToStopContainer": "Impossibile arrestare il container {{name}}",
|
||||
"containerRestarted": "Container {{name}} riavviato",
|
||||
"failedToRestartContainer": "Impossibile riavviare il container {{name}}",
|
||||
"containerPaused": "Container {{name}} in pausa",
|
||||
"containerUnpaused": "Container {{name}} riattivato",
|
||||
"failedToTogglePauseContainer": "Impossibile cambiare lo stato di pausa del container {{name}}",
|
||||
"containerRemoved": "Container {{name}} rimosso",
|
||||
"failedToRemoveContainer": "Impossibile rimuovere il container {{name}}",
|
||||
"image": "Immagine",
|
||||
"ports": "Porte",
|
||||
"noPorts": "Nessuna porta",
|
||||
"start": "Avvia",
|
||||
"stop": "Stop",
|
||||
"status": "Stato",
|
||||
"actions": "Azioni",
|
||||
"cardView": "Visualizzazione a schede",
|
||||
"listView": "Visualizzazione elenco",
|
||||
"confirmRemoveContainer": "Sei sicuro di voler rimuovere il container '{{name}}'? Questa azione non può essere annullata.",
|
||||
"runningContainerWarning": "Attenzione: questo container è attualmente in esecuzione. La rimozione lo arresterà prima.",
|
||||
"loadingContainers": "Caricamento container in corso...",
|
||||
"manager": "Gestore Docker",
|
||||
"autoRefresh": "Aggiornamento automatico",
|
||||
"timestamps": "Timestamp",
|
||||
"lines": "Righe",
|
||||
"filterLogs": "Filtra log...",
|
||||
"refresh": "Aggiorna",
|
||||
"download": "Scarica",
|
||||
"clear": "Pulisci",
|
||||
"logsDownloaded": "Log scaricati con successo",
|
||||
"last50": "Ultime 50",
|
||||
"last100": "Ultime 100",
|
||||
"last500": "Ultime 500",
|
||||
"last1000": "Ultime 1000",
|
||||
"allLogs": "Tutti i log",
|
||||
"noLogsMatching": "Nessun log corrispondente a \"{{query}}\"",
|
||||
"noLogsAvailable": "Nessun log disponibile",
|
||||
"noContainersFound": "Nessun contenitore trovato",
|
||||
"noContainersFoundHint": "Nessun contenitore Docker disponibile su questo host",
|
||||
"searchPlaceholder": "Cerca contenitori...",
|
||||
"allStatuses": "Tutti gli stati",
|
||||
"stateRunning": "In esecuzione",
|
||||
"statePaused": "In pausa",
|
||||
"stateExited": "Terminato",
|
||||
"stateRestarting": "In riavvio",
|
||||
"noContainersMatchFilters": "Nessun contenitore corrisponde ai filtri",
|
||||
"noContainersMatchFiltersHint": "Prova a modificare i criteri di ricerca o di filtro",
|
||||
"failedToFetchStats": "Impossibile recuperare le statistiche del contenitore",
|
||||
"containerNotRunning": "Contenitore non in esecuzione",
|
||||
"startContainerToViewStats": "Avvia il contenitore per visualizzare le statistiche",
|
||||
"loadingStats": "Caricamento statistiche...",
|
||||
"errorLoadingStats": "Errore nel caricamento delle statistiche",
|
||||
"noStatsAvailable": "Nessuna statistica disponibile",
|
||||
"cpuUsage": "Utilizzo CPU",
|
||||
"current": "Corrente",
|
||||
"memoryUsage": "Utilizzo memoria",
|
||||
"networkIo": "I/O di rete",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"blockIo": "Block I/O",
|
||||
"read": "Lettura",
|
||||
"write": "Scrittura",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Informazioni sul container",
|
||||
"name": "Nome",
|
||||
"id": "ID",
|
||||
"state": "Stato",
|
||||
"containerMustBeRunning": "Il container deve essere in esecuzione per accedere alla console",
|
||||
"verificationCodePrompt": "Inserisci il codice di verifica",
|
||||
"totpVerificationFailed": "Verifica TOTP fallita. Riprova.",
|
||||
"browserSignInFailed": "Accesso non riuscito. Riprova.",
|
||||
"connectedTo": "Connesso a {{containerName}}",
|
||||
"disconnected": "Disconnesso",
|
||||
"consoleError": "Errore console",
|
||||
"errorMessage": "Errore: {{message}}",
|
||||
"failedToConnect": "Connessione al container fallita",
|
||||
"console": "Console",
|
||||
"selectShell": "Seleziona shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Connetti",
|
||||
"disconnect": "Disconnetti",
|
||||
"notConnected": "Non connesso",
|
||||
"clickToConnect": "Clicca Connetti per avviare una sessione shell",
|
||||
"connectingTo": "Connessione a {{containerName}} in corso...",
|
||||
"containerNotFound": "Container non trovato",
|
||||
"backToList": "Torna all'elenco",
|
||||
"logs": "Log",
|
||||
"stats": "Statistiche",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Avvia il container per accedere alla console",
|
||||
"noHostSelected": "Nessun host selezionato",
|
||||
"failedToFetchLogs": "Impossibile recuperare i log: {{error}}",
|
||||
"failedToDownloadLogs": "Impossibile scaricare i log: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Visualizza i container, leggi i log e controlla le statistiche su qualsiasi host che esegue Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Riprova",
|
||||
"clipboardReadFailed": "Lettura dagli appunti non riuscita. Assicurati che i permessi degli appunti siano stati concessi.",
|
||||
"clipboardWriteFailed": "Copia negli appunti non riuscita. Assicurati che la pagina sia servita via HTTPS o localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Utilizzare Docker",
|
||||
"description": "Apri il gestore Docker e le console dei container sugli host in cui Docker è abilitato."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Abilita Docker",
|
||||
"description": "Gestisci i container su questo host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Runtime container",
|
||||
"description": "Scegli la CLI utilizzata per la gestione dei container su questo host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "È necessario un server remoto. Per utilizzare questo tipo di connessione, collega un server remoto nelle Impostazioni."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Visualizza documentazione",
|
||||
"hostNotFound": "Host non trovato",
|
||||
"loadingHost": "Caricamento host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "このホストではDockerが有効になっていません",
|
||||
"validating": "Dockerを検証中...",
|
||||
"connecting": "接続中...",
|
||||
"error": "エラー",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Dockerへの接続に失敗しました",
|
||||
"containerStarted": "コンテナ {{name}} が起動しました",
|
||||
"failedToStartContainer": "コンテナ {{name}} の起動に失敗しました",
|
||||
"containerStopped": "コンテナ {{name}} が停止しました",
|
||||
"failedToStopContainer": "コンテナ {{name}} の停止に失敗しました",
|
||||
"containerRestarted": "コンテナ {{name}} が再起動しました",
|
||||
"failedToRestartContainer": "コンテナ {{name}} の再起動に失敗しました",
|
||||
"containerPaused": "コンテナ {{name}} が一時停止しました",
|
||||
"containerUnpaused": "コンテナ {{name}} の一時停止が解除されました",
|
||||
"failedToTogglePauseContainer": "コンテナ {{name}} の一時停止状態の切り替えに失敗しました",
|
||||
"containerRemoved": "コンテナ {{name}} が削除されました",
|
||||
"failedToRemoveContainer": "コンテナ {{name}} の削除に失敗しました",
|
||||
"image": "イメージ",
|
||||
"ports": "ポート",
|
||||
"noPorts": "ポートなし",
|
||||
"start": "起動",
|
||||
"stop": "ストップ",
|
||||
"status": "状態",
|
||||
"actions": "行動",
|
||||
"cardView": "カードビュー",
|
||||
"listView": "リストビュー",
|
||||
"confirmRemoveContainer": "コンテナ '{{name}}' を削除してもよろしいですか? この操作は元に戻せません。",
|
||||
"runningContainerWarning": "警告: このコンテナは現在実行中です。削除すると、まずコンテナが停止されます。",
|
||||
"loadingContainers": "コンテナを読み込み中...",
|
||||
"manager": "Docker マネージャー",
|
||||
"autoRefresh": "自動更新",
|
||||
"timestamps": "タイムスタンプ",
|
||||
"lines": "行数",
|
||||
"filterLogs": "ログをフィルター...",
|
||||
"refresh": "再読み込み",
|
||||
"download": "ダウンロード",
|
||||
"clear": "クリア",
|
||||
"logsDownloaded": "ログのダウンロードに成功しました",
|
||||
"last50": "直近50件",
|
||||
"last100": "直近100件",
|
||||
"last500": "直近500件",
|
||||
"last1000": "直近1000件",
|
||||
"allLogs": "すべてのログ",
|
||||
"noLogsMatching": "検索条件 \"{{query}}\" に一致するログはありません",
|
||||
"noLogsAvailable": "利用可能なログはありません",
|
||||
"noContainersFound": "コンテナが見つかりません",
|
||||
"noContainersFoundHint": "このホストには利用可能なDockerコンテナがありません",
|
||||
"searchPlaceholder": "コンテナを検索...",
|
||||
"allStatuses": "すべての状態",
|
||||
"stateRunning": "実行中",
|
||||
"statePaused": "一時停止中",
|
||||
"stateExited": "終了済み",
|
||||
"stateRestarting": "再起動中",
|
||||
"noContainersMatchFilters": "条件に一致するコンテナはありません",
|
||||
"noContainersMatchFiltersHint": "検索条件またはフィルター条件を調整してください",
|
||||
"failedToFetchStats": "コンテナの統計情報の取得に失敗しました",
|
||||
"containerNotRunning": "コンテナが実行されていません",
|
||||
"startContainerToViewStats": "統計情報を表示するにはコンテナを起動してください",
|
||||
"loadingStats": "統計情報を読み込み中...",
|
||||
"errorLoadingStats": "統計情報の読み込みエラー",
|
||||
"noStatsAvailable": "利用可能な統計情報はありません",
|
||||
"cpuUsage": "CPU使用率",
|
||||
"current": "現在",
|
||||
"memoryUsage": "メモリ使用量",
|
||||
"networkIo": "ネットワークI/O",
|
||||
"input": "受信",
|
||||
"output": "出力",
|
||||
"blockIo": "ブロック I/O",
|
||||
"read": "読み取り",
|
||||
"write": "書き込み",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "コンテナ情報",
|
||||
"name": "名前",
|
||||
"id": "ID",
|
||||
"state": "状態",
|
||||
"containerMustBeRunning": "コンソールにアクセスするにはコンテナが実行中である必要があります",
|
||||
"verificationCodePrompt": "認証コードを入力してください",
|
||||
"totpVerificationFailed": "TOTP認証に失敗しました。もう一度お試しください。",
|
||||
"browserSignInFailed": "ログインに失敗しました。もう一度お試しください。",
|
||||
"connectedTo": "{{containerName}}に接続しました",
|
||||
"disconnected": "切断されました",
|
||||
"consoleError": "コンソールエラー",
|
||||
"errorMessage": "エラー: {{message}}",
|
||||
"failedToConnect": "コンテナに接続できませんでした",
|
||||
"console": "コンソール",
|
||||
"selectShell": "シェルを選択",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "接続",
|
||||
"disconnect": "切断",
|
||||
"notConnected": "未接続",
|
||||
"clickToConnect": "接続ボタンをクリックしてシェルセッションを開始",
|
||||
"connectingTo": "{{containerName}}に接続中...",
|
||||
"containerNotFound": "コンテナが見つかりません",
|
||||
"backToList": "一覧に戻る",
|
||||
"logs": "ログ",
|
||||
"stats": "統計",
|
||||
"consoleTab": "コンソール",
|
||||
"startContainerToAccess": "コンテナを起動してコンソールにアクセスしてください",
|
||||
"noHostSelected": "ホストが選択されていません",
|
||||
"failedToFetchLogs": "ログの取得に失敗しました: {{error}}",
|
||||
"failedToDownloadLogs": "ログのダウンロードに失敗しました: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Dockerを実行している任意のホスト上で、コンテナの表示、ログの読み取り、統計情報の監視が可能です。"
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "再試行",
|
||||
"clipboardReadFailed": "クリップボードからの読み取りに失敗しました。クリップボードのアクセス許可が付与されていることを確認してください。",
|
||||
"clipboardWriteFailed": "クリップボードへのコピーに失敗しました。ページがHTTPSまたはlocalhostで提供されていることを確認してください。"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Dockerを使用する",
|
||||
"description": "Dockerが有効になっているホスト上で、Dockerマネージャーとコンテナコンソールを開きます。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Dockerを有効化",
|
||||
"description": "このホスト上のコンテナを管理します。"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "コンテナランタイム",
|
||||
"description": "このホストのコンテナ管理に使用するCLIを選択します。",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "リモートサーバーが必要です。この接続タイプを使用するには、設定でリモートサーバーを接続してください。"
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "ドキュメントを表示",
|
||||
"hostNotFound": "ホストが見つかりません",
|
||||
"loadingHost": "ホストを読み込み中..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "이 호스트에서 Docker가 활성화되지 않았습니다",
|
||||
"validating": "Docker 확인 중...",
|
||||
"connecting": "연결 중...",
|
||||
"error": "오류",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Docker에 연결하지 못했습니다",
|
||||
"containerStarted": "컨테이너 {{name}} 시작됨",
|
||||
"failedToStartContainer": "컨테이너 {{name}} 시작에 실패했습니다",
|
||||
"containerStopped": "컨테이너 {{name}} 중지됨",
|
||||
"failedToStopContainer": "컨테이너 {{name}} 중지에 실패했습니다",
|
||||
"containerRestarted": "컨테이너 {{name}} 재시작됨",
|
||||
"failedToRestartContainer": "컨테이너 {{name}} 재시작에 실패했습니다",
|
||||
"containerPaused": "컨테이너 {{name}} 일시 정지됨",
|
||||
"containerUnpaused": "컨테이너 {{name}} 일시 정지 해제됨",
|
||||
"failedToTogglePauseContainer": "컨테이너 {{name}}의 일시 정지 상태 전환에 실패했습니다",
|
||||
"containerRemoved": "컨테이너 {{name}} 제거됨",
|
||||
"failedToRemoveContainer": "컨테이너 {{name}} 제거에 실패했습니다",
|
||||
"image": "이미지",
|
||||
"ports": "포트",
|
||||
"noPorts": "포트 없음",
|
||||
"start": "시작",
|
||||
"stop": "중지",
|
||||
"status": "상태",
|
||||
"actions": "작업",
|
||||
"cardView": "카드 보기",
|
||||
"listView": "목록 보기",
|
||||
"confirmRemoveContainer": "컨테이너 '{{name}}'을(를) 제거하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"runningContainerWarning": "경고: 이 컨테이너는 현재 실행 중입니다. 제거하면 먼저 컨테이너가 중지됩니다.",
|
||||
"loadingContainers": "컨테이너 로딩 중...",
|
||||
"manager": "Docker 관리자",
|
||||
"autoRefresh": "자동 새로고침",
|
||||
"timestamps": "타임스탬프",
|
||||
"lines": "라인",
|
||||
"filterLogs": "로그 필터...",
|
||||
"refresh": "새로고침",
|
||||
"download": "다운로드",
|
||||
"clear": "지우기",
|
||||
"logsDownloaded": "로그가 성공적으로 다운로드되었습니다.",
|
||||
"last50": "최근 50개",
|
||||
"last100": "최근 100개",
|
||||
"last500": "최근 500개",
|
||||
"last1000": "최근 1000개",
|
||||
"allLogs": "전체 로그",
|
||||
"noLogsMatching": "\"{{query}}\"와 일치하는 로그가 없습니다.",
|
||||
"noLogsAvailable": "사용 가능한 로그가 없습니다.",
|
||||
"noContainersFound": "컨테이너를 찾을 수 없습니다.",
|
||||
"noContainersFoundHint": "이 호스트에 Docker 컨테이너가 없습니다.",
|
||||
"searchPlaceholder": "컨테이너 검색...",
|
||||
"allStatuses": "모든 상태",
|
||||
"stateRunning": "실행 중",
|
||||
"statePaused": "일시 중지",
|
||||
"stateExited": "종료됨",
|
||||
"stateRestarting": "다시 시작 중",
|
||||
"noContainersMatchFilters": "필터와 일치하는 컨테이너가 없습니다.",
|
||||
"noContainersMatchFiltersHint": "검색 또는 필터 조건을 조정해 보세요.",
|
||||
"failedToFetchStats": "컨테이너 통계를 가져오지 못했습니다.",
|
||||
"containerNotRunning": "컨테이너가 실행 중이 아닙니다.",
|
||||
"startContainerToViewStats": "통계를 보려면 컨테이너를 시작하세요.",
|
||||
"loadingStats": "통계 로딩 중...",
|
||||
"errorLoadingStats": "통계 로딩 오류",
|
||||
"noStatsAvailable": "사용 가능한 통계가 없습니다.",
|
||||
"cpuUsage": "CPU 사용량",
|
||||
"current": "현재",
|
||||
"memoryUsage": "메모리 사용량",
|
||||
"networkIo": "네트워크 I/O",
|
||||
"input": "입력",
|
||||
"output": "출력",
|
||||
"blockIo": "블록 I/O",
|
||||
"read": "읽기",
|
||||
"write": "쓰기",
|
||||
"pids": "PID",
|
||||
"containerInformation": "컨테이너 정보",
|
||||
"name": "이름",
|
||||
"id": "ID",
|
||||
"state": "상태",
|
||||
"containerMustBeRunning": "컨테이너가 실행 중이어야 콘솔에 접근할 수 있습니다",
|
||||
"verificationCodePrompt": "인증 코드를 입력하세요",
|
||||
"totpVerificationFailed": "TOTP 인증에 실패했습니다. 다시 시도하세요.",
|
||||
"browserSignInFailed": "로그인에 실패했습니다. 다시 시도해 주세요.",
|
||||
"connectedTo": "{{containerName}}에 연결됨",
|
||||
"disconnected": "연결 끊김",
|
||||
"consoleError": "콘솔 오류",
|
||||
"errorMessage": "오류: {{message}}",
|
||||
"failedToConnect": "컨테이너에 연결하지 못했습니다",
|
||||
"console": "콘솔",
|
||||
"selectShell": "셸 선택",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "연결",
|
||||
"disconnect": "연결 해제",
|
||||
"notConnected": "연결되지 않음",
|
||||
"clickToConnect": "연결을 클릭하여 셸 세션을 시작하세요",
|
||||
"connectingTo": "{{containerName}}에 연결 중...",
|
||||
"containerNotFound": "컨테이너를 찾을 수 없음",
|
||||
"backToList": "목록으로 돌아가기",
|
||||
"logs": "로그",
|
||||
"stats": "통계",
|
||||
"consoleTab": "콘솔",
|
||||
"startContainerToAccess": "컨테이너를 시작하여 콘솔에 접근하세요",
|
||||
"noHostSelected": "선택된 호스트가 없습니다",
|
||||
"failedToFetchLogs": "로그를 가져오는 데 실패했습니다: {{error}}",
|
||||
"failedToDownloadLogs": "로그 다운로드 실패: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Docker가 실행 중인 모든 호스트에서 컨테이너를 확인하고, 로그를 읽고, 통계를 볼 수 있습니다."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "재시도",
|
||||
"clipboardReadFailed": "클립보드에서 읽지 못했습니다. 클립보드 권한이 허용되었는지 확인하세요.",
|
||||
"clipboardWriteFailed": "클립보드에 복사하지 못했습니다. 페이지가 HTTPS 또는 localhost에서 제공되는지 확인하세요."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Docker를 사용하세요",
|
||||
"description": "Docker가 활성화된 호스트에서 Docker 관리자 및 컨테이너 콘솔을 엽니다."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker 활성화",
|
||||
"description": "이 호스트에서 컨테이너를 관리하세요."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "컨테이너 런타임",
|
||||
"description": "이 호스트에서 컨테이너 관리에 사용할 CLI를 선택합니다.",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "원격 서버가 필요합니다. 이 연결 유형을 사용하려면 설정에서 원격 서버를 연결하세요."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "문서 보기",
|
||||
"hostNotFound": "호스트를 찾을 수 없습니다",
|
||||
"loadingHost": "호스트 로드 중..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker is niet ingeschakeld voor deze host.",
|
||||
"validating": "Docker valideren...",
|
||||
"connecting": "Verbinden...",
|
||||
"error": "Fout",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Verbinding met Docker mislukt.",
|
||||
"containerStarted": "Container {{name}} gestart",
|
||||
"failedToStartContainer": "Het starten van de container is mislukt {{name}}",
|
||||
"containerStopped": "Container {{name}} is gestopt",
|
||||
"failedToStopContainer": "Het is niet gelukt om de container {{name}} te stoppen.",
|
||||
"containerRestarted": "Container {{name}} opnieuw opgestart",
|
||||
"failedToRestartContainer": "Het opnieuw opstarten van de container is mislukt {{name}}",
|
||||
"containerPaused": "Container {{name}} gepauzeerd",
|
||||
"containerUnpaused": "Container {{name}} is niet langer gepauzeerd",
|
||||
"failedToTogglePauseContainer": "Het is niet gelukt om de pauzestatus voor container {{name}} te wijzigen.",
|
||||
"containerRemoved": "Container {{name}} verwijderd",
|
||||
"failedToRemoveContainer": "Het verwijderen van de container is mislukt {{name}}",
|
||||
"image": "Afbeelding",
|
||||
"ports": "Havens",
|
||||
"noPorts": "Geen poorten",
|
||||
"start": "Begin",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Acties",
|
||||
"cardView": "Kaartweergave",
|
||||
"listView": "Lijstweergave",
|
||||
"confirmRemoveContainer": "Weet je zeker dat je de container '{{name}}' wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"runningContainerWarning": "Waarschuwing: Deze container is momenteel actief. Als u deze verwijdert, wordt de container eerst gestopt.",
|
||||
"loadingContainers": "Containers laden...",
|
||||
"manager": "Docker Manager",
|
||||
"autoRefresh": "Automatisch vernieuwen",
|
||||
"timestamps": "Tijdstempels",
|
||||
"lines": "Lijnen",
|
||||
"filterLogs": "Filterlogboeken...",
|
||||
"refresh": "Vernieuwen",
|
||||
"download": "Download",
|
||||
"clear": "Duidelijk",
|
||||
"logsDownloaded": "Logbestanden succesvol gedownload.",
|
||||
"last50": "Laatste 50",
|
||||
"last100": "Laatste 100",
|
||||
"last500": "Laatste 500",
|
||||
"last1000": "Laatste 1000",
|
||||
"allLogs": "Alle logboeken",
|
||||
"noLogsMatching": "Geen logbestanden gevonden die overeenkomen met \"{{query}}\"",
|
||||
"noLogsAvailable": "Geen logbestanden beschikbaar",
|
||||
"noContainersFound": "Geen containers gevonden",
|
||||
"noContainersFoundHint": "Er zijn geen Docker-containers beschikbaar op deze host.",
|
||||
"searchPlaceholder": "Containers doorzoeken...",
|
||||
"allStatuses": "Alle statussen",
|
||||
"stateRunning": "Rennen",
|
||||
"statePaused": "Gepauzeerd",
|
||||
"stateExited": "Uitgang",
|
||||
"stateRestarting": "Opnieuw opstarten",
|
||||
"noContainersMatchFilters": "Geen containers komen overeen met uw filters.",
|
||||
"noContainersMatchFiltersHint": "Probeer je zoek- of filtercriteria aan te passen.",
|
||||
"failedToFetchStats": "Het ophalen van containerstatistieken is mislukt.",
|
||||
"containerNotRunning": "Container draait niet",
|
||||
"startContainerToViewStats": "Start de container om de statistieken te bekijken.",
|
||||
"loadingStats": "Statistieken laden...",
|
||||
"errorLoadingStats": "Fout bij het laden van statistieken",
|
||||
"noStatsAvailable": "Geen statistieken beschikbaar",
|
||||
"cpuUsage": "CPU-gebruik",
|
||||
"current": "Huidig",
|
||||
"memoryUsage": "Geheugengebruik",
|
||||
"networkIo": "Netwerk I/O",
|
||||
"input": "Invoer",
|
||||
"output": "Uitvoer",
|
||||
"blockIo": "Blok I/O",
|
||||
"read": "Lezen",
|
||||
"write": "Schrijven",
|
||||
"pids": "PID's",
|
||||
"containerInformation": "Containerinformatie",
|
||||
"name": "Naam",
|
||||
"id": "ID",
|
||||
"state": "Staat",
|
||||
"containerMustBeRunning": "De container moet actief zijn om toegang te krijgen tot de console.",
|
||||
"verificationCodePrompt": "Voer de verificatiecode in.",
|
||||
"totpVerificationFailed": "TOTP-verificatie mislukt. Probeer het opnieuw.",
|
||||
"browserSignInFailed": "Aanmelden is mislukt. Probeer het opnieuw.",
|
||||
"connectedTo": "Verbonden met {{containerName}}",
|
||||
"disconnected": "Verbinding verbroken",
|
||||
"consoleError": "Consolefout",
|
||||
"errorMessage": "Fout: {{message}}",
|
||||
"failedToConnect": "Verbinding met de container mislukt.",
|
||||
"console": "Troosten",
|
||||
"selectShell": "Selecteer shell",
|
||||
"bash": "Knal",
|
||||
"sh": "sh",
|
||||
"ash": "as",
|
||||
"connect": "Verbinden",
|
||||
"disconnect": "Verbinding verbreken",
|
||||
"notConnected": "Niet verbonden",
|
||||
"clickToConnect": "Klik op 'Verbinden' om een shellsessie te starten.",
|
||||
"connectingTo": "Verbinding maken met {{containerName}}...",
|
||||
"containerNotFound": "Container niet gevonden",
|
||||
"backToList": "Terug naar de lijst",
|
||||
"logs": "Logboeken",
|
||||
"stats": "Statistieken",
|
||||
"consoleTab": "Troosten",
|
||||
"startContainerToAccess": "Start de container om toegang te krijgen tot de console.",
|
||||
"noHostSelected": "Geen host geselecteerd",
|
||||
"failedToFetchLogs": "Het ophalen van logbestanden is mislukt: {{error}}",
|
||||
"failedToDownloadLogs": "Het downloaden van logbestanden is mislukt: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Bekijk containers, lees logboeken en bekijk statistieken op elke host waarop Docker draait."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Opnieuw proberen",
|
||||
"clipboardReadFailed": "Het lezen van het klembord is mislukt. Zorg ervoor dat de juiste klembordrechten zijn verleend.",
|
||||
"clipboardWriteFailed": "Kopiëren naar klembord mislukt. Zorg ervoor dat de pagina via HTTPS of localhost wordt aangeboden."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Gebruik Docker",
|
||||
"description": "Open de Docker Manager en de containerconsoles op hosts waarop Docker is ingeschakeld."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker inschakelen",
|
||||
"description": "Beheer containers op deze host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Kies de CLI die op deze host wordt gebruikt voor containerbeheer.",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Externe server vereist. Verbind een externe server in de instellingen om dit verbindingstype te gebruiken."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Documenten bekijken",
|
||||
"hostNotFound": "Host niet gevonden",
|
||||
"loadingHost": "Host wordt geladen..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker is not enabled for this host",
|
||||
"validating": "Validating Docker...",
|
||||
"connecting": "Kobler til...",
|
||||
"error": "Feil",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Failed to connect to Docker",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container {{name}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||
"image": "Image",
|
||||
"ports": "Ports",
|
||||
"noPorts": "No ports",
|
||||
"start": "Start",
|
||||
"stop": "Stopp",
|
||||
"status": "Status",
|
||||
"actions": "Handlinger",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||
"loadingContainers": "Loading containers...",
|
||||
"manager": "Docker Manager",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
"timestamps": "Timestamps",
|
||||
"lines": "Lines",
|
||||
"filterLogs": "Filter logs...",
|
||||
"refresh": "Oppdater",
|
||||
"download": "Last ned",
|
||||
"clear": "Tøm",
|
||||
"logsDownloaded": "Logs downloaded successfully",
|
||||
"last50": "Last 50",
|
||||
"last100": "Last 100",
|
||||
"last500": "Last 500",
|
||||
"last1000": "Last 1000",
|
||||
"allLogs": "All Logs",
|
||||
"noLogsMatching": "No logs matching \"{{query}}\"",
|
||||
"noLogsAvailable": "No logs available",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||
"searchPlaceholder": "Search containers...",
|
||||
"allStatuses": "All Statuses",
|
||||
"stateRunning": "Kjører",
|
||||
"statePaused": "Paused",
|
||||
"stateExited": "Exited",
|
||||
"stateRestarting": "Restarting",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||
"failedToFetchStats": "Failed to fetch container statistics",
|
||||
"containerNotRunning": "Container not running",
|
||||
"startContainerToViewStats": "Start the container to view statistics",
|
||||
"loadingStats": "Loading statistics...",
|
||||
"errorLoadingStats": "Error loading statistics",
|
||||
"noStatsAvailable": "No statistics available",
|
||||
"cpuUsage": "CPU-bruk",
|
||||
"current": "Current",
|
||||
"memoryUsage": "Minnebruk",
|
||||
"networkIo": "Network I/O",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"blockIo": "Block I/O",
|
||||
"read": "Les",
|
||||
"write": "Skriv",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Container Information",
|
||||
"name": "Navn",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"containerMustBeRunning": "Container must be running to access console",
|
||||
"verificationCodePrompt": "Angi verifikasjonskode",
|
||||
"totpVerificationFailed": "TOTP verification failed. Please try again.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Frakoblet",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Feil: {{message}}",
|
||||
"failedToConnect": "Failed to connect to container",
|
||||
"console": "Console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Koble til",
|
||||
"disconnect": "Koble fra",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click connect to start a shell session",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to List",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console",
|
||||
"noHostSelected": "Ingen vert valgt",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Prøv igjen",
|
||||
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
||||
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Enable Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Choose the CLI used for container management on this host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker nie jest włączony dla tego hosta",
|
||||
"validating": "Sprawdzanie poprawności Dockera...",
|
||||
"connecting": "Łączenie...",
|
||||
"error": "Błąd",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Nie udało się połączyć z Dockerem",
|
||||
"containerStarted": "Kontener {{name}} został uruchomiony",
|
||||
"failedToStartContainer": "Nie udało się uruchomić kontenera {{name}}",
|
||||
"containerStopped": "Kontener {{name}} zatrzymany",
|
||||
"failedToStopContainer": "Nie udało się zatrzymać kontenera {{name}}",
|
||||
"containerRestarted": "Kontener {{name}} został ponownie uruchomiony",
|
||||
"failedToRestartContainer": "Nie udało się ponownie uruchomić kontenera {{name}}",
|
||||
"containerPaused": "Kontener {{name}} wstrzymany",
|
||||
"containerUnpaused": "Kontener {{name}} wznowiony",
|
||||
"failedToTogglePauseContainer": "Nie udało się przełączyć stanu pauzy dla kontenera {{name}}",
|
||||
"containerRemoved": "Kontener {{name}} został usunięty",
|
||||
"failedToRemoveContainer": "Nie udało się usunąć kontenera {{name}}",
|
||||
"image": "Obraz",
|
||||
"ports": "Porty",
|
||||
"noPorts": "Brak portów",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Czy na pewno chcesz usunąć kontener „{{name}}”? Tej czynności nie można cofnąć.",
|
||||
"runningContainerWarning": "Ostrzeżenie: Ten kontener jest obecnie uruchomiony. Jego usunięcie spowoduje najpierw jego zatrzymanie.",
|
||||
"loadingContainers": "Ładowanie kontenerów...",
|
||||
"manager": "Menedżer Dockera",
|
||||
"autoRefresh": "Automatyczne odświeżanie",
|
||||
"timestamps": "Znaczniki czasu",
|
||||
"lines": "Wiersze",
|
||||
"filterLogs": "Filtruj logi...",
|
||||
"refresh": "Odśwież",
|
||||
"download": "Pobierz",
|
||||
"clear": "Wyczyść",
|
||||
"logsDownloaded": "Pomyślnie pobrano logi",
|
||||
"last50": "Ostatnie 50",
|
||||
"last100": "Ostatnie 100",
|
||||
"last500": "Ostatnie 500",
|
||||
"last1000": "Ostatnie 1000",
|
||||
"allLogs": "Wszystkie logi",
|
||||
"noLogsMatching": "Brak logów pasujących do „{{query}}”",
|
||||
"noLogsAvailable": "Brak dostępnych logów",
|
||||
"noContainersFound": "Nie znaleziono kontenerów",
|
||||
"noContainersFoundHint": "Na tym hoście nie ma dostępnych kontenerów Docker",
|
||||
"searchPlaceholder": "Przeszukaj kontenery...",
|
||||
"allStatuses": "Wszystkie statusy",
|
||||
"stateRunning": "Działa",
|
||||
"statePaused": "Wstrzymano",
|
||||
"stateExited": "Zakończony",
|
||||
"stateRestarting": "Restartuje się",
|
||||
"noContainersMatchFilters": "Brak kontenerów pasujących do filtrów",
|
||||
"noContainersMatchFiltersHint": "Spróbuj dostosować kryteria wyszukiwania lub filtrowania",
|
||||
"failedToFetchStats": "Nie udało się pobrać statystyk kontenera",
|
||||
"containerNotRunning": "Kontener nie działa",
|
||||
"startContainerToViewStats": "Uruchom kontener, aby wyświetlić statystyki",
|
||||
"loadingStats": "Ładowanie statystyk...",
|
||||
"errorLoadingStats": "Błąd ładowania statystyk",
|
||||
"noStatsAvailable": "Brak dostępnych statystyk",
|
||||
"cpuUsage": "Wykorzystanie procesora",
|
||||
"current": "Bieżące",
|
||||
"memoryUsage": "Wykorzystanie pamięci",
|
||||
"networkIo": "Wejście/wyjście sieciowe",
|
||||
"input": "Wejście",
|
||||
"output": "Wyjście",
|
||||
"blockIo": "Blokowe I/O",
|
||||
"read": "Odczyt",
|
||||
"write": "Zapis",
|
||||
"pids": "PID-y",
|
||||
"containerInformation": "Informacje o kontenerze",
|
||||
"name": "Nazwa",
|
||||
"id": "ID",
|
||||
"state": "Stan",
|
||||
"containerMustBeRunning": "Aby uzyskać dostęp do konsoli, kontener musi być uruchomiony",
|
||||
"verificationCodePrompt": "Wprowadź kod weryfikacyjny",
|
||||
"totpVerificationFailed": "Weryfikacja TOTP nie powiodła się. Spróbuj ponownie.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Połączono z {{containerName}}",
|
||||
"disconnected": "Rozłączono",
|
||||
"consoleError": "Błąd konsoli",
|
||||
"errorMessage": "Błąd: {{message}}",
|
||||
"failedToConnect": "Nie udało się połączyć z kontenerem",
|
||||
"console": "Konsola",
|
||||
"selectShell": "Wybierz powłokę",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Połącz",
|
||||
"disconnect": "Odłącz",
|
||||
"notConnected": "Nie połączono",
|
||||
"clickToConnect": "Kliknij „Połącz”, aby rozpocząć sesję powłoki",
|
||||
"connectingTo": "Łączenie z {{containerName}}...",
|
||||
"containerNotFound": "Nie znaleziono kontenera",
|
||||
"backToList": "Powrót do listy",
|
||||
"logs": "Logi",
|
||||
"stats": "Statystyki",
|
||||
"consoleTab": "Konsola",
|
||||
"startContainerToAccess": "Uruchom kontener, aby uzyskać dostęp do konsoli",
|
||||
"noHostSelected": "Nie wybrano hosta",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Przeglądaj kontenery, czytaj logi i śledź statystyki na dowolnym hoście, na którym działa Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Ponów",
|
||||
"clipboardReadFailed": "Nie udało się odczytać danych ze schowka. Upewnij się, że uprawnienia do schowka zostały przyznane.",
|
||||
"clipboardWriteFailed": "Nie udało się skopiować do schowka. Upewnij się, że strona jest serwowana przez HTTPS lub localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Włącz Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Środowisko wykonawcze kontenera",
|
||||
"description": "Wybierz CLI używane do zarządzania kontenerami na tym hoście",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Wymagany jest serwer zdalny. Połącz się z serwerem zdalnym w Ustawieniach, aby korzystać z tego typu połączenia."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Zobacz dokumentację",
|
||||
"hostNotFound": "Nie znaleziono hosta",
|
||||
"loadingHost": "Ładowanie hosta..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker is not enabled for this host",
|
||||
"validating": "Validating Docker...",
|
||||
"connecting": "Conectando...",
|
||||
"error": "Erro",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Failed to connect to Docker",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container {{name}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||
"image": "Image",
|
||||
"ports": "Ports",
|
||||
"noPorts": "No ports",
|
||||
"start": "Iniciar",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||
"loadingContainers": "Loading containers...",
|
||||
"manager": "Docker Manager",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
"timestamps": "Timestamps",
|
||||
"lines": "Lines",
|
||||
"filterLogs": "Filter logs...",
|
||||
"refresh": "Atualizar",
|
||||
"download": "Download",
|
||||
"clear": "Limpar",
|
||||
"logsDownloaded": "Logs downloaded successfully",
|
||||
"last50": "Last 50",
|
||||
"last100": "Last 100",
|
||||
"last500": "Last 500",
|
||||
"last1000": "Last 1000",
|
||||
"allLogs": "All Logs",
|
||||
"noLogsMatching": "No logs matching \"{{query}}\"",
|
||||
"noLogsAvailable": "No logs available",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||
"searchPlaceholder": "Search containers...",
|
||||
"allStatuses": "All Statuses",
|
||||
"stateRunning": "Running",
|
||||
"statePaused": "Paused",
|
||||
"stateExited": "Exited",
|
||||
"stateRestarting": "Restarting",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||
"failedToFetchStats": "Failed to fetch container statistics",
|
||||
"containerNotRunning": "Container not running",
|
||||
"startContainerToViewStats": "Start the container to view statistics",
|
||||
"loadingStats": "Loading statistics...",
|
||||
"errorLoadingStats": "Error loading statistics",
|
||||
"noStatsAvailable": "No statistics available",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"current": "Current",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"networkIo": "Entrada e Saída de rede",
|
||||
"input": "Entrada",
|
||||
"output": "Output",
|
||||
"blockIo": "Bloco de E/S",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"pids": "Identificadores de Processos",
|
||||
"containerInformation": "Container Information",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"containerMustBeRunning": "O contêiner precisa estar em execução para acessar o console",
|
||||
"verificationCodePrompt": "Enter verification code",
|
||||
"totpVerificationFailed": "TOTP verification failed. Please try again.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Disconnected",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "Failed to connect to container",
|
||||
"console": "Console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Conectar",
|
||||
"disconnect": "Desconectar",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click connect to start a shell session",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to List",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console",
|
||||
"noHostSelected": "No host selected",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Retry",
|
||||
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
||||
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Ativar Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Choose the CLI used for container management on this host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Documentação",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker não está ativado para este host",
|
||||
"validating": "A validar o Docker...",
|
||||
"connecting": "A ligar...",
|
||||
"error": "Erro",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Falha ao ligar ao Docker",
|
||||
"containerStarted": "Contentor {{name}} iniciado",
|
||||
"failedToStartContainer": "Falha ao iniciar o contentor {{name}}",
|
||||
"containerStopped": "Contentor {{name}} parado",
|
||||
"failedToStopContainer": "Falha ao parar o contentor {{name}}",
|
||||
"containerRestarted": "Contentor {{name}} reiniciado",
|
||||
"failedToRestartContainer": "Falha ao reiniciar o contentor {{name}}",
|
||||
"containerPaused": "Contentor {{name}} suspenso",
|
||||
"containerUnpaused": "Contentor {{name}} retomado",
|
||||
"failedToTogglePauseContainer": "Falha ao alternar estado de pausa do contentor {{name}}",
|
||||
"containerRemoved": "Contentor {{name}} removido",
|
||||
"failedToRemoveContainer": "Falha ao remover o contentor {{name}}",
|
||||
"image": "Imagem",
|
||||
"ports": "Portas",
|
||||
"noPorts": "Sem portas",
|
||||
"start": "Iniciar",
|
||||
"stop": "Stop",
|
||||
"status": "Estado",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Tem a certeza de que pretende remover o contentor '{{name}}'? Esta ação não pode ser desfeita.",
|
||||
"runningContainerWarning": "Aviso: Este contentor está em execução. Removê-lo irá pará-lo primeiro.",
|
||||
"loadingContainers": "A carregar contentores...",
|
||||
"manager": "Gestor Docker",
|
||||
"autoRefresh": "Atualização automática",
|
||||
"timestamps": "Marcas de tempo",
|
||||
"lines": "Linhas",
|
||||
"filterLogs": "Filtrar registos...",
|
||||
"refresh": "Atualizar",
|
||||
"download": "Transferir",
|
||||
"clear": "Limpar",
|
||||
"logsDownloaded": "Registos transferidos com sucesso",
|
||||
"last50": "Últimos 50",
|
||||
"last100": "Últimos 100",
|
||||
"last500": "Últimos 500",
|
||||
"last1000": "Últimos 1000",
|
||||
"allLogs": "Todos os registos",
|
||||
"noLogsMatching": "Nenhum registo corresponde a \"{{query}}\"",
|
||||
"noLogsAvailable": "Nenhum registo disponível",
|
||||
"noContainersFound": "Nenhum contentor encontrado",
|
||||
"noContainersFoundHint": "Não existem contentores Docker disponíveis neste host",
|
||||
"searchPlaceholder": "Pesquisar contentores...",
|
||||
"allStatuses": "Todos os estados",
|
||||
"stateRunning": "Em execução",
|
||||
"statePaused": "Em pausa",
|
||||
"stateExited": "Terminado",
|
||||
"stateRestarting": "A reiniciar",
|
||||
"noContainersMatchFilters": "Nenhum contentor corresponde aos seus filtros",
|
||||
"noContainersMatchFiltersHint": "Tente ajustar os seus critérios de pesquisa ou filtro",
|
||||
"failedToFetchStats": "Falha ao obter estatísticas do contentor",
|
||||
"containerNotRunning": "O contentor não está em execução",
|
||||
"startContainerToViewStats": "Inicie o contentor para ver as estatísticas",
|
||||
"loadingStats": "A carregar estatísticas...",
|
||||
"errorLoadingStats": "Erro ao carregar estatísticas",
|
||||
"noStatsAvailable": "Nenhuma estatística disponível",
|
||||
"cpuUsage": "Utilização da CPU",
|
||||
"current": "Atual",
|
||||
"memoryUsage": "Utilização da memória",
|
||||
"networkIo": "I/O de rede",
|
||||
"input": "Entrada",
|
||||
"output": "Saída",
|
||||
"blockIo": "E/S de blocos",
|
||||
"read": "Leitura",
|
||||
"write": "Escrita",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Informações do contentor",
|
||||
"name": "Nome",
|
||||
"id": "ID",
|
||||
"state": "Estado",
|
||||
"containerMustBeRunning": "O contentor tem de estar em execução para aceder à consola",
|
||||
"verificationCodePrompt": "Introduza o código de verificação",
|
||||
"totpVerificationFailed": "A verificação TOTP falhou. Por favor, tente novamente.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Ligado a {{containerName}}",
|
||||
"disconnected": "Desligado",
|
||||
"consoleError": "Erro da consola",
|
||||
"errorMessage": "Erro: {{message}}",
|
||||
"failedToConnect": "Falha ao ligar ao contentor",
|
||||
"console": "Consola",
|
||||
"selectShell": "Selecionar shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Ligar",
|
||||
"disconnect": "Desligar",
|
||||
"notConnected": "Não ligado",
|
||||
"clickToConnect": "Clique em Ligar para iniciar uma sessão shell",
|
||||
"connectingTo": "A ligar a {{containerName}}...",
|
||||
"containerNotFound": "Contentor não encontrado",
|
||||
"backToList": "Voltar à lista",
|
||||
"logs": "Registos",
|
||||
"stats": "Estatísticas",
|
||||
"consoleTab": "Consola",
|
||||
"startContainerToAccess": "Inicie o contentor para aceder à consola",
|
||||
"noHostSelected": "Nenhum anfitrião selecionado",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Repetir",
|
||||
"clipboardReadFailed": "Erro ao ler da área de transferência. Certifique-se de que as permissões da área de transferência foram concedidas.",
|
||||
"clipboardWriteFailed": "Erro ao copiar para a área de transferência. Certifique-se de que a página é servida via HTTPS ou localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Ativar Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Runtime de Contentores",
|
||||
"description": "Escolha a CLI usada para a gestão de contentores neste host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Ver documentação",
|
||||
"hostNotFound": "Host não encontrado",
|
||||
"loadingHost": "A carregar o host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker nu este activat pentru această gazdă",
|
||||
"validating": "Se validează Docker...",
|
||||
"connecting": "Se conectează...",
|
||||
"error": "Eroare",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Conectarea la Docker nu a reușit",
|
||||
"containerStarted": "Containerul {{name}} a fost pornit",
|
||||
"failedToStartContainer": "Containerul {{name}} nu a putut fi pornit",
|
||||
"containerStopped": "Containerul {{name}} s-a oprit",
|
||||
"failedToStopContainer": "Containerul {{name}} nu a putut fi oprit.",
|
||||
"containerRestarted": "Containerul {{name}} a fost repornit",
|
||||
"failedToRestartContainer": "Repornirea containerului {{name}} a eșuat.",
|
||||
"containerPaused": "Containerul {{name}} a fost întrerupt",
|
||||
"containerUnpaused": "Containerul {{name}} a fost reluat",
|
||||
"failedToTogglePauseContainer": "Nu s-a putut comuta starea de pauză pentru containerul {{name}}",
|
||||
"containerRemoved": "Containerul {{name}} a fost eliminat",
|
||||
"failedToRemoveContainer": "Nu s-a putut elimina containerul {{name}}",
|
||||
"image": "Imagine",
|
||||
"ports": "Porturi",
|
||||
"noPorts": "Fără porturi",
|
||||
"start": "Început",
|
||||
"stop": "Stop",
|
||||
"status": "Stare",
|
||||
"actions": "Acțiuni",
|
||||
"cardView": "Vizualizare card",
|
||||
"listView": "Vizualizare listă",
|
||||
"confirmRemoveContainer": "Sigur doriți să eliminați containerul „{{name}}”? Această acțiune nu poate fi anulată.",
|
||||
"runningContainerWarning": "Avertisment: Acest container rulează în prezent. Eliminarea sa va opri mai întâi containerul.",
|
||||
"loadingContainers": "Se încarcă containerele...",
|
||||
"manager": "Manager Docker",
|
||||
"autoRefresh": "Reîmprospătare automată",
|
||||
"timestamps": "Marcaje temporale",
|
||||
"lines": "Linii",
|
||||
"filterLogs": "Filtrare jurnale...",
|
||||
"refresh": "Reîmprospăta",
|
||||
"download": "Descărcare",
|
||||
"clear": "Clar",
|
||||
"logsDownloaded": "Jurnalele au fost descărcate cu succes",
|
||||
"last50": "Ultimii 50",
|
||||
"last100": "Ultimele 100",
|
||||
"last500": "Ultimii 500",
|
||||
"last1000": "Ultimele 1000",
|
||||
"allLogs": "Toate jurnalele",
|
||||
"noLogsMatching": "Niciun jurnal care să corespundă cu „{{query}}”",
|
||||
"noLogsAvailable": "Nu sunt disponibile jurnale",
|
||||
"noContainersFound": "Nu s-au găsit containere",
|
||||
"noContainersFoundHint": "Nu există containere Docker disponibile pe această gazdă",
|
||||
"searchPlaceholder": "Căutați containere...",
|
||||
"allStatuses": "Toate stările",
|
||||
"stateRunning": "Funcţionare",
|
||||
"statePaused": "Întrerupt",
|
||||
"stateExited": "Ieșit",
|
||||
"stateRestarting": "Repornire",
|
||||
"noContainersMatchFilters": "Niciun container nu corespunde filtrelor dvs.",
|
||||
"noContainersMatchFiltersHint": "Încercați să ajustați criteriile de căutare sau de filtrare",
|
||||
"failedToFetchStats": "Nu s-au putut prelua statisticile containerului",
|
||||
"containerNotRunning": "Containerul nu rulează",
|
||||
"startContainerToViewStats": "Porniți containerul pentru a vizualiza statisticile",
|
||||
"loadingStats": "Se încarcă statisticile...",
|
||||
"errorLoadingStats": "Eroare la încărcarea statisticilor",
|
||||
"noStatsAvailable": "Nu sunt disponibile statistici",
|
||||
"cpuUsage": "Utilizarea procesorului",
|
||||
"current": "Actual",
|
||||
"memoryUsage": "Utilizarea memoriei",
|
||||
"networkIo": "I/O de rețea",
|
||||
"input": "Intrare",
|
||||
"output": "Ieșire",
|
||||
"blockIo": "Bloc I/O",
|
||||
"read": "Citire",
|
||||
"write": "Scrie",
|
||||
"pids": "PID-uri",
|
||||
"containerInformation": "Informații despre container",
|
||||
"name": "Nume",
|
||||
"id": "ID-ul",
|
||||
"state": "Stat",
|
||||
"containerMustBeRunning": "Containerul trebuie să ruleze pentru a accesa consola.",
|
||||
"verificationCodePrompt": "Introduceți codul de verificare",
|
||||
"totpVerificationFailed": "Verificarea TOTP a eșuat. Vă rugăm să încercați din nou.",
|
||||
"browserSignInFailed": "Conectarea a eșuat. Vă rugăm să încercați din nou.",
|
||||
"connectedTo": "Conectat la {{containerName}}",
|
||||
"disconnected": "Deconectat",
|
||||
"consoleError": "Eroare consolă",
|
||||
"errorMessage": "Eroare: {{message}}",
|
||||
"failedToConnect": "Conectarea la container nu a reușit",
|
||||
"console": "Consolă",
|
||||
"selectShell": "Selectați shell-ul",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "frasin",
|
||||
"connect": "Conectează-te",
|
||||
"disconnect": "Deconecta",
|
||||
"notConnected": "Neconectat",
|
||||
"clickToConnect": "Faceți clic pe conectare pentru a începe o sesiune shell",
|
||||
"connectingTo": "Conectare la {{containerName}}...",
|
||||
"containerNotFound": "Container negăsit",
|
||||
"backToList": "Înapoi la listă",
|
||||
"logs": "Jurnalele",
|
||||
"stats": "Statistici",
|
||||
"consoleTab": "Consolă",
|
||||
"startContainerToAccess": "Porniți containerul pentru a accesa consola",
|
||||
"noHostSelected": "Nicio gazdă selectată",
|
||||
"failedToFetchLogs": "Nu s-au putut prelua jurnalele: {{error}}",
|
||||
"failedToDownloadLogs": "Descărcarea jurnalelor a eșuat: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docher"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docher",
|
||||
"feature_docker_desc": "Vizualizați containere, citiți jurnale și urmăriți statistici pe orice gazdă care rulează Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Reîncercați",
|
||||
"clipboardReadFailed": "Citirea din clipboard nu a reușit. Asigurați-vă că permisiunile de acces la clipboard sunt acordate.",
|
||||
"clipboardWriteFailed": "Copierea în clipboard nu a reușit. Asigurați-vă că pagina este servită prin HTTPS sau localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Folosește Docker",
|
||||
"description": "Deschideți managerul Docker și consolele containerelor pe gazdele cu Docker activat."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Activează Docker",
|
||||
"description": "Gestionați containerele de pe această gazdă."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Runtime de container",
|
||||
"description": "Alegeți interfața CLI utilizată pentru gestionarea containerelor pe această gazdă",
|
||||
"docker": "Docher",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Este necesar un server la distanță. Conectați un server la distanță în Setări pentru a utiliza acest tip de conexiune."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Vizualizați documentele",
|
||||
"hostNotFound": "Gazdă negăsită",
|
||||
"loadingHost": "Se încarcă gazda..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker не включен для этого хоста",
|
||||
"validating": "Проверка Docker...",
|
||||
"connecting": "Подключение...",
|
||||
"error": "Ошибка",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Не удалось подключиться к Docker",
|
||||
"containerStarted": "Контейнер {{name}} запущен",
|
||||
"failedToStartContainer": "Не удалось запустить контейнер {{name}}",
|
||||
"containerStopped": "Контейнер {{name}} остановлен",
|
||||
"failedToStopContainer": "Не удалось остановить контейнер {{name}}",
|
||||
"containerRestarted": "Контейнер {{name}} перезапущен",
|
||||
"failedToRestartContainer": "Не удалось перезапустить контейнер {{name}}",
|
||||
"containerPaused": "Контейнер {{name}} приостановлен",
|
||||
"containerUnpaused": "Контейнер {{name}} возобновлён",
|
||||
"failedToTogglePauseContainer": "Не удалось изменить состояние приостановки для контейнера {{name}}",
|
||||
"containerRemoved": "Контейнер {{name}} удалён",
|
||||
"failedToRemoveContainer": "Не удалось удалить контейнер {{name}}",
|
||||
"image": "Образ",
|
||||
"ports": "Порты",
|
||||
"noPorts": "Нет портов",
|
||||
"start": "Запустить",
|
||||
"stop": "Остановить",
|
||||
"status": "Статус",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Вы уверены, что хотите удалить контейнер «{{name}}»? Это действие нельзя отменить.",
|
||||
"runningContainerWarning": "Предупреждение: этот контейнер в настоящее время работает. При удалении он будет сначала остановлен.",
|
||||
"loadingContainers": "Загрузка контейнеров...",
|
||||
"manager": "Менеджер Docker",
|
||||
"autoRefresh": "Автообновление",
|
||||
"timestamps": "Отметки времени",
|
||||
"lines": "Строки",
|
||||
"filterLogs": "Фильтровать логи...",
|
||||
"refresh": "Обновить",
|
||||
"download": "Скачать",
|
||||
"clear": "Очистить",
|
||||
"logsDownloaded": "Логи успешно скачаны",
|
||||
"last50": "Последние 50",
|
||||
"last100": "Последние 100",
|
||||
"last500": "Последние 500",
|
||||
"last1000": "Последние 1000",
|
||||
"allLogs": "Все логи",
|
||||
"noLogsMatching": "Нет логов, соответствующих \"{{query}}\"",
|
||||
"noLogsAvailable": "Логи отсутствуют",
|
||||
"noContainersFound": "Контейнеры не найдены",
|
||||
"noContainersFoundHint": "На этом хосте нет Docker-контейнеров",
|
||||
"searchPlaceholder": "Поиск контейнеров...",
|
||||
"allStatuses": "Все состояния",
|
||||
"stateRunning": "Запущен",
|
||||
"statePaused": "Приостановлен",
|
||||
"stateExited": "Завершён",
|
||||
"stateRestarting": "Перезапускается",
|
||||
"noContainersMatchFilters": "Нет контейнеров, соответствующих фильтрам",
|
||||
"noContainersMatchFiltersHint": "Попробуйте изменить параметры поиска или фильтры",
|
||||
"failedToFetchStats": "Не удалось получить статистику контейнера",
|
||||
"containerNotRunning": "Контейнер не запущен",
|
||||
"startContainerToViewStats": "Запустите контейнер, чтобы посмотреть статистику",
|
||||
"loadingStats": "Загрузка статистики...",
|
||||
"errorLoadingStats": "Ошибка загрузки статистики",
|
||||
"noStatsAvailable": "Статистика недоступна",
|
||||
"cpuUsage": "Использование ЦП",
|
||||
"current": "Текущий",
|
||||
"memoryUsage": "Использование памяти",
|
||||
"networkIo": "Сетевой ввод-вывод",
|
||||
"input": "Ввод",
|
||||
"output": "Вывод",
|
||||
"blockIo": "Блочный ввод-вывод",
|
||||
"read": "Чтение",
|
||||
"write": "Запись",
|
||||
"pids": "PID",
|
||||
"containerInformation": "Информация о контейнере",
|
||||
"name": "Имя",
|
||||
"id": "ID",
|
||||
"state": "Состояние",
|
||||
"containerMustBeRunning": "Для доступа к консоли контейнер должен быть запущен",
|
||||
"verificationCodePrompt": "Введите код подтверждения",
|
||||
"totpVerificationFailed": "Ошибка проверки TOTP. Попробуйте еще раз.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Подключено к {{containerName}}",
|
||||
"disconnected": "Отключено",
|
||||
"consoleError": "Ошибка консоли",
|
||||
"errorMessage": "Ошибка: {{message}}",
|
||||
"failedToConnect": "Не удалось подключиться к контейнеру",
|
||||
"console": "Консоль",
|
||||
"selectShell": "Выберите оболочку",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Подключиться",
|
||||
"disconnect": "Отключиться",
|
||||
"notConnected": "Не подключено",
|
||||
"clickToConnect": "Нажмите «Подключиться», чтобы запустить сеанс оболочки",
|
||||
"connectingTo": "Подключение к {{containerName}}...",
|
||||
"containerNotFound": "Контейнер не найден",
|
||||
"backToList": "Вернуться к списку",
|
||||
"logs": "Логи",
|
||||
"stats": "Статистика",
|
||||
"consoleTab": "Консоль",
|
||||
"startContainerToAccess": "Запустите контейнер, чтобы получить доступ к консоли",
|
||||
"noHostSelected": "Хост не выбран",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Повторить",
|
||||
"clipboardReadFailed": "Не удалось прочитать из буфера обмена. Убедитесь, что предоставлены разрешения на доступ к буферу обмена.",
|
||||
"clipboardWriteFailed": "Не удалось скопировать в буфер обмена. Убедитесь, что страница загружена по HTTPS или с localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Включить Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Среда выполнения контейнеров",
|
||||
"description": "Выберите CLI, используемый для управления контейнерами на этом хосте",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Документация",
|
||||
"hostNotFound": "Хост не найден",
|
||||
"loadingHost": "Загрузка хоста..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker is not enabled for this host",
|
||||
"validating": "Validating Docker...",
|
||||
"connecting": "Connecting...",
|
||||
"error": "Error",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Failed to connect to Docker",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container {{name}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||
"image": "Image",
|
||||
"ports": "Ports",
|
||||
"noPorts": "No ports",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||
"loadingContainers": "Loading containers...",
|
||||
"manager": "Docker Manager",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
"timestamps": "Timestamps",
|
||||
"lines": "Lines",
|
||||
"filterLogs": "Filter logs...",
|
||||
"refresh": "Refresh",
|
||||
"download": "Download",
|
||||
"clear": "Clear",
|
||||
"logsDownloaded": "Logs downloaded successfully",
|
||||
"last50": "Last 50",
|
||||
"last100": "Last 100",
|
||||
"last500": "Last 500",
|
||||
"last1000": "Last 1000",
|
||||
"allLogs": "All Logs",
|
||||
"noLogsMatching": "No logs matching \"{{query}}\"",
|
||||
"noLogsAvailable": "No logs available",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||
"searchPlaceholder": "Search containers...",
|
||||
"allStatuses": "All Statuses",
|
||||
"stateRunning": "Running",
|
||||
"statePaused": "Paused",
|
||||
"stateExited": "Exited",
|
||||
"stateRestarting": "Restarting",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||
"failedToFetchStats": "Failed to fetch container statistics",
|
||||
"containerNotRunning": "Container not running",
|
||||
"startContainerToViewStats": "Start the container to view statistics",
|
||||
"loadingStats": "Loading statistics...",
|
||||
"errorLoadingStats": "Error loading statistics",
|
||||
"noStatsAvailable": "No statistics available",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"current": "Current",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"networkIo": "Network I/O",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"blockIo": "Block I/O",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Container Information",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"containerMustBeRunning": "Container must be running to access console",
|
||||
"verificationCodePrompt": "Enter verification code",
|
||||
"totpVerificationFailed": "TOTP verification failed. Please try again.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Disconnected",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "Failed to connect to container",
|
||||
"console": "Console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click connect to start a shell session",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to List",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console",
|
||||
"noHostSelected": "No host selected",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Retry",
|
||||
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
||||
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Enable Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Choose the CLI used for container management on this host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker is not enabled for this host",
|
||||
"validating": "Validating Docker...",
|
||||
"connecting": "Connecting...",
|
||||
"error": "Error",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Failed to connect to Docker",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container {{name}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||
"image": "Image",
|
||||
"ports": "Ports",
|
||||
"noPorts": "No ports",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||
"loadingContainers": "Loading containers...",
|
||||
"manager": "Docker Manager",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
"timestamps": "Timestamps",
|
||||
"lines": "Lines",
|
||||
"filterLogs": "Filter logs...",
|
||||
"refresh": "Refresh",
|
||||
"download": "Download",
|
||||
"clear": "Clear",
|
||||
"logsDownloaded": "Logs downloaded successfully",
|
||||
"last50": "Last 50",
|
||||
"last100": "Last 100",
|
||||
"last500": "Last 500",
|
||||
"last1000": "Last 1000",
|
||||
"allLogs": "All Logs",
|
||||
"noLogsMatching": "No logs matching \"{{query}}\"",
|
||||
"noLogsAvailable": "No logs available",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||
"searchPlaceholder": "Search containers...",
|
||||
"allStatuses": "All Statuses",
|
||||
"stateRunning": "Running",
|
||||
"statePaused": "Paused",
|
||||
"stateExited": "Exited",
|
||||
"stateRestarting": "Restarting",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||
"failedToFetchStats": "Failed to fetch container statistics",
|
||||
"containerNotRunning": "Container not running",
|
||||
"startContainerToViewStats": "Start the container to view statistics",
|
||||
"loadingStats": "Loading statistics...",
|
||||
"errorLoadingStats": "Error loading statistics",
|
||||
"noStatsAvailable": "No statistics available",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"current": "Current",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"networkIo": "Network I/O",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"blockIo": "Block I/O",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Container Information",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"containerMustBeRunning": "Container must be running to access console",
|
||||
"verificationCodePrompt": "Enter verification code",
|
||||
"totpVerificationFailed": "TOTP verification failed. Please try again.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Disconnected",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "Failed to connect to container",
|
||||
"console": "Console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click connect to start a shell session",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to List",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console",
|
||||
"noHostSelected": "No host selected",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Retry",
|
||||
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
||||
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Enable Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Choose the CLI used for container management on this host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker is not enabled for this host",
|
||||
"validating": "Validating Docker...",
|
||||
"connecting": "Connecting...",
|
||||
"error": "Error",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Failed to connect to Docker",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container {{name}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||
"image": "Image",
|
||||
"ports": "Ports",
|
||||
"noPorts": "No ports",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||
"loadingContainers": "Loading containers...",
|
||||
"manager": "Docker Manager",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
"timestamps": "Timestamps",
|
||||
"lines": "Lines",
|
||||
"filterLogs": "Filter logs...",
|
||||
"refresh": "Refresh",
|
||||
"download": "Download",
|
||||
"clear": "Clear",
|
||||
"logsDownloaded": "Logs downloaded successfully",
|
||||
"last50": "Last 50",
|
||||
"last100": "Last 100",
|
||||
"last500": "Last 500",
|
||||
"last1000": "Last 1000",
|
||||
"allLogs": "All Logs",
|
||||
"noLogsMatching": "No logs matching \"{{query}}\"",
|
||||
"noLogsAvailable": "No logs available",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||
"searchPlaceholder": "Search containers...",
|
||||
"allStatuses": "All Statuses",
|
||||
"stateRunning": "Running",
|
||||
"statePaused": "Paused",
|
||||
"stateExited": "Exited",
|
||||
"stateRestarting": "Restarting",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||
"failedToFetchStats": "Failed to fetch container statistics",
|
||||
"containerNotRunning": "Container not running",
|
||||
"startContainerToViewStats": "Start the container to view statistics",
|
||||
"loadingStats": "Loading statistics...",
|
||||
"errorLoadingStats": "Error loading statistics",
|
||||
"noStatsAvailable": "No statistics available",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"current": "Current",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"networkIo": "Network I/O",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"blockIo": "Block I/O",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Container Information",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"containerMustBeRunning": "Container must be running to access console",
|
||||
"verificationCodePrompt": "Enter verification code",
|
||||
"totpVerificationFailed": "TOTP verification failed. Please try again.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Disconnected",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "Failed to connect to container",
|
||||
"console": "Console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click connect to start a shell session",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to List",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console",
|
||||
"noHostSelected": "No host selected",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Retry",
|
||||
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
||||
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Enable Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Choose the CLI used for container management on this host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Bu sunucu için Docker etkinleştirilmemiş",
|
||||
"validating": "Docker doğrulanıyor...",
|
||||
"connecting": "Bağlanıyor...",
|
||||
"error": "Hata",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Docker'a bağlanılamadı",
|
||||
"containerStarted": "{{name}} konteyneri başlatıldı",
|
||||
"failedToStartContainer": "{{name}} konteyneri başlatılamadı",
|
||||
"containerStopped": "{{name}} konteyneri durduruldu",
|
||||
"failedToStopContainer": "{{name}} konteyneri durdurulamadı",
|
||||
"containerRestarted": "{{name}} konteyneri yeniden başlatıldı",
|
||||
"failedToRestartContainer": "{{name}} konteyneri yeniden başlatılamadı",
|
||||
"containerPaused": "{{name}} konteyneri duraklatıldı",
|
||||
"containerUnpaused": "{{name}} konteyneri devam ettirildi",
|
||||
"failedToTogglePauseContainer": "{{name}} konteyneri için duraklatma durumu değiştirilemedi",
|
||||
"containerRemoved": "{{name}} konteyneri kaldırıldı",
|
||||
"failedToRemoveContainer": "{{name}} konteyneri kaldırılamadı",
|
||||
"image": "İmaj",
|
||||
"ports": "Portlar",
|
||||
"noPorts": "Port yok",
|
||||
"start": "Başlat",
|
||||
"stop": "Durdurma",
|
||||
"status": "Durum",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "'{{name}}' konteynerini kaldırmak istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"runningContainerWarning": "Uyarı: Bu konteyner şu anda çalışıyor. Kaldırma işlemi önce konteyneri durduracak.",
|
||||
"loadingContainers": "Konteynerler yükleniyor...",
|
||||
"manager": "Docker Yöneticisi",
|
||||
"autoRefresh": "Otomatik Yenile",
|
||||
"timestamps": "Zaman Damgaları",
|
||||
"lines": "Satırlar",
|
||||
"filterLogs": "Günlükleri filtrele...",
|
||||
"refresh": "Yenile",
|
||||
"download": "İndir",
|
||||
"clear": "Temizle",
|
||||
"logsDownloaded": "Günlükler başarıyla indirildi",
|
||||
"last50": "Son 50",
|
||||
"last100": "Son 100",
|
||||
"last500": "Son 500",
|
||||
"last1000": "Son 1000",
|
||||
"allLogs": "Tüm Günlükler",
|
||||
"noLogsMatching": "\"{{query}}\" ile eşleşen günlük yok",
|
||||
"noLogsAvailable": "Günlükler mevcut değil",
|
||||
"noContainersFound": "Konteyner bulunamadı",
|
||||
"noContainersFoundHint": "Bu ana bilgisayarda Docker konteyneri bulunmuyor",
|
||||
"searchPlaceholder": "Konteyner ara...",
|
||||
"allStatuses": "Tüm Durumlar",
|
||||
"stateRunning": "Çalışıyor",
|
||||
"statePaused": "Duraklatıldı",
|
||||
"stateExited": "Durduruldu",
|
||||
"stateRestarting": "Yeniden başlatılıyor",
|
||||
"noContainersMatchFilters": "Filtrelere uyan konteyner yok",
|
||||
"noContainersMatchFiltersHint": "Aramanızı veya filtre ölçütlerinizi değiştirmeyi deneyin",
|
||||
"failedToFetchStats": "Konteyner istatistikleri alınamadı",
|
||||
"containerNotRunning": "Konteyner çalışmıyor",
|
||||
"startContainerToViewStats": "İstatistikleri görüntülemek için konteyneri başlatın",
|
||||
"loadingStats": "İstatistikler yükleniyor...",
|
||||
"errorLoadingStats": "İstatistikler yüklenirken hata oluştu",
|
||||
"noStatsAvailable": "İstatistikler mevcut değil",
|
||||
"cpuUsage": "CPU Kullanımı",
|
||||
"current": "Anlık",
|
||||
"memoryUsage": "Bellek Kullanımı",
|
||||
"networkIo": "Ağ G/Ç",
|
||||
"input": "Giriş",
|
||||
"output": "Çıktı",
|
||||
"blockIo": "Blok G/Ç",
|
||||
"read": "Okuma",
|
||||
"write": "Yazma",
|
||||
"pids": "PID'ler",
|
||||
"containerInformation": "Konteyner Bilgileri",
|
||||
"name": "Ad",
|
||||
"id": "ID",
|
||||
"state": "Durum",
|
||||
"containerMustBeRunning": "Konsola erişmek için konteyner çalışıyor olmalıdır",
|
||||
"verificationCodePrompt": "Doğrulama kodunu girin",
|
||||
"totpVerificationFailed": "TOTP doğrulaması başarısız oldu. Lütfen tekrar deneyin.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "{{containerName}} ile bağlantı kuruldu",
|
||||
"disconnected": "Bağlantı kesildi",
|
||||
"consoleError": "Konsol hatası",
|
||||
"errorMessage": "Hata: {{message}}",
|
||||
"failedToConnect": "Konteynere bağlanılamadı",
|
||||
"console": "Konsol",
|
||||
"selectShell": "Kabuk seç",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Bağlan",
|
||||
"disconnect": "Bağlantıyı kes",
|
||||
"notConnected": "Bağlı değil",
|
||||
"clickToConnect": "Kabuk oturumu başlatmak için bağlan düğmesine tıklayın",
|
||||
"connectingTo": "{{containerName}} konteynerine bağlanılıyor...",
|
||||
"containerNotFound": "Konteyner bulunamadı",
|
||||
"backToList": "Listeye Dön",
|
||||
"logs": "Günlükler",
|
||||
"stats": "İstatistikler",
|
||||
"consoleTab": "Konsol",
|
||||
"startContainerToAccess": "Konsola erişmek için konteyneri başlatın",
|
||||
"noHostSelected": "Sunucu seçilmedi",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Yeniden dene",
|
||||
"clipboardReadFailed": "Panodan okuma başarısız oldu. Pano izinlerinin verildiğinden emin olun.",
|
||||
"clipboardWriteFailed": "Panoya kopyalama başarısız oldu. Sayfanın HTTPS veya localhost üzerinden sunulduğundan emin olun."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker'ı Etkinleştir",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Kapsayıcı Çalışma Zamanı",
|
||||
"description": "Bu ana bilgisayarda kapsayıcı yönetimi için kullanılan CLI'yi seçin",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Dokümanları görüntüle",
|
||||
"hostNotFound": "Host bulunamadı",
|
||||
"loadingHost": "Host yükleniyor..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker is not enabled for this host",
|
||||
"validating": "Validating Docker...",
|
||||
"connecting": "Connecting...",
|
||||
"error": "Error",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Failed to connect to Docker",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container {{name}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||
"image": "Image",
|
||||
"ports": "Ports",
|
||||
"noPorts": "No ports",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||
"loadingContainers": "Loading containers...",
|
||||
"manager": "Docker Manager",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
"timestamps": "Timestamps",
|
||||
"lines": "Lines",
|
||||
"filterLogs": "Filter logs...",
|
||||
"refresh": "Refresh",
|
||||
"download": "Download",
|
||||
"clear": "Clear",
|
||||
"logsDownloaded": "Logs downloaded successfully",
|
||||
"last50": "Last 50",
|
||||
"last100": "Last 100",
|
||||
"last500": "Last 500",
|
||||
"last1000": "Last 1000",
|
||||
"allLogs": "All Logs",
|
||||
"noLogsMatching": "No logs matching \"{{query}}\"",
|
||||
"noLogsAvailable": "No logs available",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||
"searchPlaceholder": "Search containers...",
|
||||
"allStatuses": "All Statuses",
|
||||
"stateRunning": "Running",
|
||||
"statePaused": "Paused",
|
||||
"stateExited": "Exited",
|
||||
"stateRestarting": "Restarting",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||
"failedToFetchStats": "Failed to fetch container statistics",
|
||||
"containerNotRunning": "Container not running",
|
||||
"startContainerToViewStats": "Start the container to view statistics",
|
||||
"loadingStats": "Loading statistics...",
|
||||
"errorLoadingStats": "Error loading statistics",
|
||||
"noStatsAvailable": "No statistics available",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"current": "Current",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"networkIo": "Network I/O",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"blockIo": "Block I/O",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Container Information",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"containerMustBeRunning": "Container must be running to access console",
|
||||
"verificationCodePrompt": "Enter verification code",
|
||||
"totpVerificationFailed": "TOTP verification failed. Please try again.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Disconnected",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "Failed to connect to container",
|
||||
"console": "Console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click connect to start a shell session",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to List",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console",
|
||||
"noHostSelected": "No host selected",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Retry",
|
||||
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
||||
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Enable Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Choose the CLI used for container management on this host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "Docker chưa được kích hoạt cho máy chủ này",
|
||||
"validating": "Đang kiểm tra Docker...",
|
||||
"connecting": "Đang kết nối...",
|
||||
"error": "Lỗi",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "Kết nối Docker thất bại",
|
||||
"containerStarted": "Container {{name}} đã khởi động",
|
||||
"failedToStartContainer": "Khởi động container {{name}} thất bại",
|
||||
"containerStopped": "Container {{name}} đã dừng",
|
||||
"failedToStopContainer": "Dừng container {{name}} thất bại",
|
||||
"containerRestarted": "Container {{name}} đã khởi động lại",
|
||||
"failedToRestartContainer": "Khởi động lại container {{name}} thất bại",
|
||||
"containerPaused": "Container {{name}} đã tạm dừng",
|
||||
"containerUnpaused": "Container {{name}} đã tiếp tục",
|
||||
"failedToTogglePauseContainer": "Chuyển đổi trạng thái tạm dừng cho container {{name}} thất bại",
|
||||
"containerRemoved": "Container {{name}} đã bị xóa",
|
||||
"failedToRemoveContainer": "Xóa container {{name}} thất bại",
|
||||
"image": "Image",
|
||||
"ports": "Cổng",
|
||||
"noPorts": "Không có cổng nào",
|
||||
"start": "Khởi động",
|
||||
"stop": "Bit dừng",
|
||||
"status": "Trạng thái",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "Bạn có chắc chắn muốn xóa container '{{name}}' không? Hành động này không thể hoàn tác.",
|
||||
"runningContainerWarning": "Cảnh báo: Container này đang chạy. Xóa nó sẽ dừng container trước.",
|
||||
"loadingContainers": "Đang tải danh sách container...",
|
||||
"manager": "Quản lý Docker",
|
||||
"autoRefresh": "Tự động làm mới",
|
||||
"timestamps": "Dấu thời gian",
|
||||
"lines": "Dòng",
|
||||
"filterLogs": "Lọc nhật ký...",
|
||||
"refresh": "Làm mới",
|
||||
"download": "Tải xuống",
|
||||
"clear": "Xóa",
|
||||
"logsDownloaded": "Đã tải xuống nhật ký thành công",
|
||||
"last50": "50 cuối cùng",
|
||||
"last100": "100 cuối cùng",
|
||||
"last500": "500 cuối cùng",
|
||||
"last1000": "1000 cuối cùng",
|
||||
"allLogs": "Tất cả nhật ký",
|
||||
"noLogsMatching": "Không có nhật ký nào khớp với \"{{query}}\"",
|
||||
"noLogsAvailable": "Không có nhật ký khả dụng",
|
||||
"noContainersFound": "Không tìm thấy container",
|
||||
"noContainersFoundHint": "Không có container Docker nào trên máy chủ này",
|
||||
"searchPlaceholder": "Tìm kiếm container...",
|
||||
"allStatuses": "Tất cả trạng thái",
|
||||
"stateRunning": "Đang chạy",
|
||||
"statePaused": "Tạm dừng",
|
||||
"stateExited": "Đã thoát",
|
||||
"stateRestarting": "Đang khởi động lại",
|
||||
"noContainersMatchFilters": "Không có container nào khớp bộ lọc của bạn",
|
||||
"noContainersMatchFiltersHint": "Hãy thử điều chỉnh tiêu chí tìm kiếm hoặc lọc",
|
||||
"failedToFetchStats": "Không thể lấy số liệu thống kê container",
|
||||
"containerNotRunning": "Container không chạy",
|
||||
"startContainerToViewStats": "Khởi động container để xem số liệu thống kê",
|
||||
"loadingStats": "Đang tải số liệu thống kê...",
|
||||
"errorLoadingStats": "Lỗi khi tải số liệu thống kê",
|
||||
"noStatsAvailable": "Không có số liệu thống kê",
|
||||
"cpuUsage": "Sử dụng CPU",
|
||||
"current": "Hiện tại",
|
||||
"memoryUsage": "Sử dụng bộ nhớ",
|
||||
"networkIo": "I/O mạng",
|
||||
"input": "Nhập",
|
||||
"output": "Đầu ra",
|
||||
"blockIo": "I/O khối",
|
||||
"read": "Đọc",
|
||||
"write": "Ghi",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Thông tin container",
|
||||
"name": "Tên",
|
||||
"id": "ID",
|
||||
"state": "Trạng thái",
|
||||
"containerMustBeRunning": "Container phải đang chạy để truy cập bảng điều khiển",
|
||||
"verificationCodePrompt": "Nhập mã xác minh",
|
||||
"totpVerificationFailed": "Xác minh TOTP thất bại. Vui lòng thử lại.",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "Đã kết nối đến {{containerName}}",
|
||||
"disconnected": "Đã ngắt kết nối",
|
||||
"consoleError": "Lỗi bảng điều khiển",
|
||||
"errorMessage": "Lỗi: {{message}}",
|
||||
"failedToConnect": "Không thể kết nối đến container",
|
||||
"console": "Bảng điều khiển",
|
||||
"selectShell": "Chọn shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "Kết nối",
|
||||
"disconnect": "Ngắt kết nối",
|
||||
"notConnected": "Chưa kết nối",
|
||||
"clickToConnect": "Nhấn Kết nối để bắt đầu phiên shell",
|
||||
"connectingTo": "Đang kết nối đến {{containerName}}...",
|
||||
"containerNotFound": "Không tìm thấy container",
|
||||
"backToList": "Quay lại danh sách",
|
||||
"logs": "Nhật ký",
|
||||
"stats": "Thống kê",
|
||||
"consoleTab": "Bảng điều khiển",
|
||||
"startContainerToAccess": "Khởi động container để truy cập bảng điều khiển",
|
||||
"noHostSelected": "Chưa chọn máy chủ",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Thử lại",
|
||||
"clipboardReadFailed": "Đọc từ clipboard thất bại. Đảm bảo quyền truy cập clipboard đã được cấp.",
|
||||
"clipboardWriteFailed": "Sao chép vào clipboard thất bại. Đảm bảo trang được phục vụ qua HTTPS hoặc localhost."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Bật Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Môi trường chạy container",
|
||||
"description": "Chọn CLI dùng để quản lý container trên máy chủ này",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Xem tài liệu",
|
||||
"hostNotFound": "Không tìm thấy máy chủ",
|
||||
"loadingHost": "Đang tải máy chủ..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "此主机未启用 Docker",
|
||||
"validating": "验证 Docker 中...",
|
||||
"connecting": "连接中...",
|
||||
"error": "错误",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "连接 Docker 失败",
|
||||
"containerStarted": "容器 {{name}} 已启动",
|
||||
"failedToStartContainer": "启动容器 {{name}} 失败",
|
||||
"containerStopped": "容器 {{name}} 已停止",
|
||||
"failedToStopContainer": "停止容器 {{name}} 失败",
|
||||
"containerRestarted": "容器 {{name}} 已重启",
|
||||
"failedToRestartContainer": "重启容器 {{name}} 失败",
|
||||
"containerPaused": "容器 {{name}} 已暂停",
|
||||
"containerUnpaused": "容器 {{name}} 已恢复",
|
||||
"failedToTogglePauseContainer": "切换容器 {{name}} 暂停状态失败",
|
||||
"containerRemoved": "容器 {{name}} 已移除",
|
||||
"failedToRemoveContainer": "移除容器 {{name}} 失败",
|
||||
"image": "镜像",
|
||||
"ports": "端口",
|
||||
"noPorts": "无端口",
|
||||
"start": "启动",
|
||||
"stop": "停止",
|
||||
"status": "状态",
|
||||
"actions": "操作",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "确定要移除容器 '{{name}}' 吗?此操作无法撤销。",
|
||||
"runningContainerWarning": "警告:此容器正在运行。移除操作将先停止容器。",
|
||||
"loadingContainers": "正在加载容器...",
|
||||
"manager": "Docker 管理器",
|
||||
"autoRefresh": "自动刷新",
|
||||
"timestamps": "时间戳",
|
||||
"lines": "行数",
|
||||
"filterLogs": "筛选日志...",
|
||||
"refresh": "刷新",
|
||||
"download": "下载",
|
||||
"clear": "清除",
|
||||
"logsDownloaded": "日志下载成功",
|
||||
"last50": "最近 50 条",
|
||||
"last100": "最近 100 条",
|
||||
"last500": "最近 500 条",
|
||||
"last1000": "最近 1000 条",
|
||||
"allLogs": "全部日志",
|
||||
"noLogsMatching": "未找到匹配 \"{{query}}\" 的日志",
|
||||
"noLogsAvailable": "暂无日志",
|
||||
"noContainersFound": "未找到容器",
|
||||
"noContainersFoundHint": "此主机上没有可用的 Docker 容器",
|
||||
"searchPlaceholder": "搜索容器...",
|
||||
"allStatuses": "所有状态",
|
||||
"stateRunning": "运行中",
|
||||
"statePaused": "已暂停",
|
||||
"stateExited": "已退出",
|
||||
"stateRestarting": "重启中",
|
||||
"noContainersMatchFilters": "没有容器符合筛选条件",
|
||||
"noContainersMatchFiltersHint": "请尝试调整搜索或筛选条件",
|
||||
"failedToFetchStats": "获取容器统计信息失败",
|
||||
"containerNotRunning": "容器未运行",
|
||||
"startContainerToViewStats": "启动容器以查看统计信息",
|
||||
"loadingStats": "加载统计信息...",
|
||||
"errorLoadingStats": "加载统计信息时出错",
|
||||
"noStatsAvailable": "无可用统计信息",
|
||||
"cpuUsage": "CPU 使用率",
|
||||
"current": "当前",
|
||||
"memoryUsage": "内存使用量",
|
||||
"networkIo": "网络 I/O",
|
||||
"input": "输入",
|
||||
"output": "输出",
|
||||
"blockIo": "块 I/O",
|
||||
"read": "读取",
|
||||
"write": "写入",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "容器信息",
|
||||
"name": "名称",
|
||||
"id": "ID",
|
||||
"state": "状态",
|
||||
"containerMustBeRunning": "访问控制台需要容器处于运行状态",
|
||||
"verificationCodePrompt": "输入验证码",
|
||||
"totpVerificationFailed": "TOTP 验证失败,请重试。",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "已连接到 {{containerName}}",
|
||||
"disconnected": "已断开连接",
|
||||
"consoleError": "控制台错误",
|
||||
"errorMessage": "错误:{{message}}",
|
||||
"failedToConnect": "连接到容器失败",
|
||||
"console": "控制台",
|
||||
"selectShell": "选择 shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "连接",
|
||||
"disconnect": "断开连接",
|
||||
"notConnected": "未连接",
|
||||
"clickToConnect": "点击“连接”启动 shell 会话",
|
||||
"connectingTo": "正在连接到 {{containerName}}...",
|
||||
"containerNotFound": "未找到容器",
|
||||
"backToList": "返回列表",
|
||||
"logs": "日志",
|
||||
"stats": "统计",
|
||||
"consoleTab": "控制台",
|
||||
"startContainerToAccess": "启动容器以访问控制台",
|
||||
"noHostSelected": "未选择主机",
|
||||
"failedToFetchLogs": "获取日志失败:{{error}}",
|
||||
"failedToDownloadLogs": "日志下载失败:{{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "在任何运行 Docker 的主机上查看容器、读取日志及监控运行状态。"
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "重试",
|
||||
"clipboardReadFailed": "从剪贴板读取失败。请确保已授予剪贴板权限。",
|
||||
"clipboardWriteFailed": "复制到剪贴板失败。请确保页面通过 HTTPS 或 localhost 提供服务。"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "启用 Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "容器运行时",
|
||||
"description": "选择此主机上用于容器管理的 CLI",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "查看文档",
|
||||
"hostNotFound": "未找到主机",
|
||||
"loadingHost": "正在加载主机..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"docker": {
|
||||
"notEnabled": "此主機未啟用 Docker",
|
||||
"validating": "正在驗證 Docker...",
|
||||
"connecting": "連線中...",
|
||||
"error": "錯誤",
|
||||
"version": "{{runtime}} {{version}}",
|
||||
"connectionFailed": "無法連線至 Docker",
|
||||
"containerStarted": "容器 {{name}} 已啟動",
|
||||
"failedToStartContainer": "無法啟動容器 {{name}}",
|
||||
"containerStopped": "容器 {{name}} 已停止",
|
||||
"failedToStopContainer": "無法停止容器 {{name}}",
|
||||
"containerRestarted": "容器 {{name}} 已重新啟動",
|
||||
"failedToRestartContainer": "無法重新啟動容器 {{name}}",
|
||||
"containerPaused": "容器 {{name}} 已暫停",
|
||||
"containerUnpaused": "容器 {{name}} 已取消暫停",
|
||||
"failedToTogglePauseContainer": "無法切換容器 {{name}} 的暫停狀態",
|
||||
"containerRemoved": "容器 {{name}} 已移除",
|
||||
"failedToRemoveContainer": "無法移除容器 {{name}}",
|
||||
"image": "映像",
|
||||
"ports": "連接埠",
|
||||
"noPorts": "無連接埠",
|
||||
"start": "啟動",
|
||||
"stop": "停止位元",
|
||||
"status": "狀態",
|
||||
"actions": "Actions",
|
||||
"cardView": "Card view",
|
||||
"listView": "List view",
|
||||
"confirmRemoveContainer": "確定要移除容器 '{{name}}' 嗎?此動作無法復原。",
|
||||
"runningContainerWarning": "警告:此容器正在執行中。移除時會先將其停止。",
|
||||
"loadingContainers": "正在載入容器...",
|
||||
"manager": "Docker 管理",
|
||||
"autoRefresh": "自動重新整理",
|
||||
"timestamps": "時間戳記",
|
||||
"lines": "行",
|
||||
"filterLogs": "篩選日誌...",
|
||||
"refresh": "重新整理",
|
||||
"download": "下載",
|
||||
"clear": "清除",
|
||||
"logsDownloaded": "日誌下載成功",
|
||||
"last50": "最後 50 筆",
|
||||
"last100": "最後 100 筆",
|
||||
"last500": "最後 500 筆",
|
||||
"last1000": "最後 1000 筆",
|
||||
"allLogs": "所有日誌",
|
||||
"noLogsMatching": "沒有符合 \"{{query}}\" 的日誌",
|
||||
"noLogsAvailable": "無可用日誌",
|
||||
"noContainersFound": "找不到容器",
|
||||
"noContainersFoundHint": "此主機上沒有可用的 Docker 容器",
|
||||
"searchPlaceholder": "搜尋容器...",
|
||||
"allStatuses": "所有狀態",
|
||||
"stateRunning": "執行中",
|
||||
"statePaused": "已暫停",
|
||||
"stateExited": "已退出",
|
||||
"stateRestarting": "正在重新啟動",
|
||||
"noContainersMatchFilters": "沒有符合篩選條件的容器",
|
||||
"noContainersMatchFiltersHint": "請嘗試調整搜尋或篩選條件",
|
||||
"failedToFetchStats": "無法取得容器統計資料",
|
||||
"containerNotRunning": "容器未執行",
|
||||
"startContainerToViewStats": "啟動容器以檢視統計資料",
|
||||
"loadingStats": "正在載入統計資料...",
|
||||
"errorLoadingStats": "載入統計資料時發生錯誤",
|
||||
"noStatsAvailable": "無可用統計資料",
|
||||
"cpuUsage": "CPU 使用率",
|
||||
"current": "目前",
|
||||
"memoryUsage": "記憶體使用量",
|
||||
"networkIo": "網路 I/O",
|
||||
"input": "輸入",
|
||||
"output": "輸出",
|
||||
"blockIo": "區塊 I/O",
|
||||
"read": "讀取",
|
||||
"write": "寫入",
|
||||
"pids": "PID",
|
||||
"containerInformation": "容器資訊",
|
||||
"name": "名稱",
|
||||
"id": "ID",
|
||||
"state": "狀態",
|
||||
"containerMustBeRunning": "容器必須在執行中才能存取主控台",
|
||||
"verificationCodePrompt": "請輸入驗證碼",
|
||||
"totpVerificationFailed": "TOTP 驗證失敗,請再試一次。",
|
||||
"browserSignInFailed": "Sign-in failed. Please try again.",
|
||||
"connectedTo": "已連線至 {{containerName}}",
|
||||
"disconnected": "已中斷連線",
|
||||
"consoleError": "主控台錯誤",
|
||||
"errorMessage": "錯誤:{{message}}",
|
||||
"failedToConnect": "無法連線至容器",
|
||||
"console": "主控台",
|
||||
"selectShell": "選取 shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connect": "連線",
|
||||
"disconnect": "中斷連線",
|
||||
"notConnected": "未連線",
|
||||
"clickToConnect": "點擊連線以啟動 shell 工作階段",
|
||||
"connectingTo": "正在連線至 {{containerName}}...",
|
||||
"containerNotFound": "找不到容器",
|
||||
"backToList": "返回列表",
|
||||
"logs": "記錄",
|
||||
"stats": "統計資料",
|
||||
"consoleTab": "主控台",
|
||||
"startContainerToAccess": "啟動容器以存取主控台",
|
||||
"noHostSelected": "未選擇主機",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "重試",
|
||||
"clipboardReadFailed": "從剪貼簿讀取失敗。請確認已授予剪貼簿權限。",
|
||||
"clipboardWriteFailed": "複製到剪貼簿失敗。請確認頁面是透過 HTTPS 或 localhost 提供。"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "啟用 Docker",
|
||||
"description": "Manage containers on this host."
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "容器執行環境",
|
||||
"description": "選擇用於此主機容器管理的 CLI",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "檢視文件",
|
||||
"hostNotFound": "找不到主機",
|
||||
"loadingHost": "正在載入主機..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"id": "docker",
|
||||
"name": "Docker",
|
||||
"version": "1.0.0",
|
||||
"description": "Manage Docker and Podman containers over SSH, with logs and a container console.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Infrastructure",
|
||||
"icon": "Box",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"credentials:use",
|
||||
"ssh:connect",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"events:core",
|
||||
"users:impersonate"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"service": "docker.containers",
|
||||
"version": "1.0.0",
|
||||
"permission": "docker.use"
|
||||
},
|
||||
{
|
||||
"service": "docker.events",
|
||||
"version": "1.0.0",
|
||||
"permission": "docker.use"
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "docker",
|
||||
"titleKey": "nav.docker",
|
||||
"icon": "Box",
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use",
|
||||
"titleKey": "permissions.use.title",
|
||||
"descriptionKey": "permissions.use.description",
|
||||
"defaultRoles": ["admin", "user"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"host": {
|
||||
"editorGroup": "ssh",
|
||||
"editorOrder": 30,
|
||||
"enableKey": "enableDocker",
|
||||
"enableLabelKey": "settings.host.enable.label",
|
||||
"enableDescriptionKey": "settings.host.enable.description",
|
||||
"fields": [
|
||||
{
|
||||
"key": "containerRuntime",
|
||||
"type": "select",
|
||||
"labelKey": "settings.host.containerRuntime.label",
|
||||
"descriptionKey": "settings.host.containerRuntime.description",
|
||||
"requires": "enableDocker",
|
||||
"default": "docker",
|
||||
"options": [
|
||||
{
|
||||
"value": "docker",
|
||||
"labelKey": "settings.host.containerRuntime.docker"
|
||||
},
|
||||
{
|
||||
"value": "podman",
|
||||
"labelKey": "settings.host.containerRuntime.podman"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"uiPresets": {
|
||||
"simple": {
|
||||
"viewMode": "list",
|
||||
"containerLayout": "card"
|
||||
},
|
||||
"balanced": {
|
||||
"viewMode": "list",
|
||||
"containerLayout": "card"
|
||||
},
|
||||
"advanced": {
|
||||
"viewMode": "detail",
|
||||
"containerLayout": "table"
|
||||
}
|
||||
},
|
||||
"uses": ["homepage.widgets", "terminal.resolveTheme"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@termix-plugin/docker",
|
||||
"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": {
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.33.0",
|
||||
"react-xtermjs": "^1.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@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",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"termix": {
|
||||
"patches": [
|
||||
"xterm-android-ime"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"react-xtermjs": {
|
||||
"@xterm/xterm": "$@xterm/xterm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* The Docker panel's interactive connect.
|
||||
*
|
||||
* Core's pipeline does the handshake; this only bridges its prompt channel to
|
||||
* HTTP. A connect that needs a person (a TOTP code, a browser sign-in, a
|
||||
* password the host does not store) answers the request that is waiting with
|
||||
* what to ask, and the next request (connect-totp, connect-browser-sign-in) carries
|
||||
* the answer back and waits for whatever comes after it: the session, another
|
||||
* prompt, or an error.
|
||||
*/
|
||||
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginSshHost,
|
||||
PluginSshPromptRequest,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { ContainerRuntime } from "./container-runtime.js";
|
||||
import type { DockerSessions } from "./sessions.js";
|
||||
import {
|
||||
connectionLog,
|
||||
getErrorMessage,
|
||||
type ConnectionLogLine,
|
||||
type DockerLogger,
|
||||
} from "./helpers.js";
|
||||
|
||||
/** What one HTTP request answers with. */
|
||||
export interface ConnectStep {
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const TOTP_WAIT_MS = 3 * 60 * 1000;
|
||||
const BROWSER_SIGN_IN_WAIT_MS = 5 * 60 * 1000;
|
||||
const STEP_WAIT_MS = 90 * 1000;
|
||||
// Covers the longest a person may take on a prompt, plus the handshake.
|
||||
const CONNECT_TIMEOUT_MS = BROWSER_SIGN_IN_WAIT_MS + 30 * 1000;
|
||||
|
||||
const PASSWORD_PROMPT = /password/i;
|
||||
|
||||
/** Hands each step to whichever request is waiting for one. */
|
||||
export class PendingConnect {
|
||||
constructor(
|
||||
readonly userId: string,
|
||||
readonly hostId: number,
|
||||
) {}
|
||||
|
||||
expiresAt = Date.now() + TOTP_WAIT_MS;
|
||||
/** What the open prompt expects, or null when nothing is being asked. */
|
||||
awaiting: "totp" | "browser" | null = null;
|
||||
abandoned = false;
|
||||
private waiter: ((step: ConnectStep) => void) | null = null;
|
||||
private queued: ConnectStep | null = null;
|
||||
private answer: ((value: string | null) => void) | null = null;
|
||||
|
||||
emit(step: ConnectStep): void {
|
||||
if (this.waiter) {
|
||||
const waiter = this.waiter;
|
||||
this.waiter = null;
|
||||
waiter(step);
|
||||
} else {
|
||||
this.queued = step;
|
||||
}
|
||||
}
|
||||
|
||||
next(timeoutMs = STEP_WAIT_MS): Promise<ConnectStep> {
|
||||
if (this.queued) {
|
||||
const step = this.queued;
|
||||
this.queued = null;
|
||||
return Promise.resolve(step);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.waiter !== settle) return;
|
||||
this.waiter = null;
|
||||
this.cancel();
|
||||
resolve({
|
||||
status: 408,
|
||||
body: { error: "Connection timed out. Please reconnect." },
|
||||
});
|
||||
}, timeoutMs);
|
||||
const settle = (step: ConnectStep) => {
|
||||
clearTimeout(timer);
|
||||
resolve(step);
|
||||
};
|
||||
this.waiter = settle;
|
||||
});
|
||||
}
|
||||
|
||||
ask(kind: "totp" | "browser", waitMs: number): Promise<string | null> {
|
||||
this.awaiting = kind;
|
||||
this.expiresAt = Date.now() + waitMs;
|
||||
return new Promise((resolve) => {
|
||||
this.answer = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
respond(value: string | null): void {
|
||||
const answer = this.answer;
|
||||
this.answer = null;
|
||||
this.awaiting = null;
|
||||
answer?.(value);
|
||||
}
|
||||
|
||||
/** Nobody will read the result: give up the prompt and drop the client. */
|
||||
cancel(): void {
|
||||
this.abandoned = true;
|
||||
this.respond(null);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyConnectError(
|
||||
message: string,
|
||||
logs: ConnectionLogLine[],
|
||||
): { hostKeyChanged: boolean; authFailed: boolean } {
|
||||
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
|
||||
logs.push(
|
||||
connectionLog("error", "dns", `DNS resolution failed: ${message}`),
|
||||
);
|
||||
} else if (
|
||||
message.includes("ECONNREFUSED") ||
|
||||
message.includes("ETIMEDOUT")
|
||||
) {
|
||||
logs.push(
|
||||
connectionLog("error", "tcp", `TCP connection failed: ${message}`),
|
||||
);
|
||||
} else if (message.includes("verification failed")) {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"error",
|
||||
"handshake",
|
||||
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
|
||||
),
|
||||
);
|
||||
return { hostKeyChanged: true, authFailed: false };
|
||||
} else if (
|
||||
message.includes("handshake") ||
|
||||
message.includes("key exchange")
|
||||
) {
|
||||
logs.push(
|
||||
connectionLog("error", "handshake", `SSH handshake failed: ${message}`),
|
||||
);
|
||||
} else if (/authentication/i.test(message)) {
|
||||
logs.push(
|
||||
connectionLog("error", "auth", `Authentication failed: ${message}`),
|
||||
);
|
||||
return { hostKeyChanged: false, authFailed: true };
|
||||
} else {
|
||||
logs.push(
|
||||
connectionLog("error", "error", `SSH connection failed: ${message}`),
|
||||
);
|
||||
}
|
||||
return { hostKeyChanged: false, authFailed: false };
|
||||
}
|
||||
|
||||
export interface StartConnectInput {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
host: PluginSshHost;
|
||||
runtime: ContainerRuntime;
|
||||
logs: ConnectionLogLine[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a connect and returns the first step for the request that asked.
|
||||
* The pending entry lives in `sessions` until the connect settles, so the
|
||||
* answer routes can find it.
|
||||
*/
|
||||
export async function startConnect(
|
||||
ctx: PluginContext,
|
||||
sessions: DockerSessions,
|
||||
log: DockerLogger,
|
||||
input: StartConnectInput,
|
||||
): Promise<ConnectStep> {
|
||||
const { sessionId, userId, host, runtime, logs } = input;
|
||||
const hostId = host.id;
|
||||
const authType = (host.authType as string) || "none";
|
||||
const credentialless = !ctx.ssh.requiresSecret(authType);
|
||||
|
||||
const pending = new PendingConnect(userId, hostId);
|
||||
sessions.setPending(sessionId, pending);
|
||||
|
||||
const parkTotp = (prompt: string, extra: Record<string, unknown>) => {
|
||||
logs.push(connectionLog("info", "docker_auth", "Verification required"));
|
||||
const answer = pending.ask("totp", TOTP_WAIT_MS);
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: {
|
||||
requires_totp: true,
|
||||
sessionId,
|
||||
prompt,
|
||||
connectionLogs: logs,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
return answer;
|
||||
};
|
||||
|
||||
const ask = async (
|
||||
request: PluginSshPromptRequest,
|
||||
): Promise<string | null> => {
|
||||
if (pending.abandoned) return null;
|
||||
switch (request.kind) {
|
||||
case "totp":
|
||||
return parkTotp(request.prompt, request.retry ? { retry: true } : {});
|
||||
case "browser": {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"info",
|
||||
"docker_auth",
|
||||
`${request.label} sign-in required`,
|
||||
),
|
||||
);
|
||||
const answer = pending.ask("browser", BROWSER_SIGN_IN_WAIT_MS);
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: {
|
||||
requires_browser_sign_in: true,
|
||||
sessionId,
|
||||
label: request.label,
|
||||
url: request.url,
|
||||
code: request.code,
|
||||
connectionLogs: logs,
|
||||
},
|
||||
});
|
||||
return answer;
|
||||
}
|
||||
case "input": {
|
||||
const isPassword = PASSWORD_PROMPT.test(request.prompt);
|
||||
if (isPassword && credentialless) {
|
||||
pending.abandoned = true;
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: { status: "auth_required", reason: "no_keyboard" },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!isPassword && !request.isPush) return "";
|
||||
return parkTotp(request.prompt, isPassword ? { isPassword: true } : {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
logs.push(connectionLog("info", "dns", `Resolving DNS for ${host.ip}`));
|
||||
logs.push(
|
||||
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
|
||||
);
|
||||
logs.push(connectionLog("info", "handshake", "Initiating SSH handshake"));
|
||||
|
||||
ctx.ssh
|
||||
.connect<Client>(host, {
|
||||
purpose: "docker",
|
||||
profile: "session",
|
||||
timeoutMs: CONNECT_TIMEOUT_MS,
|
||||
prompt: { ask },
|
||||
})
|
||||
.then(
|
||||
(connection) => {
|
||||
sessions.clearPending(sessionId);
|
||||
ctx.hosts.status.reportLogin(hostId, { ok: true });
|
||||
if (pending.abandoned) {
|
||||
connection.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: sessionId,
|
||||
client: connection.client,
|
||||
dispose: connection.dispose,
|
||||
hostId,
|
||||
userId,
|
||||
runtime,
|
||||
isWindows: false,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
};
|
||||
sessions.add(session);
|
||||
|
||||
connection.client.exec("ver", (err, stream) => {
|
||||
if (err || !stream) return;
|
||||
let output = "";
|
||||
stream.on("data", (d: Buffer) => {
|
||||
output += d.toString();
|
||||
});
|
||||
stream.on("close", () => {
|
||||
if (output.toLowerCase().includes("windows")) {
|
||||
session.isWindows = true;
|
||||
}
|
||||
});
|
||||
stream.stderr.on("data", () => {});
|
||||
});
|
||||
|
||||
void ctx.audit.record({
|
||||
action: "docker_connect",
|
||||
resourceType: "host",
|
||||
resourceId: String(hostId),
|
||||
resourceName: (host.name as string) || undefined,
|
||||
success: true,
|
||||
});
|
||||
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"success",
|
||||
"connected",
|
||||
"SSH connection established successfully",
|
||||
),
|
||||
);
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: { success: true, status: "success", connectionLogs: logs },
|
||||
});
|
||||
},
|
||||
(error: unknown) => {
|
||||
sessions.clearPending(sessionId);
|
||||
const message = getErrorMessage(error, "SSH connection failed");
|
||||
if (pending.abandoned) return;
|
||||
|
||||
const outcome = (error as { outcome?: Record<string, unknown> })
|
||||
.outcome;
|
||||
if (outcome?.status === "interaction-required") {
|
||||
logs.push(connectionLog("error", "docker_auth", message));
|
||||
pending.emit({
|
||||
status: 401,
|
||||
body: {
|
||||
error: message,
|
||||
requiresAuthInteraction: outcome.interaction,
|
||||
...(typeof outcome.flag === "string"
|
||||
? { [outcome.flag]: true }
|
||||
: {}),
|
||||
connectionLogs: logs,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (outcome?.status === "error") {
|
||||
logs.push(connectionLog("error", "docker_auth", message));
|
||||
pending.emit({
|
||||
status: 400,
|
||||
body: { error: message, connectionLogs: logs },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { hostKeyChanged, authFailed } = classifyConnectError(
|
||||
message,
|
||||
logs,
|
||||
);
|
||||
if (hostKeyChanged || authFailed) {
|
||||
ctx.hosts.status.reportLogin(hostId, { ok: false, hostKeyChanged });
|
||||
}
|
||||
log.error("Docker SSH connection failed", {
|
||||
hostId,
|
||||
userId,
|
||||
error: message,
|
||||
});
|
||||
|
||||
if (credentialless && authFailed) {
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: {
|
||||
status: "auth_required",
|
||||
reason: "no_keyboard",
|
||||
connectionLogs: logs,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
pending.emit({
|
||||
status: 500,
|
||||
body: { success: false, message, connectionLogs: logs },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return pending.next();
|
||||
}
|
||||
|
||||
/** Carries a person's answer to a parked connect and waits for what's next. */
|
||||
export async function answerConnect(
|
||||
sessions: DockerSessions,
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
kind: "totp" | "browser",
|
||||
value: string,
|
||||
): Promise<ConnectStep> {
|
||||
const pending = sessions.getPending(sessionId);
|
||||
if (!(pending instanceof PendingConnect) || pending.userId !== userId) {
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: "Authentication session expired. Please reconnect." },
|
||||
};
|
||||
}
|
||||
if (pending.awaiting !== kind) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error:
|
||||
kind === "browser"
|
||||
? "Session is not waiting for a browser sign-in"
|
||||
: "Session is not waiting for a code",
|
||||
},
|
||||
};
|
||||
}
|
||||
pending.respond(value);
|
||||
return pending.next();
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { AsyncResource } from "node:async_hooks";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import { WebSocket, type RawData } from "ws";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginWebSocketConnection,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
containerCommand,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
import { readDockerHostSettings } from "./host-settings.js";
|
||||
import {
|
||||
CONTAINER_ID_RE,
|
||||
HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||
asObject,
|
||||
asString,
|
||||
getErrorMessage,
|
||||
hostAddressMismatch,
|
||||
parseWsMessage,
|
||||
toTerminalDimension,
|
||||
type DockerLogger,
|
||||
} from "./helpers.js";
|
||||
|
||||
const SHELLS = ["bash", "sh", "ash", "zsh"];
|
||||
const PING_MS = 30_000;
|
||||
|
||||
interface ConsoleSession {
|
||||
client: Client;
|
||||
dispose: () => void;
|
||||
stream: ClientChannel | null;
|
||||
hostId: number;
|
||||
containerId: string;
|
||||
runtime: ContainerRuntime;
|
||||
}
|
||||
|
||||
function shellExists(
|
||||
client: Client,
|
||||
runtime: ContainerRuntime,
|
||||
containerId: string,
|
||||
shell: string,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
client.exec(
|
||||
containerCommand(runtime, `exec ${containerId} which ${shell}`),
|
||||
(err, stream) => {
|
||||
if (err) return resolve(false);
|
||||
let output = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
stream.on("close", (code: number) =>
|
||||
resolve(code === 0 && output.trim().length > 0),
|
||||
);
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", () => resolve(false));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function pickShell(
|
||||
client: Client,
|
||||
runtime: ContainerRuntime,
|
||||
containerId: string,
|
||||
requested: string | undefined,
|
||||
): Promise<string> {
|
||||
if (requested && (await shellExists(client, runtime, containerId, requested)))
|
||||
return requested;
|
||||
for (const shell of ["bash", "sh", "ash"]) {
|
||||
if (await shellExists(client, runtime, containerId, shell)) return shell;
|
||||
}
|
||||
return "sh";
|
||||
}
|
||||
|
||||
/**
|
||||
* The container console at /plugin-ws/docker/console.
|
||||
*
|
||||
* Public with optionalAuth: core still resolves the caller's token, and the
|
||||
* handler refuses a socket without a user or without docker.use. Every live
|
||||
* console is closed when the plugin deactivates.
|
||||
*/
|
||||
export function registerConsole(ctx: PluginContext, log: DockerLogger): void {
|
||||
const live = new Set<() => void>();
|
||||
ctx.disposables.add(() => {
|
||||
for (const end of [...live]) end();
|
||||
live.clear();
|
||||
});
|
||||
|
||||
const onConnection = async (connection: PluginWebSocketConnection) => {
|
||||
const ws = connection.socket as WebSocket;
|
||||
const userId = connection.userId;
|
||||
if (!userId || !(await ctx.rbac.hasFor(userId, "use"))) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
let session: ConsoleSession | null = null;
|
||||
const send = (message: Record<string, unknown>) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
|
||||
};
|
||||
|
||||
const endSession = () => {
|
||||
if (!session) return;
|
||||
const current = session;
|
||||
session = null;
|
||||
current.stream?.end();
|
||||
current.dispose();
|
||||
};
|
||||
|
||||
const pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
||||
}, PING_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(pingTimer);
|
||||
endSession();
|
||||
live.delete(shutdown);
|
||||
};
|
||||
const shutdown = () => {
|
||||
cleanup();
|
||||
try {
|
||||
ws.close(1001, "Docker plugin disabled");
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
};
|
||||
live.add(shutdown);
|
||||
|
||||
const connect = async (data: unknown) => {
|
||||
if (!connection.isDataUnlocked()) {
|
||||
send({
|
||||
type: "error",
|
||||
message: "Session expired - please log in again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const payload = asObject(data);
|
||||
const hostConfig = asObject(payload.hostConfig);
|
||||
const hostId = Number(hostConfig.id);
|
||||
const syncId =
|
||||
typeof hostConfig.syncId === "string" ? hostConfig.syncId : null;
|
||||
const containerId = asString(payload.containerId);
|
||||
const shell = asString(payload.shell) || undefined;
|
||||
const cols = toTerminalDimension(payload.cols) || 80;
|
||||
const rows = toTerminalDimension(payload.rows) || 24;
|
||||
|
||||
if (!hostId || !containerId) {
|
||||
send({
|
||||
type: "error",
|
||||
message: "Host configuration and container ID are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!CONTAINER_ID_RE.test(containerId)) {
|
||||
send({ type: "error", message: "Invalid container ID" });
|
||||
return;
|
||||
}
|
||||
if (shell && !SHELLS.includes(shell)) {
|
||||
send({ type: "error", message: "Invalid shell" });
|
||||
return;
|
||||
}
|
||||
|
||||
endSession();
|
||||
|
||||
try {
|
||||
// syncId names the host on both sides of a sync pair; the numeric
|
||||
// id only names it in the database the client is displaying.
|
||||
const host = await ctx.ssh.resolveHost(hostId, { syncId });
|
||||
if (!host) {
|
||||
send({
|
||||
type: "error",
|
||||
message: syncId
|
||||
? HOST_NOT_ON_THIS_SERVER_MESSAGE
|
||||
: "Host not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!syncId && hostAddressMismatch(hostConfig.ip, host.ip)) {
|
||||
log.error("Refusing Docker console: host id resolves elsewhere", {
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
send({ type: "error", message: HOST_ADDRESS_MISMATCH_MESSAGE });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await readDockerHostSettings(ctx, host.id);
|
||||
if (!settings.enabled) {
|
||||
send({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { client, dispose } = await ctx.ssh.connect<Client>(host, {
|
||||
purpose: "docker-console",
|
||||
profile: "stream",
|
||||
timeoutMs: 65_000,
|
||||
});
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
const current: ConsoleSession = {
|
||||
client,
|
||||
dispose,
|
||||
stream: null,
|
||||
hostId: host.id,
|
||||
containerId,
|
||||
runtime: settings.runtime,
|
||||
};
|
||||
session = current;
|
||||
|
||||
const shellToUse = await pickShell(
|
||||
client,
|
||||
settings.runtime,
|
||||
containerId,
|
||||
shell,
|
||||
);
|
||||
if (session !== current) return;
|
||||
|
||||
client.exec(
|
||||
containerCommand(
|
||||
settings.runtime,
|
||||
`exec -it ${containerId} /bin/${shellToUse}`,
|
||||
),
|
||||
{ pty: { term: "xterm-256color", cols, rows } },
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
send({
|
||||
type: "error",
|
||||
message: `Failed to start console: ${err.message}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (session !== current) {
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
current.stream = stream;
|
||||
|
||||
// Buffers split multi-byte UTF-8 sequences across chunks.
|
||||
const decoder = new StringDecoder("utf-8");
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
const text = decoder.write(chunk);
|
||||
if (text) send({ type: "output", data: text });
|
||||
});
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
send({
|
||||
type: "error",
|
||||
message: `Console error: ${streamErr.message}`,
|
||||
});
|
||||
});
|
||||
stream.on("close", () => {
|
||||
send({ type: "disconnected", message: "Console session ended" });
|
||||
if (session === current) endSession();
|
||||
});
|
||||
|
||||
send({
|
||||
type: "connected",
|
||||
data: {
|
||||
shell: shellToUse,
|
||||
requestedShell: shell,
|
||||
shellChanged: !!shell && shell !== shellToUse,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to connect to container", error, {
|
||||
hostId,
|
||||
containerId,
|
||||
});
|
||||
send({
|
||||
type: "error",
|
||||
message: getErrorMessage(error, "Failed to connect to container"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.on(
|
||||
"message",
|
||||
AsyncResource.bind(async (raw: RawData) => {
|
||||
let type: string;
|
||||
let data: unknown;
|
||||
try {
|
||||
({ type, data } = parseWsMessage(raw));
|
||||
} catch (error) {
|
||||
send({ type: "error", message: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "connect":
|
||||
await connect(data);
|
||||
break;
|
||||
case "input": {
|
||||
const input = asString(data);
|
||||
if (input) session?.stream?.write(input);
|
||||
break;
|
||||
}
|
||||
case "resize": {
|
||||
const size = asObject(data);
|
||||
const cols = toTerminalDimension(size.cols);
|
||||
const rows = toTerminalDimension(size.rows);
|
||||
if (cols && rows)
|
||||
session?.stream?.setWindow(rows, cols, rows, cols);
|
||||
break;
|
||||
}
|
||||
case "disconnect":
|
||||
if (session) {
|
||||
endSession();
|
||||
send({
|
||||
type: "disconnected",
|
||||
message: "Disconnected from container",
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "ping":
|
||||
send({ type: "pong" });
|
||||
break;
|
||||
default:
|
||||
log.warn("Unknown docker console message", { type });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
ws.on("close", cleanup);
|
||||
ws.on("error", (error: Error) => {
|
||||
log.error("Docker console socket error", error);
|
||||
cleanup();
|
||||
});
|
||||
};
|
||||
|
||||
ctx.ws.route("/console", onConnection, { public: true, optionalAuth: true });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type ContainerRuntime = "docker" | "podman";
|
||||
|
||||
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
|
||||
return value === "podman" ? "podman" : "docker";
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
||||
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
||||
* Extend PATH with their common install locations before invoking the CLI.
|
||||
*/
|
||||
const EXTRA_PATH_DIRS =
|
||||
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
||||
|
||||
export function containerCommand(
|
||||
runtime: ContainerRuntime | undefined,
|
||||
args: string,
|
||||
): string {
|
||||
return `PATH="${EXTRA_PATH_DIRS}:$PATH" ${normalizeContainerRuntime(runtime)} ${args}`;
|
||||
}
|
||||
|
||||
export function getRuntimeLabel(runtime: ContainerRuntime): string {
|
||||
return runtime === "podman" ? "Podman" : "Docker";
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Container state snapshots and the events two of them imply, for the
|
||||
* docker.events service.
|
||||
*
|
||||
* Events come from diffing successive `ps -a` snapshots, so the poll interval
|
||||
* is the resolution: a container that stops and starts between two polls is
|
||||
* not reported. That is the tradeoff for not holding a `docker events` stream
|
||||
* open against every watched host.
|
||||
*/
|
||||
|
||||
export interface ContainerState {
|
||||
/** Docker's own state word: running, exited, restarting, ... */
|
||||
state: string;
|
||||
/** Health from the status text, when the image declares a healthcheck. */
|
||||
unhealthy: boolean;
|
||||
}
|
||||
|
||||
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
|
||||
|
||||
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
|
||||
|
||||
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
|
||||
export function parseContainerStates(
|
||||
output: string,
|
||||
): Map<string, ContainerState> {
|
||||
const states = new Map<string, ContainerState>();
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as {
|
||||
name?: string;
|
||||
state?: string;
|
||||
status?: string;
|
||||
};
|
||||
if (!parsed.name) continue;
|
||||
|
||||
states.set(parsed.name, {
|
||||
state: (parsed.state ?? "").toLowerCase(),
|
||||
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
|
||||
});
|
||||
} catch {
|
||||
// A partial line is not worth failing the whole poll over.
|
||||
}
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* The events a pair of snapshots implies. A container missing from the
|
||||
* previous snapshot counts as newly seen rather than started, so the first
|
||||
* poll after a restart does not replay every running container.
|
||||
*/
|
||||
export function diffContainerStates(
|
||||
previous: Map<string, ContainerState>,
|
||||
current: Map<string, ContainerState>,
|
||||
): Array<{ container: string; event: DockerEventName }> {
|
||||
const events: Array<{ container: string; event: DockerEventName }> = [];
|
||||
|
||||
for (const [name, now] of current) {
|
||||
const before = previous.get(name);
|
||||
if (!before) continue;
|
||||
|
||||
if (before.state !== now.state) {
|
||||
if (now.state === "exited")
|
||||
events.push({ container: name, event: "exited" });
|
||||
else if (now.state === "running")
|
||||
events.push({ container: name, event: "started" });
|
||||
else if (now.state === "restarting")
|
||||
events.push({ container: name, event: "restarting" });
|
||||
}
|
||||
|
||||
// Health is independent of state: a container can go unhealthy while it
|
||||
// stays up, which is exactly the case worth alerting on.
|
||||
if (!before.unhealthy && now.unhealthy) {
|
||||
events.push({ container: name, event: "unhealthy" });
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { RawData } from "ws";
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export type Meta = Record<string, unknown>;
|
||||
|
||||
export interface DockerLogger {
|
||||
info: (message: string, meta?: Meta) => void;
|
||||
warn: (message: string, meta?: Meta) => void;
|
||||
/** An error, or structured context, or both. */
|
||||
error: (message: string, errorOrMeta?: unknown, meta?: Meta) => void;
|
||||
}
|
||||
|
||||
function withMeta(message: string, meta?: Meta): string {
|
||||
if (!meta || Object.keys(meta).length === 0) return message;
|
||||
try {
|
||||
return `${message} ${JSON.stringify(meta)}`;
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/** ctx.log with the structured context the old core logger carried. */
|
||||
export function createLogger(log: PluginLogger): DockerLogger {
|
||||
return {
|
||||
info: (message, meta) => log.info(withMeta(message, meta)),
|
||||
warn: (message, meta) => log.warn(withMeta(message, meta)),
|
||||
error: (message, errorOrMeta, meta) => {
|
||||
if (errorOrMeta instanceof Error) {
|
||||
log.error(withMeta(message, meta), errorOrMeta);
|
||||
return;
|
||||
}
|
||||
log.error(
|
||||
withMeta(message, {
|
||||
...((errorOrMeta as Meta | undefined) ?? {}),
|
||||
...meta,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type LogLevel = "info" | "success" | "warning" | "error";
|
||||
|
||||
/** One line of the connection log the panel shows while connecting. */
|
||||
export interface ConnectionLogLine {
|
||||
type: LogLevel;
|
||||
stage: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function connectionLog(
|
||||
type: LogLevel,
|
||||
stage: string,
|
||||
message: string,
|
||||
): ConnectionLogLine {
|
||||
return { type, stage, message };
|
||||
}
|
||||
|
||||
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
|
||||
|
||||
// Cap on a single decoded text frame. Control messages are tiny, and console
|
||||
// input is bounded by what a user can type or paste.
|
||||
export const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
|
||||
|
||||
function rawByteLength(raw: RawData): number {
|
||||
if (Buffer.isBuffer(raw)) return raw.length;
|
||||
if (Array.isArray(raw))
|
||||
return raw.reduce((sum, part) => sum + part.length, 0);
|
||||
if (raw instanceof ArrayBuffer) return raw.byteLength;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Parses a frame into `{ type, data }`, throwing on anything else. */
|
||||
export function parseWsMessage(raw: RawData): { type: string; data: unknown } {
|
||||
if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) {
|
||||
throw new Error("Message too large");
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Message must be a JSON object");
|
||||
}
|
||||
const { type, data } = parsed as { type?: unknown; data?: unknown };
|
||||
if (typeof type !== "string") {
|
||||
throw new Error("Message type must be a string");
|
||||
}
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
export function asObject(data: unknown): Record<string, unknown> {
|
||||
return data !== null && typeof data === "object" && !Array.isArray(data)
|
||||
? (data as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** A sane integer width or height, or 0 when the value is unusable. */
|
||||
export function toTerminalDimension(value: unknown): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
const rounded = Math.floor(n);
|
||||
if (rounded < 1) return 0;
|
||||
return Math.min(rounded, 10000);
|
||||
}
|
||||
|
||||
function normalizeHostAddress(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a numeric host id resolved to a different machine than the client
|
||||
* meant. Ids from the desktop app and a sync server drift apart, and the
|
||||
* resolved row supplies the credentials, so a mismatch must be refused.
|
||||
*/
|
||||
export function hostAddressMismatch(
|
||||
clientAddress: unknown,
|
||||
resolvedAddress: unknown,
|
||||
): boolean {
|
||||
const resolved = normalizeHostAddress(resolvedAddress);
|
||||
if (!resolved) return false;
|
||||
return resolved !== normalizeHostAddress(clientAddress);
|
||||
}
|
||||
|
||||
export const HOST_ADDRESS_MISMATCH_MESSAGE =
|
||||
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
|
||||
"The host ids on this device and on the sync server have drifted apart. " +
|
||||
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.';
|
||||
|
||||
export const HOST_NOT_ON_THIS_SERVER_MESSAGE =
|
||||
"This host does not exist on the sync server, so the connection was refused. " +
|
||||
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
|
||||
|
||||
export function newSessionId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
normalizeContainerRuntime,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
|
||||
export const PLUGIN_ID = "docker";
|
||||
|
||||
export interface DockerHostSettings {
|
||||
enabled: boolean;
|
||||
runtime: ContainerRuntime;
|
||||
}
|
||||
|
||||
export async function readDockerHostSettings(
|
||||
ctx: PluginContext,
|
||||
hostId: number,
|
||||
): Promise<DockerHostSettings> {
|
||||
const values = await ctx.settings.getAll("host", hostId);
|
||||
return {
|
||||
enabled: values.enableDocker === true,
|
||||
runtime: normalizeContainerRuntime(values.containerRuntime),
|
||||
};
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
let parsed = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === 1 || value === "1" || value === "true") return true;
|
||||
if (value === 0 || value === "0" || value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The docker host settings in an imported or created host row: the nested
|
||||
* `pluginSettings.docker` of a Termix export first, then the flat
|
||||
* `enableDocker` and `dockerConfig.runtime` hosts had before 2.9.0 (older
|
||||
* exports, proxmox guests marked as Docker hosts).
|
||||
*/
|
||||
export function normalizeImportedHost(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const nested = asObject(asObject(raw.pluginSettings)?.[PLUGIN_ID]) ?? {};
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
const enabled = asBool(
|
||||
nested.enableDocker !== undefined ? nested.enableDocker : raw.enableDocker,
|
||||
);
|
||||
if (enabled !== undefined) out.enableDocker = enabled;
|
||||
|
||||
const runtime =
|
||||
nested.containerRuntime ?? asObject(raw.dockerConfig)?.runtime;
|
||||
if (runtime === "docker" || runtime === "podman") {
|
||||
out.containerRuntime = runtime;
|
||||
}
|
||||
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { createLogger } from "./helpers.js";
|
||||
import { createDockerSessions } from "./sessions.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { registerConsole } from "./console.js";
|
||||
import { registerServices } from "./services.js";
|
||||
import { normalizeImportedHost } from "./host-settings.js";
|
||||
|
||||
export type {
|
||||
DockerContainerAction,
|
||||
DockerContainerSummary,
|
||||
DockerEvent,
|
||||
DockerEventsV1,
|
||||
DockerServiceV1,
|
||||
} from "./services.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const log = createLogger(ctx.log);
|
||||
const sessions = createDockerSessions(ctx.schedule, log);
|
||||
ctx.disposables.add(() => sessions.closeAll());
|
||||
|
||||
registerRoutes(ctx.http.router<Router>(), { ctx, sessions, log });
|
||||
registerConsole(ctx, log);
|
||||
registerServices(ctx, log);
|
||||
ctx.registry.provide("docker.hostImportNormalizer", normalizeImportedHost);
|
||||
|
||||
ctx.log.info(
|
||||
"Docker mounted at /plugin-api/docker and /plugin-ws/docker/console",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {}
|
||||
@@ -0,0 +1,998 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import { answerConnect, startConnect } from "./connect.js";
|
||||
import { containerCommand, getRuntimeLabel } from "./container-runtime.js";
|
||||
import { readDockerHostSettings } from "./host-settings.js";
|
||||
import type { DockerSession, DockerSessions } from "./sessions.js";
|
||||
import {
|
||||
CONTAINER_ID_RE,
|
||||
DOCKER_TIMESTAMP_RE,
|
||||
connectionLog,
|
||||
getErrorMessage,
|
||||
type ConnectionLogLine,
|
||||
type DockerLogger,
|
||||
} from "./helpers.js";
|
||||
|
||||
const CONTAINER_ACTIONS = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"pause",
|
||||
"unpause",
|
||||
] as const;
|
||||
type ContainerAction = (typeof CONTAINER_ACTIONS)[number];
|
||||
|
||||
const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
|
||||
const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`;
|
||||
const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
|
||||
const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`;
|
||||
|
||||
export function listFormat(isWindows: boolean): string {
|
||||
return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX;
|
||||
}
|
||||
|
||||
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
|
||||
export function parseContainerList(output: string): Record<string, unknown>[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line) as Record<string, unknown>];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notFound(error: unknown): boolean {
|
||||
return getErrorMessage(error, "").includes("No such container");
|
||||
}
|
||||
|
||||
interface Deps {
|
||||
ctx: PluginContext;
|
||||
sessions: DockerSessions;
|
||||
log: DockerLogger;
|
||||
}
|
||||
|
||||
export function registerRoutes(router: Router, { ctx, sessions, log }: Deps) {
|
||||
router.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
router.use(ctx.rbac.require("use") as never);
|
||||
|
||||
router.param("containerId", (_req, res, next, value) => {
|
||||
if (!CONTAINER_ID_RE.test(value)) {
|
||||
res.status(400).json({ error: "Invalid container ID" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
const userOf = (res: Response): string | null => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return null;
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
|
||||
/** The caller's live session for the path's sessionId, or an error reply. */
|
||||
const sessionOf = (req: Request, res: Response): DockerSession | null => {
|
||||
const userId = userOf(res);
|
||||
if (!userId) return null;
|
||||
const sessionId = String(req.params.sessionId ?? "");
|
||||
if (sessions.getPending(sessionId)) {
|
||||
res.status(400).json({
|
||||
error: "Connection pending authentication",
|
||||
code: "AUTH_PENDING",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const session = sessions.get(sessionId, userId);
|
||||
if (!session) {
|
||||
res.status(400).json({ error: "SSH session not found or not connected" });
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/ssh/connect:
|
||||
* post:
|
||||
* summary: Establish SSH session for Docker
|
||||
* description: Opens an SSH session to a host for Docker operations. When the host asks for a code, a browser sign-in or a password, the reply says so and the session waits for connect-totp or connect-browser-sign-in.
|
||||
* tags:
|
||||
* - Docker
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - sessionId
|
||||
* - hostId
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* hostId:
|
||||
* type: integer
|
||||
* userProvidedPassword:
|
||||
* type: string
|
||||
* userProvidedSshKey:
|
||||
* type: string
|
||||
* userProvidedKeyPassword:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connected, or a prompt the user has to answer.
|
||||
* 400:
|
||||
* description: Missing sessionId or hostId.
|
||||
* 403:
|
||||
* description: Docker is not enabled for this host, or the user lacks docker.use.
|
||||
* 404:
|
||||
* description: Host not found.
|
||||
* 500:
|
||||
* description: SSH connection failed.
|
||||
*/
|
||||
router.post("/ssh/connect", async (req, res) => {
|
||||
const userId = userOf(res);
|
||||
if (!userId) return;
|
||||
const {
|
||||
sessionId,
|
||||
hostId,
|
||||
userProvidedPassword,
|
||||
userProvidedSshKey,
|
||||
userProvidedKeyPassword,
|
||||
} = (req.body ?? {}) as Record<string, unknown>;
|
||||
const logs: ConnectionLogLine[] = [];
|
||||
|
||||
if (typeof sessionId !== "string" || !sessionId || !hostId) {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"error",
|
||||
"docker_connecting",
|
||||
"Missing connection parameters",
|
||||
),
|
||||
);
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing sessionId or hostId", connectionLogs: logs });
|
||||
}
|
||||
const numericHostId = Number(hostId);
|
||||
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"info",
|
||||
"docker_connecting",
|
||||
"Initiating Docker SSH connection",
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const host = await ctx.ssh.resolveHost(numericHostId);
|
||||
if (!host) {
|
||||
logs.push(
|
||||
connectionLog("error", "docker_connecting", "Host not found"),
|
||||
);
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Host not found", connectionLogs: logs });
|
||||
}
|
||||
|
||||
const settings = await readDockerHostSettings(ctx, numericHostId);
|
||||
if (!settings.enabled) {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"error",
|
||||
"docker_connecting",
|
||||
"Docker is not enabled for this host",
|
||||
),
|
||||
);
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
code: "DOCKER_DISABLED",
|
||||
connectionLogs: logs,
|
||||
});
|
||||
}
|
||||
|
||||
sessions.close(sessionId);
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"info",
|
||||
"docker_auth",
|
||||
"Resolving authentication credentials",
|
||||
),
|
||||
);
|
||||
|
||||
const target: PluginSshHost = {
|
||||
...host,
|
||||
id: numericHostId,
|
||||
port: host.port || 22,
|
||||
};
|
||||
if (typeof userProvidedPassword === "string" && userProvidedPassword) {
|
||||
target.password = userProvidedPassword;
|
||||
target.authType = "password";
|
||||
}
|
||||
if (typeof userProvidedSshKey === "string" && userProvidedSshKey) {
|
||||
target.key = userProvidedSshKey;
|
||||
target.authType = "key";
|
||||
}
|
||||
if (
|
||||
typeof userProvidedKeyPassword === "string" &&
|
||||
userProvidedKeyPassword
|
||||
) {
|
||||
target.keyPassword = userProvidedKeyPassword;
|
||||
}
|
||||
|
||||
const step = await startConnect(ctx, sessions, log, {
|
||||
sessionId,
|
||||
userId,
|
||||
host: target,
|
||||
runtime: settings.runtime,
|
||||
logs,
|
||||
});
|
||||
return res.status(step.status).json(step.body);
|
||||
} catch (error) {
|
||||
log.error("Docker SSH connection error", error, {
|
||||
hostId: numericHostId,
|
||||
userId,
|
||||
});
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"error",
|
||||
"docker_connecting",
|
||||
`Connection error: ${getErrorMessage(error)}`,
|
||||
),
|
||||
);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: getErrorMessage(error),
|
||||
connectionLogs: logs,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/ssh/connect-totp:
|
||||
* post:
|
||||
* summary: Answer a verification prompt
|
||||
* description: Sends the code (or password) the host asked for and waits for the next step of the connect.
|
||||
* tags:
|
||||
* - Docker
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - sessionId
|
||||
* - totpCode
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* totpCode:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connected, or another prompt.
|
||||
* 400:
|
||||
* description: Session ID and code required, or the session is not waiting for a code.
|
||||
* 404:
|
||||
* description: The connect expired.
|
||||
* 408:
|
||||
* description: The host did not answer in time.
|
||||
*/
|
||||
router.post("/ssh/connect-totp", async (req, res) => {
|
||||
const userId = userOf(res);
|
||||
if (!userId) return;
|
||||
const { sessionId, totpCode } = (req.body ?? {}) as Record<string, unknown>;
|
||||
if (typeof sessionId !== "string" || typeof totpCode !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Session ID and TOTP code required" });
|
||||
}
|
||||
const step = await answerConnect(
|
||||
sessions,
|
||||
sessionId,
|
||||
userId,
|
||||
"totp",
|
||||
totpCode.trim(),
|
||||
);
|
||||
return res.status(step.status).json(step.body);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/ssh/connect-browser-sign-in:
|
||||
* post:
|
||||
* summary: Continue after a browser sign-in
|
||||
* description: Continues a connect after the user finished signing in in the browser (an SSH gateway's approval, for example).
|
||||
* tags:
|
||||
* - Docker
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - sessionId
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connected, or another prompt.
|
||||
* 400:
|
||||
* description: Session ID required, or the session is not waiting for a browser sign-in.
|
||||
* 404:
|
||||
* description: The connect expired.
|
||||
* 408:
|
||||
* description: The host did not answer in time.
|
||||
*/
|
||||
router.post("/ssh/connect-browser-sign-in", async (req, res) => {
|
||||
const userId = userOf(res);
|
||||
if (!userId) return;
|
||||
const { sessionId } = (req.body ?? {}) as Record<string, unknown>;
|
||||
if (typeof sessionId !== "string" || !sessionId) {
|
||||
return res.status(400).json({ error: "Session ID required" });
|
||||
}
|
||||
const step = await answerConnect(
|
||||
sessions,
|
||||
sessionId,
|
||||
userId,
|
||||
"browser",
|
||||
"",
|
||||
);
|
||||
return res.status(step.status).json(step.body);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/ssh/disconnect:
|
||||
* post:
|
||||
* summary: Disconnect SSH session
|
||||
* description: Closes the caller's Docker SSH session.
|
||||
* tags:
|
||||
* - Docker
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: SSH session disconnected.
|
||||
* 400:
|
||||
* description: Session ID is required.
|
||||
*/
|
||||
router.post("/ssh/disconnect", (req, res) => {
|
||||
const userId = userOf(res);
|
||||
if (!userId) return;
|
||||
const { sessionId } = (req.body ?? {}) as Record<string, unknown>;
|
||||
if (typeof sessionId !== "string" || !sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
const pending = sessions.getPending(sessionId);
|
||||
if (sessions.get(sessionId, userId) || pending?.userId === userId) {
|
||||
sessions.close(sessionId);
|
||||
}
|
||||
return res.json({ success: true, message: "SSH session disconnected" });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/ssh/keepalive:
|
||||
* post:
|
||||
* summary: Keep SSH session alive
|
||||
* description: Resets the idle timeout of the caller's Docker SSH session.
|
||||
* tags:
|
||||
* - Docker
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session keepalive successful.
|
||||
* 400:
|
||||
* description: Session ID is required or session not found.
|
||||
*/
|
||||
router.post("/ssh/keepalive", (req, res) => {
|
||||
const userId = userOf(res);
|
||||
if (!userId) return;
|
||||
const { sessionId } = (req.body ?? {}) as Record<string, unknown>;
|
||||
if (typeof sessionId !== "string" || !sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
const session = sessions.get(sessionId, userId);
|
||||
if (!session) {
|
||||
return res.status(400).json({
|
||||
error: "SSH session not found or not connected",
|
||||
connected: false,
|
||||
});
|
||||
}
|
||||
sessions.touch(session);
|
||||
return res.json({
|
||||
success: true,
|
||||
connected: true,
|
||||
lastActive: session.lastActive,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/ssh/status:
|
||||
* get:
|
||||
* summary: Check SSH session status
|
||||
* description: Whether the caller's Docker SSH session is connected.
|
||||
* tags:
|
||||
* - Docker
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session status.
|
||||
* 400:
|
||||
* description: Session ID is required.
|
||||
*/
|
||||
router.get("/ssh/status", (req, res) => {
|
||||
const userId = userOf(res);
|
||||
if (!userId) return;
|
||||
const sessionId = req.query.sessionId;
|
||||
if (typeof sessionId !== "string" || !sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
connected: !!sessions.get(sessionId, userId),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/validate/{sessionId}:
|
||||
* get:
|
||||
* summary: Validate Docker availability
|
||||
* description: Checks that the container runtime is installed and its daemon answers.
|
||||
* tags:
|
||||
* - Docker
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Docker availability status.
|
||||
* 400:
|
||||
* description: SSH session not found or not connected.
|
||||
*/
|
||||
router.get("/validate/:sessionId", async (req, res) => {
|
||||
const session = sessionOf(req, res);
|
||||
if (!session) return;
|
||||
const runtime = session.runtime;
|
||||
const label = getRuntimeLabel(runtime);
|
||||
|
||||
await sessions.run(session, async () => {
|
||||
let version = "unknown";
|
||||
try {
|
||||
const output = await sessions.exec(
|
||||
session,
|
||||
containerCommand(runtime, "--version"),
|
||||
);
|
||||
version =
|
||||
output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ??
|
||||
"unknown";
|
||||
} catch {
|
||||
res.json({
|
||||
available: false,
|
||||
error: `${label} is not installed on this host.`,
|
||||
code: "NOT_INSTALLED",
|
||||
runtime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sessions.exec(session, containerCommand(runtime, "ps"));
|
||||
res.json({ available: true, version, runtime });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
if (message.includes("Cannot connect to the Docker daemon")) {
|
||||
res.json({
|
||||
available: false,
|
||||
error: `${label} daemon is not running or accessible`,
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
runtime,
|
||||
});
|
||||
} else if (message.includes("permission denied")) {
|
||||
res.json({
|
||||
available: false,
|
||||
error: `Permission denied accessing ${label}`,
|
||||
code: "PERMISSION_DENIED",
|
||||
runtime,
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
available: false,
|
||||
error: message,
|
||||
code: "DOCKER_ERROR",
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/containers/{sessionId}:
|
||||
* get:
|
||||
* summary: List all containers
|
||||
* description: Lists the containers on the session's host.
|
||||
* tags:
|
||||
* - Docker
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: all
|
||||
* schema:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of containers.
|
||||
* 400:
|
||||
* description: SSH session not found or not connected.
|
||||
* 500:
|
||||
* description: Failed to list containers.
|
||||
*/
|
||||
router.get("/containers/:sessionId", async (req, res) => {
|
||||
const session = sessionOf(req, res);
|
||||
if (!session) return;
|
||||
const all = req.query.all !== "false";
|
||||
try {
|
||||
const output = await sessions.run(session, () =>
|
||||
sessions.exec(
|
||||
session,
|
||||
containerCommand(
|
||||
session.runtime,
|
||||
`ps ${all ? "-a " : ""}--format ${listFormat(session.isWindows)}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
res.json(parseContainerList(output));
|
||||
} catch (error) {
|
||||
log.error("Failed to list Docker containers", error, {
|
||||
hostId: session.hostId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(error, "Failed to list containers"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/containers/{sessionId}/{containerId}:
|
||||
* get:
|
||||
* summary: Get container details
|
||||
* description: The container's inspect output.
|
||||
* tags:
|
||||
* - Docker
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: containerId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Container details.
|
||||
* 400:
|
||||
* description: SSH session not found or not connected.
|
||||
* 404:
|
||||
* description: Container not found.
|
||||
* 500:
|
||||
* description: Failed to get container details.
|
||||
*/
|
||||
router.get("/containers/:sessionId/:containerId", async (req, res) => {
|
||||
const session = sessionOf(req, res);
|
||||
if (!session) return;
|
||||
const { containerId } = req.params;
|
||||
try {
|
||||
const output = await sessions.run(session, () =>
|
||||
sessions.exec(
|
||||
session,
|
||||
containerCommand(session.runtime, `inspect ${containerId}`),
|
||||
),
|
||||
);
|
||||
const details = JSON.parse(output) as unknown[];
|
||||
if (details?.length > 0) {
|
||||
res.json(details[0]);
|
||||
} else {
|
||||
res
|
||||
.status(404)
|
||||
.json({ error: "Container not found", code: "CONTAINER_NOT_FOUND" });
|
||||
}
|
||||
} catch (error) {
|
||||
if (notFound(error)) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Container not found", code: "CONTAINER_NOT_FOUND" });
|
||||
}
|
||||
log.error("Failed to get container details", error, {
|
||||
hostId: session.hostId,
|
||||
containerId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(error, "Failed to get container details"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/containers/{sessionId}/{containerId}/{action}:
|
||||
* post:
|
||||
* summary: Start, stop, restart, pause or unpause a container
|
||||
* description: Runs the container action on the session's host.
|
||||
* tags:
|
||||
* - Docker
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: containerId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: action
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [start, stop, restart, pause, unpause]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The action ran.
|
||||
* 400:
|
||||
* description: SSH session not found or not connected.
|
||||
* 404:
|
||||
* description: Container not found, or an unknown action.
|
||||
* 500:
|
||||
* description: The action failed.
|
||||
*/
|
||||
router.post(
|
||||
"/containers/:sessionId/:containerId/:action",
|
||||
async (req, res) => {
|
||||
const action = req.params.action as ContainerAction;
|
||||
if (!CONTAINER_ACTIONS.includes(action)) {
|
||||
return res.status(404).json({ error: "Unknown container action" });
|
||||
}
|
||||
const session = sessionOf(req, res);
|
||||
if (!session) return;
|
||||
const { containerId } = req.params;
|
||||
try {
|
||||
log.info("Docker container operation", {
|
||||
hostId: session.hostId,
|
||||
containerId,
|
||||
action,
|
||||
});
|
||||
await sessions.run(session, () =>
|
||||
sessions.exec(
|
||||
session,
|
||||
containerCommand(session.runtime, `${action} ${containerId}`),
|
||||
),
|
||||
);
|
||||
res.json({ success: true, message: `Container ${action} succeeded` });
|
||||
} catch (error) {
|
||||
if (notFound(error)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Container not found",
|
||||
code: "CONTAINER_NOT_FOUND",
|
||||
});
|
||||
}
|
||||
log.error(`Failed to ${action} container`, error, {
|
||||
hostId: session.hostId,
|
||||
containerId,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error, `Failed to ${action} container`),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/containers/{sessionId}/{containerId}/remove:
|
||||
* delete:
|
||||
* summary: Remove container
|
||||
* description: Removes a container, optionally by force.
|
||||
* tags:
|
||||
* - Docker
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: containerId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: force
|
||||
* schema:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Container removed.
|
||||
* 400:
|
||||
* description: Session not found, or the container is running and force was not set.
|
||||
* 404:
|
||||
* description: Container not found.
|
||||
* 500:
|
||||
* description: Failed to remove container.
|
||||
*/
|
||||
router.delete(
|
||||
"/containers/:sessionId/:containerId/remove",
|
||||
async (req, res) => {
|
||||
const session = sessionOf(req, res);
|
||||
if (!session) return;
|
||||
const { containerId } = req.params;
|
||||
const force = req.query.force === "true";
|
||||
try {
|
||||
log.info("Docker container operation", {
|
||||
hostId: session.hostId,
|
||||
containerId,
|
||||
action: "remove",
|
||||
});
|
||||
await sessions.run(session, () =>
|
||||
sessions.exec(
|
||||
session,
|
||||
containerCommand(
|
||||
session.runtime,
|
||||
`rm ${force ? "-f " : ""}${containerId}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
res.json({ success: true, message: "Container removed successfully" });
|
||||
} catch (error) {
|
||||
if (notFound(error)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Container not found",
|
||||
code: "CONTAINER_NOT_FOUND",
|
||||
});
|
||||
}
|
||||
if (
|
||||
getErrorMessage(error, "").includes(
|
||||
"cannot remove a running container",
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
"Cannot remove a running container. Stop it first or use force.",
|
||||
code: "CONTAINER_RUNNING",
|
||||
});
|
||||
}
|
||||
log.error("Failed to remove container", error, {
|
||||
hostId: session.hostId,
|
||||
containerId,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error, "Failed to remove container"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/containers/{sessionId}/{containerId}/logs:
|
||||
* get:
|
||||
* summary: Get container logs
|
||||
* description: The container's log output.
|
||||
* tags:
|
||||
* - Docker
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: containerId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: tail
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: timestamps
|
||||
* schema:
|
||||
* type: boolean
|
||||
* - in: query
|
||||
* name: since
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: until
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Container logs.
|
||||
* 400:
|
||||
* description: SSH session not found or not connected.
|
||||
* 404:
|
||||
* description: Container not found.
|
||||
* 500:
|
||||
* description: Failed to get container logs.
|
||||
*/
|
||||
router.get("/containers/:sessionId/:containerId/logs", async (req, res) => {
|
||||
const session = sessionOf(req, res);
|
||||
if (!session) return;
|
||||
const { containerId } = req.params;
|
||||
const tail = req.query.tail ? parseInt(String(req.query.tail), 10) : 100;
|
||||
const since = typeof req.query.since === "string" ? req.query.since : "";
|
||||
const until = typeof req.query.until === "string" ? req.query.until : "";
|
||||
|
||||
let command = containerCommand(session.runtime, `logs ${containerId}`);
|
||||
if (tail && tail > 0) command += ` --tail ${Math.floor(tail)}`;
|
||||
if (req.query.timestamps === "true") command += " --timestamps";
|
||||
if (since && DOCKER_TIMESTAMP_RE.test(since))
|
||||
command += ` --since ${since}`;
|
||||
if (until && DOCKER_TIMESTAMP_RE.test(until))
|
||||
command += ` --until ${until}`;
|
||||
command += " 2>&1";
|
||||
|
||||
try {
|
||||
const logs = await sessions.run(session, () =>
|
||||
sessions.exec(session, command),
|
||||
);
|
||||
res.json({ success: true, logs });
|
||||
} catch (error) {
|
||||
if (notFound(error)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Container not found",
|
||||
code: "CONTAINER_NOT_FOUND",
|
||||
});
|
||||
}
|
||||
log.error("Failed to get container logs", error, {
|
||||
hostId: session.hostId,
|
||||
containerId,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error, "Failed to get container logs"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/docker/containers/{sessionId}/{containerId}/stats:
|
||||
* get:
|
||||
* summary: Get container stats
|
||||
* description: One sample of the container's CPU, memory, network and block IO.
|
||||
* tags:
|
||||
* - Docker
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: containerId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Container stats.
|
||||
* 400:
|
||||
* description: SSH session not found or not connected.
|
||||
* 404:
|
||||
* description: Container not found.
|
||||
* 500:
|
||||
* description: Failed to get container stats.
|
||||
*/
|
||||
router.get("/containers/:sessionId/:containerId/stats", async (req, res) => {
|
||||
const session = sessionOf(req, res);
|
||||
if (!session) return;
|
||||
const { containerId } = req.params;
|
||||
const format = session.isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX;
|
||||
try {
|
||||
const output = await sessions.run(session, () =>
|
||||
sessions.exec(
|
||||
session,
|
||||
containerCommand(
|
||||
session.runtime,
|
||||
`stats ${containerId} --no-stream --format ${format}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const raw = JSON.parse(output.trim()) as Record<string, string>;
|
||||
const split = (value: string | undefined) => {
|
||||
const [first, second] = (value ?? "").split(" / ");
|
||||
return [first?.trim() || "0B", second?.trim() || "0B"];
|
||||
};
|
||||
const [memoryUsed, memoryLimit] = split(raw.memory);
|
||||
const [netInput, netOutput] = split(raw.netIO);
|
||||
const [blockRead, blockWrite] = split(raw.blockIO);
|
||||
res.json({
|
||||
cpu: raw.cpu,
|
||||
memoryUsed,
|
||||
memoryLimit,
|
||||
memoryPercent: raw.memoryPercent,
|
||||
netInput,
|
||||
netOutput,
|
||||
blockRead,
|
||||
blockWrite,
|
||||
pids: raw.pids,
|
||||
});
|
||||
} catch (error) {
|
||||
if (notFound(error)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Container not found",
|
||||
code: "CONTAINER_NOT_FOUND",
|
||||
});
|
||||
}
|
||||
log.error("Failed to get container stats", error, {
|
||||
hostId: session.hostId,
|
||||
containerId,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error, "Failed to get container stats"),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import { containerCommand } from "./container-runtime.js";
|
||||
import {
|
||||
PS_STATE_FORMAT,
|
||||
diffContainerStates,
|
||||
parseContainerStates,
|
||||
type ContainerState,
|
||||
type DockerEventName,
|
||||
} from "./container-state.js";
|
||||
import { readDockerHostSettings } from "./host-settings.js";
|
||||
import { execOnClient } from "./sessions.js";
|
||||
import { listFormat, parseContainerList } from "./routes.js";
|
||||
import {
|
||||
CONTAINER_ID_RE,
|
||||
getErrorMessage,
|
||||
type DockerLogger,
|
||||
} from "./helpers.js";
|
||||
|
||||
export const POOL = "docker";
|
||||
const TICK_MS = 15_000;
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const HOST_CACHE_MS = 5 * 60_000;
|
||||
|
||||
export interface DockerContainerSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
state: string;
|
||||
ports: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export type DockerContainerAction =
|
||||
"start" | "stop" | "restart" | "pause" | "unpause" | "remove";
|
||||
|
||||
const ACTIONS: DockerContainerAction[] = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"pause",
|
||||
"unpause",
|
||||
"remove",
|
||||
];
|
||||
|
||||
/** The "docker.containers" service, version 1. Runs as the caller's user. */
|
||||
export interface DockerServiceV1 {
|
||||
listContainers: (
|
||||
hostId: number,
|
||||
options?: { all?: boolean },
|
||||
) => Promise<DockerContainerSummary[]>;
|
||||
action: (
|
||||
hostId: number,
|
||||
container: string,
|
||||
action: DockerContainerAction,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DockerEvent {
|
||||
hostId: number;
|
||||
container: string;
|
||||
event: DockerEventName;
|
||||
}
|
||||
|
||||
/** The "docker.events" service, version 1. Runs as the caller's user. */
|
||||
export interface DockerEventsV1 {
|
||||
/**
|
||||
* Calls `listener` for container state changes on a host, polled about
|
||||
* once a minute as the subscribing user. Subscribing the same listener
|
||||
* again is a no-op, so a consumer can re-subscribe on a timer and pick up
|
||||
* a restarted docker plugin. Resolves to an unsubscribe function.
|
||||
*/
|
||||
subscribe: (
|
||||
hostId: number,
|
||||
listener: (event: DockerEvent) => void,
|
||||
) => Promise<() => void>;
|
||||
}
|
||||
|
||||
function actorOf(ctx: PluginContext): string {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) throw new Error("The docker service needs a calling user");
|
||||
return userId;
|
||||
}
|
||||
|
||||
export function registerServices(ctx: PluginContext, log: DockerLogger): void {
|
||||
const runOnHost = async <T>(
|
||||
hostId: number,
|
||||
command: (runtime: "docker" | "podman") => string,
|
||||
handle: (output: string) => T,
|
||||
): Promise<T> => {
|
||||
const { runtime } = await readDockerHostSettings(ctx, hostId);
|
||||
const output = await ctx.ssh.withConnection<string, Client>(
|
||||
hostId,
|
||||
{ pool: POOL, purpose: "docker", profile: "session" },
|
||||
(client) => execOnClient(client, command(runtime)),
|
||||
);
|
||||
return handle(output);
|
||||
};
|
||||
|
||||
ctx.services.provide<DockerServiceV1>("docker.containers", {
|
||||
listContainers: async (hostId, options) => {
|
||||
actorOf(ctx);
|
||||
const all = options?.all !== false;
|
||||
return runOnHost(
|
||||
hostId,
|
||||
(runtime) =>
|
||||
containerCommand(
|
||||
runtime,
|
||||
`ps ${all ? "-a " : ""}--format ${listFormat(false)}`,
|
||||
),
|
||||
(output) =>
|
||||
parseContainerList(output) as unknown as DockerContainerSummary[],
|
||||
);
|
||||
},
|
||||
action: async (hostId, container, action) => {
|
||||
actorOf(ctx);
|
||||
if (!CONTAINER_ID_RE.test(container)) {
|
||||
throw new Error("Invalid container name");
|
||||
}
|
||||
if (!ACTIONS.includes(action)) {
|
||||
throw new Error(`Unknown container action: ${action}`);
|
||||
}
|
||||
const args =
|
||||
action === "remove" ? `rm ${container}` : `${action} ${container}`;
|
||||
await runOnHost(
|
||||
hostId,
|
||||
(runtime) => containerCommand(runtime, args),
|
||||
() => undefined,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
interface Watch {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
listeners: Set<(event: DockerEvent) => void>;
|
||||
snapshot?: Map<string, ContainerState>;
|
||||
host?: { value: PluginSshHost; at: number };
|
||||
lastPolledAt: number;
|
||||
failing: boolean;
|
||||
}
|
||||
const watches = new Map<string, Watch>();
|
||||
ctx.disposables.add(() => watches.clear());
|
||||
|
||||
const hostFor = async (watch: Watch): Promise<PluginSshHost | null> => {
|
||||
if (watch.host && Date.now() - watch.host.at < HOST_CACHE_MS) {
|
||||
return watch.host.value;
|
||||
}
|
||||
const host = await ctx.asUser(watch.userId, () =>
|
||||
ctx.ssh.resolveHost(watch.hostId),
|
||||
);
|
||||
watch.host = host ? { value: host, at: Date.now() } : undefined;
|
||||
return host;
|
||||
};
|
||||
|
||||
const pollOne = async (watch: Watch) => {
|
||||
try {
|
||||
const host = await hostFor(watch);
|
||||
if (!host) throw new Error("Host not found");
|
||||
const { runtime } = await readDockerHostSettings(ctx, watch.hostId);
|
||||
const output = await ctx.ssh.withConnection<string, Client>(
|
||||
host,
|
||||
{ pool: POOL, purpose: "docker", profile: "session" },
|
||||
(client) =>
|
||||
execOnClient(
|
||||
client,
|
||||
containerCommand(runtime, `ps -a --format ${PS_STATE_FORMAT}`),
|
||||
),
|
||||
);
|
||||
const current = parseContainerStates(output);
|
||||
const previous = watch.snapshot;
|
||||
watch.snapshot = current;
|
||||
watch.failing = false;
|
||||
if (!previous) return;
|
||||
for (const { container, event } of diffContainerStates(
|
||||
previous,
|
||||
current,
|
||||
)) {
|
||||
for (const listener of [...watch.listeners]) {
|
||||
try {
|
||||
listener({ hostId: watch.hostId, container, event });
|
||||
} catch (error) {
|
||||
log.warn("Docker event listener failed", {
|
||||
hostId: watch.hostId,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Docker missing, the host down or the owner's data locked. A later
|
||||
// success is a first observation again rather than a diff.
|
||||
watch.snapshot = undefined;
|
||||
watch.host = undefined;
|
||||
if (!watch.failing) {
|
||||
log.warn("Docker event poll failed", {
|
||||
hostId: watch.hostId,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
watch.failing = true;
|
||||
}
|
||||
};
|
||||
|
||||
ctx.schedule.every(TICK_MS, async () => {
|
||||
const now = Date.now();
|
||||
for (const watch of [...watches.values()]) {
|
||||
if (now - watch.lastPolledAt < POLL_INTERVAL_MS) continue;
|
||||
watch.lastPolledAt = now;
|
||||
await pollOne(watch);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.events.on("host.updated", (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (!hostId) return;
|
||||
ctx.ssh.dropPooled(POOL, hostId);
|
||||
for (const watch of watches.values()) {
|
||||
if (watch.hostId === hostId) watch.host = undefined;
|
||||
}
|
||||
});
|
||||
ctx.events.on("host.deleted", (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (!hostId) return;
|
||||
for (const [key, watch] of watches) {
|
||||
if (watch.hostId === hostId) watches.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.services.provide<DockerEventsV1>("docker.events", {
|
||||
subscribe: async (hostId, listener) => {
|
||||
const userId = actorOf(ctx);
|
||||
const key = `${userId}:${hostId}`;
|
||||
let watch = watches.get(key);
|
||||
if (!watch) {
|
||||
watch = {
|
||||
userId,
|
||||
hostId,
|
||||
listeners: new Set(),
|
||||
lastPolledAt: 0,
|
||||
failing: false,
|
||||
};
|
||||
watches.set(key, watch);
|
||||
}
|
||||
watch.listeners.add(listener);
|
||||
const current = watch;
|
||||
return () => {
|
||||
current.listeners.delete(listener);
|
||||
if (current.listeners.size === 0 && watches.get(key) === current) {
|
||||
watches.delete(key);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
|
||||
import type { ContainerRuntime } from "./container-runtime.js";
|
||||
import type { DockerLogger } from "./helpers.js";
|
||||
|
||||
const SESSION_IDLE_MS = 60 * 60 * 1000;
|
||||
const PENDING_SWEEP_MS = 60 * 1000;
|
||||
|
||||
/** A live SSH connection behind the Docker panel, owned by one user. */
|
||||
export interface DockerSession {
|
||||
id: string;
|
||||
client: Client;
|
||||
dispose: () => void;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
runtime: ContainerRuntime;
|
||||
isWindows: boolean;
|
||||
lastActive: number;
|
||||
activeOperations: number;
|
||||
cancelIdle?: () => void;
|
||||
}
|
||||
|
||||
/** A connect still waiting on a person (TOTP, a browser sign-in). */
|
||||
export interface PendingEntry {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
expiresAt: number;
|
||||
/** Gives up: answers the open prompt with nothing. */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export interface DockerSessions {
|
||||
add: (session: DockerSession) => void;
|
||||
/** The session when `userId` owns it. */
|
||||
get: (id: string, userId: string) => DockerSession | undefined;
|
||||
close: (id: string) => void;
|
||||
touch: (session: DockerSession) => void;
|
||||
/** Runs `fn` with the session marked busy, so idle cleanup waits. */
|
||||
run: <T>(session: DockerSession, fn: () => Promise<T>) => Promise<T>;
|
||||
exec: (session: DockerSession, command: string) => Promise<string>;
|
||||
setPending: (id: string, entry: PendingEntry) => void;
|
||||
getPending: (id: string) => PendingEntry | undefined;
|
||||
clearPending: (id: string) => void;
|
||||
closeAll: () => void;
|
||||
}
|
||||
|
||||
/** Runs one command and resolves stdout, rejecting on a non-zero exit. */
|
||||
export function execOnClient(client: Client, command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.exec(command, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0 && code !== null && code !== undefined) {
|
||||
reject(new Error(stderr || `Command exited with code ${code}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
stream.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
stream.on("error", (streamErr: Error) => reject(streamErr));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createDockerSessions(
|
||||
schedule: PluginSchedule,
|
||||
log: DockerLogger,
|
||||
): DockerSessions {
|
||||
const sessions = new Map<string, DockerSession>();
|
||||
const pending = new Map<string, PendingEntry>();
|
||||
|
||||
const end = (session: DockerSession) => {
|
||||
session.cancelIdle?.();
|
||||
sessions.delete(session.id);
|
||||
session.dispose();
|
||||
};
|
||||
|
||||
const scheduleIdle = (session: DockerSession) => {
|
||||
session.cancelIdle?.();
|
||||
session.cancelIdle = schedule.after(SESSION_IDLE_MS, () => {
|
||||
if (sessions.get(session.id) !== session) return;
|
||||
if (session.activeOperations > 0) {
|
||||
scheduleIdle(session);
|
||||
return;
|
||||
}
|
||||
end(session);
|
||||
});
|
||||
};
|
||||
|
||||
schedule.every(PENDING_SWEEP_MS, () => {
|
||||
const now = Date.now();
|
||||
for (const [id, entry] of pending) {
|
||||
if (now < entry.expiresAt) continue;
|
||||
pending.delete(id);
|
||||
entry.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
add: (session) => {
|
||||
const existing = sessions.get(session.id);
|
||||
if (existing) end(existing);
|
||||
sessions.set(session.id, session);
|
||||
session.client.once("close", () => {
|
||||
if (sessions.get(session.id) === session) {
|
||||
session.cancelIdle?.();
|
||||
sessions.delete(session.id);
|
||||
}
|
||||
});
|
||||
scheduleIdle(session);
|
||||
},
|
||||
|
||||
get: (id, userId) => {
|
||||
const session = sessions.get(id);
|
||||
return session && session.userId === userId ? session : undefined;
|
||||
},
|
||||
|
||||
close: (id) => {
|
||||
const session = sessions.get(id);
|
||||
if (session) end(session);
|
||||
const entry = pending.get(id);
|
||||
if (entry) {
|
||||
pending.delete(id);
|
||||
entry.cancel();
|
||||
}
|
||||
},
|
||||
|
||||
touch: (session) => {
|
||||
session.lastActive = Date.now();
|
||||
scheduleIdle(session);
|
||||
},
|
||||
|
||||
run: async (session, fn) => {
|
||||
session.lastActive = Date.now();
|
||||
session.activeOperations++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
session.activeOperations--;
|
||||
}
|
||||
},
|
||||
|
||||
exec: async (session, command) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
return await execOnClient(session.client, command);
|
||||
} catch (error) {
|
||||
log.warn("Docker command failed", {
|
||||
hostId: session.hostId,
|
||||
command: command.split(" ")[2],
|
||||
durationMs: Date.now() - started,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setPending: (id, entry) => {
|
||||
const existing = pending.get(id);
|
||||
if (existing) existing.cancel();
|
||||
pending.set(id, entry);
|
||||
},
|
||||
|
||||
getPending: (id) => pending.get(id),
|
||||
|
||||
clearPending: (id) => {
|
||||
pending.delete(id);
|
||||
},
|
||||
|
||||
closeAll: () => {
|
||||
for (const session of [...sessions.values()]) end(session);
|
||||
for (const entry of pending.values()) entry.cancel();
|
||||
pending.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ConnectionScreen, FullScreenAppWrapper } from "@termix/plugin-sdk/ui";
|
||||
import React from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { DockerManager } from "./DockerManager.tsx";
|
||||
import { toDockerHost, type DockerHostSource } from "./types";
|
||||
|
||||
interface DockerAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DockerManager
|
||||
host={toDockerHost(hostConfig as unknown as DockerHostSource)}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerApp;
|
||||
@@ -0,0 +1,714 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Box,
|
||||
ExternalLink,
|
||||
Grid3X3,
|
||||
List as ListIcon,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useConnectionRetry,
|
||||
useHost,
|
||||
useTranslation,
|
||||
logActivity,
|
||||
usePluginUiPreferences,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Card,
|
||||
ConnectionLogProvider,
|
||||
ConnectionScreen,
|
||||
Input,
|
||||
SSHAuthDialog,
|
||||
Select2,
|
||||
Separator,
|
||||
TOTPDialog,
|
||||
BrowserSignInDialog,
|
||||
useAdaptivePolling,
|
||||
useConnectionLog,
|
||||
useTabsSafe,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { DockerApiError, useDockerApi, type ConnectResult } from "./docker-api";
|
||||
import {
|
||||
dockerEnabled,
|
||||
hostTitle,
|
||||
toDockerHost,
|
||||
type DockerContainer,
|
||||
type DockerHost,
|
||||
type DockerValidation,
|
||||
} from "./types";
|
||||
import { ContainerList } from "./components/ContainerList.tsx";
|
||||
import { ContainerTable } from "./components/ContainerTable.tsx";
|
||||
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
||||
|
||||
interface DockerManagerProps {
|
||||
host?: DockerHost;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface PromptState {
|
||||
kind: "totp" | "browser";
|
||||
sessionId: string;
|
||||
prompt?: string;
|
||||
isPassword?: boolean;
|
||||
url?: string;
|
||||
code?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function newSessionId(): string {
|
||||
return `docker-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
function DockerManagerInner({
|
||||
host,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
onClose,
|
||||
}: DockerManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const { addLog, setLogs, clearLogs } = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabsSafe();
|
||||
const { values: dockerPrefs, set: setDockerPref } = usePluginUiPreferences<{
|
||||
viewMode: "list" | "detail";
|
||||
containerLayout: "card" | "table";
|
||||
}>();
|
||||
|
||||
// The shell's host list keeps the host current after an edit; a
|
||||
// standalone window has no shell, so it keeps the host it was given.
|
||||
const liveRecord = useHost(host?.id);
|
||||
const currentHost = React.useMemo(
|
||||
() => (liveRecord ? toDockerHost(liveRecord) : host),
|
||||
[liveRecord, host],
|
||||
);
|
||||
const enabled = dockerEnabled(currentHost);
|
||||
|
||||
const [sessionId, setSessionId] = React.useState<string | null>(null);
|
||||
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
|
||||
const containersSignatureRef = React.useRef("");
|
||||
const [selectedContainer, setSelectedContainer] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [detailInitialTab, setDetailInitialTab] = React.useState<
|
||||
"logs" | "stats" | "console"
|
||||
>("logs");
|
||||
const [containerLayout, setContainerLayout] = React.useState<
|
||||
"card" | "table"
|
||||
>(dockerPrefs.containerLayout);
|
||||
|
||||
const handleSetContainerLayout = React.useCallback(
|
||||
(layout: "card" | "table") => {
|
||||
setContainerLayout(layout);
|
||||
setDockerPref("containerLayout", layout);
|
||||
},
|
||||
[setDockerPref],
|
||||
);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
const [dockerValidation, setDockerValidation] =
|
||||
React.useState<DockerValidation | null>(null);
|
||||
const [isValidating, setIsValidating] = React.useState(false);
|
||||
// Initial view follows the interface preset; switching it afterwards is a
|
||||
// per-session choice.
|
||||
const [viewMode, setViewMode] = React.useState<"list" | "detail">(
|
||||
dockerPrefs.viewMode,
|
||||
);
|
||||
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
|
||||
const [hasLoadedContainersOnce, setHasLoadedContainersOnce] =
|
||||
React.useState(false);
|
||||
const [prompt, setPrompt] = React.useState<PromptState | null>(null);
|
||||
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
|
||||
const [authReason, setAuthReason] = React.useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const [retryCount, setRetryCount] = React.useState(0);
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
|
||||
const logDockerActivity = () => {
|
||||
if (!currentHost?.id || activityLoggedRef.current) return;
|
||||
activityLoggedRef.current = true;
|
||||
logActivity("docker", currentHost.id, hostTitle(currentHost)).catch(() => {
|
||||
activityLoggedRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setContainers([]);
|
||||
setSelectedContainer(null);
|
||||
setSessionId(null);
|
||||
setDockerValidation(null);
|
||||
setViewMode("list");
|
||||
}, [host?.id]);
|
||||
|
||||
const retryRef = React.useRef<ReturnType<typeof useConnectionRetry> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const validate = async (sid: string, clearAfter: boolean) => {
|
||||
setSessionId(sid);
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const validation = await docker.validate(sid);
|
||||
setDockerValidation(validation);
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
retryRef.current?.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
if (clearAfter) setTimeout(() => clearLogs(), 1000);
|
||||
retryRef.current?.markConnected();
|
||||
}
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Acts on one step of a connect, whichever request it answered. */
|
||||
const applyConnectResult = async (
|
||||
sid: string,
|
||||
result: ConnectResult,
|
||||
clearAfter: boolean,
|
||||
) => {
|
||||
if (result.connectionLogs) setLogs(result.connectionLogs);
|
||||
|
||||
if (result.requires_browser_sign_in) {
|
||||
setPrompt({
|
||||
kind: "browser",
|
||||
sessionId: sid,
|
||||
url: result.url || "",
|
||||
code: result.code || "",
|
||||
label: result.label || "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.requires_totp) {
|
||||
if (result.retry) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.totpVerificationFailed"),
|
||||
});
|
||||
}
|
||||
setPrompt({
|
||||
kind: "totp",
|
||||
sessionId: sid,
|
||||
prompt: result.prompt || t("docker.verificationCodePrompt"),
|
||||
isPassword: result.isPassword,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.status === "auth_required") {
|
||||
setAuthReason(
|
||||
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
|
||||
);
|
||||
setShowAuthDialog(true);
|
||||
return;
|
||||
}
|
||||
setPrompt(null);
|
||||
await validate(sid, clearAfter);
|
||||
};
|
||||
|
||||
const reportFailure = (error: unknown, fallback: string) => {
|
||||
if (error instanceof DockerApiError && error.connectionLogs?.length) {
|
||||
setLogs(error.connectionLogs);
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error instanceof Error ? error.message : fallback,
|
||||
});
|
||||
}
|
||||
retryRef.current?.markFailed();
|
||||
};
|
||||
|
||||
const connect = async (credentials?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
}) => {
|
||||
if (!currentHost?.id) return;
|
||||
const sid = newSessionId();
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const result = await docker.connect(sid, currentHost.id, credentials);
|
||||
await applyConnectResult(sid, result, !credentials);
|
||||
} catch (error) {
|
||||
reportFailure(error, t("docker.connectionFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initializingRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentHost?.id || !enabled) return;
|
||||
if (initializingRef.current || sessionId) return;
|
||||
initializingRef.current = true;
|
||||
clearLogs();
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
initializingRef.current = false;
|
||||
};
|
||||
}, [currentHost?.id, enabled, retryCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
return () => {
|
||||
docker.disconnect(sessionId).catch(() => {});
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId || !isVisible) return;
|
||||
const keepalive = setInterval(
|
||||
() => {
|
||||
docker.keepalive(sessionId).catch(() => {});
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
return () => clearInterval(keepalive);
|
||||
}, [sessionId, isVisible]);
|
||||
|
||||
const refreshContainers = React.useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
setContainers(await docker.listContainers(sessionId, true));
|
||||
} catch {
|
||||
// the next poll tries again
|
||||
}
|
||||
}, [sessionId, docker]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHasLoadedContainersOnce(false);
|
||||
}, [sessionId]);
|
||||
|
||||
useAdaptivePolling(
|
||||
async () => {
|
||||
if (!sessionId) return;
|
||||
setIsLoadingContainers((loading) =>
|
||||
hasLoadedContainersOnce ? loading : true,
|
||||
);
|
||||
const data = await docker.listContainers(sessionId, true);
|
||||
const signature = JSON.stringify(data);
|
||||
const changed = signature !== containersSignatureRef.current;
|
||||
containersSignatureRef.current = signature;
|
||||
setContainers(data);
|
||||
setIsLoadingContainers(false);
|
||||
setHasLoadedContainersOnce(true);
|
||||
return changed;
|
||||
},
|
||||
{
|
||||
minIntervalMs: 5000,
|
||||
maxIntervalMs: 30000,
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
!!sessionId && isVisible && !!dockerValidation?.available,
|
||||
{
|
||||
onError: () => {
|
||||
setIsLoadingContainers(false);
|
||||
setHasLoadedContainersOnce(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleBack = React.useCallback(() => {
|
||||
setViewMode("list");
|
||||
setSelectedContainer(null);
|
||||
}, []);
|
||||
|
||||
const closeTab = () => {
|
||||
if (currentTab !== null) removeTab(currentTab);
|
||||
};
|
||||
|
||||
const handleTotpSubmit = async (code: string) => {
|
||||
if (!prompt || prompt.kind !== "totp" || !code) return;
|
||||
const sid = prompt.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
await applyConnectResult(sid, await docker.answerTotp(sid, code), false);
|
||||
} catch (error) {
|
||||
reportFailure(error, t("docker.totpVerificationFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowserSignInContinue = async () => {
|
||||
if (!prompt || prompt.kind !== "browser") return;
|
||||
const sid = prompt.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
await applyConnectResult(
|
||||
sid,
|
||||
await docker.continueBrowserSignIn(sid),
|
||||
false,
|
||||
);
|
||||
} catch (error) {
|
||||
reportFailure(error, t("docker.browserSignInFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptCancel = () => {
|
||||
const sid = prompt?.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(false);
|
||||
if (sid) docker.disconnect(sid).catch(() => {});
|
||||
closeTab();
|
||||
};
|
||||
|
||||
const handleBrowserSignInOpenUrl = () => {
|
||||
if (prompt?.url) window.open(prompt.url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleAuthSubmit = async (credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}) => {
|
||||
setShowAuthDialog(false);
|
||||
await connect({
|
||||
userProvidedPassword: credentials.password,
|
||||
userProvidedSshKey: credentials.sshKey,
|
||||
userProvidedKeyPassword: credentials.keyPassword,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuthCancel = () => {
|
||||
setShowAuthDialog(false);
|
||||
setIsConnecting(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
initializingRef.current = false;
|
||||
setSessionId(null);
|
||||
setDockerValidation(null);
|
||||
clearLogs();
|
||||
setRetryCount((c) => c + 1);
|
||||
};
|
||||
|
||||
const dockerConnectRetry = useConnectionRetry({
|
||||
connect: () => {
|
||||
handleRetry();
|
||||
},
|
||||
enabled: enabled && !prompt && !showAuthDialog,
|
||||
autoStart: false,
|
||||
});
|
||||
retryRef.current = dockerConnectRetry;
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-foreground overflow-hidden bg-transparent"
|
||||
: "bg-canvas text-foreground border-2 border-edge overflow-hidden";
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
||||
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
|
||||
<Box className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("docker.manager")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{t("docker.notEnabled")}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
<TOTPDialog
|
||||
isOpen={prompt?.kind === "totp"}
|
||||
prompt={prompt?.prompt ?? ""}
|
||||
mode={prompt?.isPassword ? "password" : "totp"}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handlePromptCancel}
|
||||
/>
|
||||
<BrowserSignInDialog
|
||||
isOpen={prompt?.kind === "browser"}
|
||||
label={prompt?.label ?? ""}
|
||||
url={prompt?.url ?? ""}
|
||||
code={prompt?.code ?? ""}
|
||||
onContinue={handleBrowserSignInContinue}
|
||||
onCancel={handlePromptCancel}
|
||||
onOpenUrl={handleBrowserSignInOpenUrl}
|
||||
/>
|
||||
{currentHost && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authReason}
|
||||
onSubmit={handleAuthSubmit}
|
||||
onCancel={handleAuthCancel}
|
||||
hostInfo={{
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username ?? "",
|
||||
name: currentHost.name,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isConnecting || isValidating) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={
|
||||
isValidating ? t("docker.validating") : t("docker.connecting")
|
||||
}
|
||||
attempt={dockerConnectRetry.attempt}
|
||||
maxAttempts={dockerConnectRetry.maxAttempts}
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
/>
|
||||
{dialogs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dockerValidation && !dockerValidation.available) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={t("docker.connectionFailed")}
|
||||
attempt={dockerConnectRetry.attempt}
|
||||
maxAttempts={dockerConnectRetry.maxAttempts}
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
retryLabel={t("terminal.retry")}
|
||||
logPosition="top"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{viewMode === "detail" &&
|
||||
sessionId &&
|
||||
selectedContainer &&
|
||||
currentHost ? (
|
||||
<ContainerDetail
|
||||
sessionId={sessionId}
|
||||
containerId={selectedContainer}
|
||||
containers={containers}
|
||||
hostConfig={currentHost}
|
||||
onBack={handleBack}
|
||||
initialTab={detailInitialTab}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
||||
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
|
||||
<Box className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full bg-accent-brand" />
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{dockerValidation?.version
|
||||
? t("docker.version", {
|
||||
runtime:
|
||||
dockerValidation.runtime === "podman"
|
||||
? "Podman"
|
||||
: "Docker",
|
||||
version: dockerValidation.version,
|
||||
})
|
||||
: t("docker.manager")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("docker.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
<Select2
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-8 px-2 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="all">{t("docker.allStatuses")}</option>
|
||||
<option value="running">{t("docker.stateRunning")}</option>
|
||||
<option value="paused">{t("docker.statePaused")}</option>
|
||||
<option value="exited">{t("docker.stateExited")}</option>
|
||||
<option value="restarting">
|
||||
{t("docker.stateRestarting")}
|
||||
</option>
|
||||
</Select2>
|
||||
<Separator orientation="vertical" className="h-8 mx-1" />
|
||||
<div className="flex items-center border border-border overflow-hidden">
|
||||
<Button
|
||||
variant={containerLayout === "card" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => handleSetContainerLayout("card")}
|
||||
className={`size-8 rounded-none ${containerLayout === "card" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
|
||||
title={t("docker.cardView")}
|
||||
>
|
||||
<Grid3X3 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
containerLayout === "table" ? "secondary" : "ghost"
|
||||
}
|
||||
size="icon"
|
||||
onClick={() => handleSetContainerLayout("table")}
|
||||
className={`size-8 rounded-none border-l border-border ${containerLayout === "table" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
|
||||
title={t("docker.listView")}
|
||||
>
|
||||
<ListIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-8 mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={refreshContainers}
|
||||
disabled={isLoadingContainers}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 text-accent-brand ${isLoadingContainers ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
<a
|
||||
href="https://docs.termix.site/features/networking/docker"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-center size-9 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("hosts.docsLink")}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{sessionId ? (
|
||||
!hasLoadedContainersOnce ? (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
|
||||
<RefreshCw className="size-8 animate-spin mb-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
{t("docker.loadingContainers")}
|
||||
</span>
|
||||
</div>
|
||||
) : containerLayout === "table" ? (
|
||||
<ContainerTable
|
||||
containers={containers}
|
||||
sessionId={sessionId}
|
||||
onSelectContainer={(id, tab) => {
|
||||
setSelectedContainer(id);
|
||||
setDetailInitialTab(tab ?? "logs");
|
||||
setViewMode("detail");
|
||||
}}
|
||||
selectedContainerId={selectedContainer}
|
||||
onRefresh={refreshContainers}
|
||||
search={search}
|
||||
statusFilter={statusFilter}
|
||||
/>
|
||||
) : (
|
||||
<ContainerList
|
||||
containers={containers}
|
||||
sessionId={sessionId}
|
||||
onSelectContainer={(id) => {
|
||||
setSelectedContainer(id);
|
||||
setDetailInitialTab("logs");
|
||||
setViewMode("detail");
|
||||
}}
|
||||
selectedContainerId={selectedContainer}
|
||||
onRefresh={refreshContainers}
|
||||
search={search}
|
||||
statusFilter={statusFilter}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{dialogs}
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={t("docker.connecting")}
|
||||
attempt={dockerConnectRetry.attempt}
|
||||
maxAttempts={dockerConnectRetry.maxAttempts}
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
retryLabel={t("terminal.retry")}
|
||||
logPosition="top"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DockerManager(props: DockerManagerProps): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<DockerManagerInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Box } from "lucide-react";
|
||||
import { useHost, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
import { DockerWidgetEditForm } from "./DockerWidgetEditForm";
|
||||
import { DockerManager } from "./DockerManager";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type DockerWidgetConfig,
|
||||
type WidgetComponentProps,
|
||||
type WidgetDefinition,
|
||||
} from "./homepage";
|
||||
import { dockerEnabled, hostTitle, toDockerHost } from "./types";
|
||||
|
||||
function DockerWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<DockerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const record = useHost(config.hostId || undefined);
|
||||
|
||||
if (!config.hostId || !record || !dockerEnabled(record)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<Box size={20} />
|
||||
<span className="text-xs">{t("common.noHostConfigured")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const host = toDockerHost(record);
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Box size={11} />} />
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DockerManager
|
||||
host={host}
|
||||
title={hostTitle(host)}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Registered by the plugin while it runs. */
|
||||
export const dockerWidget: WidgetDefinition<DockerWidgetConfig> = {
|
||||
id: "docker_widget",
|
||||
name: "Docker Manager",
|
||||
description: "Embedded Docker container manager for a configured host",
|
||||
category: "system",
|
||||
icon: <Box size={14} />,
|
||||
defaultConfig: { hostId: 0 },
|
||||
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
|
||||
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
components: { view: DockerWidget, editForm: DockerWidgetEditForm },
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerWidgetConfig, WidgetEditFormProps } from "./homepage";
|
||||
import { dockerEnabled } from "./types";
|
||||
|
||||
export function DockerWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<DockerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts } = useHosts();
|
||||
const options = hosts.filter(
|
||||
(host) => host.enableSsh !== false && dockerEnabled(host),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("common.host")}
|
||||
</label>
|
||||
<Select2
|
||||
value={config.hostId || ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, hostId: Number(e.target.value) })
|
||||
}
|
||||
className="h-8 text-xs border border-border bg-background px-2"
|
||||
>
|
||||
<option value="">{t("common.selectHost")}</option>
|
||||
{options.map((host) => (
|
||||
<option key={host.id} value={host.id}>
|
||||
{host.name || host.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
ConnectionLogProvider,
|
||||
ConnectionScreen,
|
||||
RobustClipboardProvider,
|
||||
Select2,
|
||||
copyToClipboard,
|
||||
isElectron,
|
||||
pluginWsUrl,
|
||||
readFromClipboard,
|
||||
resolveConnectionOrigin,
|
||||
useAppTheme as useTheme,
|
||||
useConnectionLog,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { DockerHost } from "../types";
|
||||
import { useConsoleLook, type ConsoleLook } from "./console-look";
|
||||
import React from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useTranslation,
|
||||
useConnectionRetry,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
function applyConsoleLook(
|
||||
terminal: NonNullable<ReturnType<typeof useXTerm>["instance"]>,
|
||||
look: ConsoleLook,
|
||||
): void {
|
||||
terminal.options.cursorBlink = look.cursorBlink;
|
||||
terminal.options.cursorStyle = look.cursorStyle;
|
||||
terminal.options.fontSize = look.fontSize;
|
||||
terminal.options.fontFamily = look.fontFamily;
|
||||
terminal.options.scrollback = look.scrollback;
|
||||
terminal.options.letterSpacing = look.letterSpacing;
|
||||
terminal.options.lineHeight = look.lineHeight;
|
||||
terminal.options.theme = { ...look.colors };
|
||||
}
|
||||
|
||||
interface ConsoleTerminalProps {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerState: string;
|
||||
hostConfig: DockerHost;
|
||||
}
|
||||
|
||||
export function ConsoleTerminal(
|
||||
props: ConsoleTerminalProps,
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<ConsoleTerminalInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsoleTerminalInner({
|
||||
containerId,
|
||||
containerName,
|
||||
containerState,
|
||||
hostConfig,
|
||||
}: ConsoleTerminalProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
|
||||
// The SSH terminal's look on this host, or plain colors while it is off.
|
||||
const look = useConsoleLook(
|
||||
hostConfig as unknown as Parameters<typeof useConsoleLook>[0],
|
||||
appTheme,
|
||||
);
|
||||
const themeColors = look.colors;
|
||||
const lookRef = React.useRef<ConsoleLook>(look);
|
||||
lookRef.current = look;
|
||||
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
const [selectedShell, setSelectedShell] = React.useState<string>("bash");
|
||||
const wsRef = React.useRef<WebSocket | null>(null);
|
||||
const fitAddonRef = React.useRef<FitAddon | null>(null);
|
||||
const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!terminal) return;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const clipboardProvider = new RobustClipboardProvider();
|
||||
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
applyConsoleLook(terminal, lookRef.current);
|
||||
|
||||
const readTextFromClipboard = async (): Promise<string> => {
|
||||
return readFromClipboard();
|
||||
};
|
||||
|
||||
const writeTextToClipboard = async (text: string): Promise<void> => {
|
||||
await copyToClipboard(text);
|
||||
};
|
||||
|
||||
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
|
||||
if (e.type !== "keydown") return true;
|
||||
|
||||
if (
|
||||
((e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) ||
|
||||
(e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey)) &&
|
||||
e.key.toLowerCase() === "v"
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard()
|
||||
.then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("terminal.clipboardReadFailed"));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key.toLowerCase() === "c" &&
|
||||
terminal.hasSelection()
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
writeTextToClipboard(selection).catch(() => {
|
||||
toast.error(t("terminal.clipboardWriteFailed"));
|
||||
});
|
||||
terminal.clearSelection();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "Insert" &&
|
||||
terminal.hasSelection()
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
writeTextToClipboard(selection).catch(() => {
|
||||
toast.error(t("terminal.clipboardWriteFailed"));
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "Insert"
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard()
|
||||
.then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("terminal.clipboardReadFailed"));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 100);
|
||||
|
||||
const resizeHandler = () => {
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
const { rows, cols } = terminal;
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
data: { rows, cols },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", resizeHandler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeHandler);
|
||||
clipboardProvider.dispose();
|
||||
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch {
|
||||
// Best-effort disconnect during cleanup.
|
||||
}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
terminal.dispose();
|
||||
};
|
||||
}, [terminal, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!terminal) return;
|
||||
applyConsoleLook(terminal, look);
|
||||
fitAddonRef.current?.fit();
|
||||
}, [terminal, look]);
|
||||
|
||||
const disconnect = React.useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch {
|
||||
// Best-effort disconnect.
|
||||
}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
retryRef.current.reset();
|
||||
clearLogs();
|
||||
if (terminal) {
|
||||
try {
|
||||
terminal.clear();
|
||||
} catch {
|
||||
// Terminal clear can fail after disposal.
|
||||
}
|
||||
}
|
||||
}, [terminal, clearLogs]);
|
||||
|
||||
const connect = React.useCallback(async () => {
|
||||
if (!terminal || containerState !== "running") {
|
||||
toast.error(t("docker.containerMustBeRunning"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "docker_connecting",
|
||||
message: t("docker.connectingTo", { containerName }),
|
||||
});
|
||||
|
||||
try {
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
|
||||
const isElectronApp = isElectron();
|
||||
|
||||
const origin = isElectronApp
|
||||
? await resolveConnectionOrigin({
|
||||
connectionOrigin: hostConfig.connectionOrigin,
|
||||
})
|
||||
: "local";
|
||||
|
||||
const resolvedUrl = await pluginWsUrl("docker", "/console", { origin });
|
||||
if (!resolvedUrl) {
|
||||
setIsConnecting(false);
|
||||
toast.error(t("errors.remoteServerRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(resolvedUrl.url, resolvedUrl.protocols);
|
||||
|
||||
ws.onopen = () => {
|
||||
const cols = terminal.cols || 80;
|
||||
const rows = terminal.rows || 24;
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connect",
|
||||
data: {
|
||||
hostConfig: {
|
||||
id: hostConfig.id,
|
||||
syncId: hostConfig.syncId ?? null,
|
||||
ip: hostConfig.ip,
|
||||
},
|
||||
containerId,
|
||||
shell: selectedShell,
|
||||
cols,
|
||||
rows,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case "output":
|
||||
terminal.write(msg.data);
|
||||
break;
|
||||
|
||||
case "connected":
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
retryRef.current.markConnected();
|
||||
|
||||
if (msg.data?.shellChanged) {
|
||||
toast.warning(
|
||||
`Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`,
|
||||
);
|
||||
} else {
|
||||
toast.success(t("docker.connectedTo", { containerName }));
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
data: { rows: terminal.rows, cols: terminal.cols },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
|
||||
case "disconnected":
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
retryRef.current.reset();
|
||||
terminal.write(
|
||||
`\r\n\x1b[1;33m${msg.message || t("docker.disconnected")}\x1b[0m\r\n`,
|
||||
);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case "error":
|
||||
setIsConnecting(false);
|
||||
toast.error(msg.message || t("docker.consoleError"));
|
||||
terminal.write(
|
||||
`\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`,
|
||||
);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "error",
|
||||
message: msg.message || t("docker.consoleError"),
|
||||
});
|
||||
retryRef.current.markFailed();
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
setIsConnecting(false);
|
||||
setIsConnected(false);
|
||||
toast.error(t("docker.failedToConnect"));
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "error",
|
||||
message: t("docker.failedToConnect"),
|
||||
});
|
||||
retryRef.current.markFailed();
|
||||
};
|
||||
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
}
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
ws.onclose = () => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
if (wsRef.current === ws) {
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
terminal.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
setIsConnecting(false);
|
||||
const message = `Failed to connect: ${getErrorMessage(error)}`;
|
||||
toast.error(message);
|
||||
addLog({ type: "error", stage: "error", message });
|
||||
retryRef.current.markFailed();
|
||||
}
|
||||
}, [
|
||||
terminal,
|
||||
containerState,
|
||||
hostConfig,
|
||||
containerId,
|
||||
selectedShell,
|
||||
containerName,
|
||||
t,
|
||||
addLog,
|
||||
]);
|
||||
|
||||
const retry = useConnectionRetry({
|
||||
connect,
|
||||
autoStart: false,
|
||||
});
|
||||
const retryRef = React.useRef(retry);
|
||||
retryRef.current = retry;
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch {
|
||||
// Best-effort disconnect during cleanup.
|
||||
}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (containerState !== "running") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t("docker.containerNotRunning")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("docker.startContainerToAccess")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<Card className="py-3">
|
||||
<CardContent className="px-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<TerminalIcon className="h-5 w-5" />
|
||||
<span className="text-base font-medium">
|
||||
{t("docker.console")}
|
||||
</span>
|
||||
</div>
|
||||
<Select2
|
||||
value={selectedShell}
|
||||
onChange={(event) => setSelectedShell(event.target.value)}
|
||||
disabled={isConnected}
|
||||
placeholder={t("docker.selectShell")}
|
||||
className="w-[120px]"
|
||||
>
|
||||
<option value="bash">{t("docker.bash")}</option>
|
||||
<option value="sh">{t("docker.sh")}</option>
|
||||
<option value="ash">{t("docker.ash")}</option>
|
||||
</Select2>
|
||||
<div className="flex gap-2 sm:gap-2">
|
||||
{!isConnected ? (
|
||||
<Button
|
||||
onClick={connect}
|
||||
disabled={isConnecting}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||
{t("docker.connecting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Power className="h-4 w-4 mr-2" />
|
||||
{t("docker.connect")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={disconnect}
|
||||
variant="destructive"
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
<PowerOff className="h-4 w-4 mr-2" />
|
||||
{t("docker.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="flex-1 overflow-hidden pt-1 pb-0"
|
||||
style={{ background: themeColors.background }}
|
||||
>
|
||||
<CardContent className="p-0 h-full relative">
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className="h-full w-full"
|
||||
style={{ display: isConnected ? "block" : "none" }}
|
||||
/>
|
||||
|
||||
{!isConnected &&
|
||||
!isConnecting &&
|
||||
retry.status !== "error" &&
|
||||
retry.status !== "disconnected" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("docker.notConnected")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("docker.clickToConnect")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isConnected &&
|
||||
(isConnecting ||
|
||||
retry.status === "error" ||
|
||||
retry.status === "disconnected") && (
|
||||
<ConnectionScreen
|
||||
status={isConnecting ? "connecting" : retry.status}
|
||||
message={t("docker.connectingTo", { containerName })}
|
||||
attempt={retry.attempt}
|
||||
maxAttempts={retry.maxAttempts}
|
||||
nextRetryInMs={retry.nextRetryInMs}
|
||||
onManualRetry={() => {
|
||||
clearLogs();
|
||||
retry.retryNow();
|
||||
}}
|
||||
retryLabel={t("docker.connect")}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { Button, Card, useConfirmation } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Pause,
|
||||
PlayCircle,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
|
||||
interface ContainerCardProps {
|
||||
container: DockerContainer;
|
||||
sessionId: string;
|
||||
onSelect?: () => void;
|
||||
isSelected?: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function DockerBadge({ state }: { state: DockerContainer["state"] }) {
|
||||
let colorClass = "border-border text-muted-foreground";
|
||||
if (state === "running")
|
||||
colorClass = "border-accent-brand/40 text-accent-brand bg-accent-brand/10";
|
||||
if (state === "paused")
|
||||
colorClass = "border-yellow-500/40 text-yellow-500 bg-yellow-500/10";
|
||||
if (state === "exited")
|
||||
colorClass = "border-destructive/40 text-destructive bg-destructive/5";
|
||||
if (state === "restarting")
|
||||
colorClass = "border-blue-400/40 text-blue-400 bg-blue-400/10";
|
||||
return (
|
||||
<span
|
||||
className={`text-[10px] font-bold px-1.5 py-0.5 border uppercase tracking-wider ${colorClass}`}
|
||||
>
|
||||
{state}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContainerCard({
|
||||
container,
|
||||
sessionId,
|
||||
onSelect,
|
||||
isSelected = false,
|
||||
onRefresh,
|
||||
}: ContainerCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const docker = useDockerApi();
|
||||
const [isStarting, setIsStarting] = React.useState(false);
|
||||
const [isStopping, setIsStopping] = React.useState(false);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
const [isPausing, setIsPausing] = React.useState(false);
|
||||
const [isRemoving, setIsRemoving] = React.useState(false);
|
||||
|
||||
const isLoading =
|
||||
isStarting || isStopping || isRestarting || isPausing || isRemoving;
|
||||
|
||||
const containerName = container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name;
|
||||
const portsList = (container.ports ?? "")
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const handleStart = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsStarting(true);
|
||||
try {
|
||||
await docker.containerAction(sessionId, container.id, "start");
|
||||
toast.success(t("docker.containerStarted", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToStartContainer", {
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsStopping(true);
|
||||
try {
|
||||
await docker.containerAction(sessionId, container.id, "stop");
|
||||
toast.success(t("docker.containerStopped", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToStopContainer", {
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
await docker.containerAction(sessionId, container.id, "restart");
|
||||
toast.success(t("docker.containerRestarted", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToRestartContainer", {
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsPausing(true);
|
||||
try {
|
||||
if (container.state === "paused") {
|
||||
await docker.containerAction(sessionId, container.id, "unpause");
|
||||
toast.success(t("docker.containerUnpaused", { name: containerName }));
|
||||
} else {
|
||||
await docker.containerAction(sessionId, container.id, "pause");
|
||||
toast.success(t("docker.containerPaused", { name: containerName }));
|
||||
}
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToTogglePauseContainer", {
|
||||
action: container.state === "paused" ? "unpause" : "pause",
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsPausing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
confirmWithToast(
|
||||
t("docker.confirmRemoveContainer", { name: containerName }) +
|
||||
(container.state === "running"
|
||||
? " " + t("docker.runningContainerWarning")
|
||||
: ""),
|
||||
async () => {
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
await docker.removeContainer(
|
||||
sessionId,
|
||||
container.id,
|
||||
container.state === "running",
|
||||
);
|
||||
toast.success(t("docker.containerRemoved", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToRemoveContainer", {
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
}
|
||||
},
|
||||
t("common.remove"),
|
||||
t("common.cancel"),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`flex flex-col overflow-hidden p-0 gap-0 group hover:border-accent-brand/40 transition-colors cursor-pointer ${isSelected ? "border-accent-brand/60 bg-accent-brand/5" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/10">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Box
|
||||
className={`size-3.5 ${container.state === "running" ? "text-accent-brand" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-sm font-bold truncate">{containerName}</span>
|
||||
</div>
|
||||
<DockerBadge state={container.state} />
|
||||
</div>
|
||||
<div className="px-4 py-3 flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
|
||||
{t("docker.image")}
|
||||
</span>
|
||||
<span className="text-xs font-mono truncate text-foreground/80">
|
||||
{container.image}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 mt-1">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
|
||||
{t("docker.ports")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{portsList.length > 0 ? (
|
||||
portsList.slice(0, 3).map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-[10px] font-mono px-1 border border-border bg-muted/30"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground italic">
|
||||
{t("docker.noPorts")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-border bg-muted/5 flex items-center justify-between opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[10px] text-muted-foreground italic">
|
||||
{container.id.substring(0, 12)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{container.state !== "running" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-accent-brand"
|
||||
disabled={isLoading}
|
||||
onClick={handleStart}
|
||||
>
|
||||
{isStarting ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{container.state === "running" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-destructive"
|
||||
disabled={isLoading}
|
||||
onClick={handleStop}
|
||||
>
|
||||
{isStopping ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Square className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{(container.state === "running" || container.state === "paused") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={isLoading}
|
||||
onClick={handlePause}
|
||||
>
|
||||
{isPausing ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : container.state === "paused" ? (
|
||||
<PlayCircle className="size-3" />
|
||||
) : (
|
||||
<Pause className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={isLoading || container.state === "exited"}
|
||||
onClick={handleRestart}
|
||||
>
|
||||
{isRestarting ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-destructive"
|
||||
disabled={isLoading}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Button, Card, Separator } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer, DockerHost } from "../types";
|
||||
import React from "react";
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
Box,
|
||||
List,
|
||||
Settings,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { LogViewer } from "./LogViewer.tsx";
|
||||
import { ContainerStats } from "./ContainerStats.tsx";
|
||||
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
|
||||
type DetailTab = "logs" | "stats" | "console";
|
||||
|
||||
interface ContainerDetailProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containers: DockerContainer[];
|
||||
hostConfig: DockerHost;
|
||||
onBack: () => void;
|
||||
initialTab?: DetailTab;
|
||||
}
|
||||
|
||||
export function ContainerDetail({
|
||||
sessionId,
|
||||
containerId,
|
||||
containers,
|
||||
hostConfig,
|
||||
onBack,
|
||||
initialTab = "logs",
|
||||
}: ContainerDetailProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = React.useState<DetailTab>(initialTab);
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveTab(initialTab);
|
||||
}, [containerId, initialTab]);
|
||||
|
||||
const container = containers.find((c) => c.id === containerId);
|
||||
const containerName = container
|
||||
? container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name
|
||||
: "";
|
||||
|
||||
if (!container) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center flex flex-col gap-3">
|
||||
<p className="text-muted-foreground">
|
||||
{t("docker.containerNotFound")}
|
||||
</p>
|
||||
<Button onClick={onBack} variant="outline" size="sm">
|
||||
<ArrowLeft className="size-4 mr-2" />
|
||||
{t("docker.backToList")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: { id: DetailTab; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: "logs",
|
||||
label: t("docker.logs"),
|
||||
icon: <List className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
label: t("docker.stats"),
|
||||
icon: <Activity className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "console",
|
||||
label: t("docker.consoleTab"),
|
||||
icon: <Terminal className="size-3.5" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-3 py-3 gap-3">
|
||||
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="size-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
|
||||
<Box className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{containerName}</h1>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{container.image}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DockerBadge state={container.state} />
|
||||
<Separator orientation="vertical" className="h-8 mx-2" />
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="size-4 text-accent-brand" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||
<div className="flex gap-1 border-b border-border shrink-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-semibold border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-b-accent-brand text-foreground bg-accent-brand/5"
|
||||
: "border-b-transparent text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{activeTab === "logs" && (
|
||||
<LogViewer
|
||||
sessionId={sessionId}
|
||||
containerId={containerId}
|
||||
containerName={containerName}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "stats" && (
|
||||
<ContainerStats
|
||||
sessionId={sessionId}
|
||||
containerId={containerId}
|
||||
containerName={containerName}
|
||||
containerState={container.state}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "console" && (
|
||||
<ConsoleTerminal
|
||||
containerId={containerId}
|
||||
containerName={containerName}
|
||||
containerState={container.state}
|
||||
hostConfig={hostConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { ContainerCard } from "./ContainerCard.tsx";
|
||||
|
||||
interface ContainerListProps {
|
||||
containers: DockerContainer[];
|
||||
sessionId: string;
|
||||
onSelectContainer: (containerId: string) => void;
|
||||
selectedContainerId?: string | null;
|
||||
onRefresh?: () => void;
|
||||
search?: string;
|
||||
statusFilter?: string;
|
||||
}
|
||||
|
||||
export function ContainerList({
|
||||
containers,
|
||||
sessionId,
|
||||
onSelectContainer,
|
||||
selectedContainerId = null,
|
||||
onRefresh,
|
||||
search = "",
|
||||
statusFilter = "all",
|
||||
}: ContainerListProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
return containers.filter((c) => {
|
||||
const name = c.name.startsWith("/") ? c.name.slice(1) : c.name;
|
||||
const matchesSearch =
|
||||
name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.image.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.id.toLowerCase().includes(search.toLowerCase());
|
||||
return (
|
||||
matchesSearch && (statusFilter === "all" || c.state === statusFilter)
|
||||
);
|
||||
});
|
||||
}, [containers, search, statusFilter]);
|
||||
|
||||
if (containers.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<Box className="size-16 mb-4" />
|
||||
<span className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("docker.noContainersFound")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{t("docker.noContainersFoundHint")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<Box className="size-16 mb-4" />
|
||||
<span className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("docker.noContainersMatchFilters")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{t("docker.noContainersMatchFiltersHint")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{filtered.map((container) => (
|
||||
<ContainerCard
|
||||
key={container.id}
|
||||
container={container}
|
||||
sessionId={sessionId}
|
||||
onSelect={() => onSelectContainer(container.id)}
|
||||
isSelected={selectedContainerId === container.id}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { SectionCard, useAdaptivePolling } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerStats } from "../types";
|
||||
import React from "react";
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Info,
|
||||
MemoryStick,
|
||||
Network,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
|
||||
interface ContainerStatsProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerState: string;
|
||||
}
|
||||
|
||||
export function ContainerStats({
|
||||
sessionId,
|
||||
containerId,
|
||||
containerName,
|
||||
containerState,
|
||||
}: ContainerStatsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [stats, setStats] = React.useState<DockerStats | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const statsRef = React.useRef(stats);
|
||||
statsRef.current = stats;
|
||||
|
||||
const fetchStats = React.useCallback(async () => {
|
||||
if (containerState !== "running") return false;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await docker.stats(sessionId, containerId);
|
||||
const previous = statsRef.current;
|
||||
const changed =
|
||||
!previous ||
|
||||
Math.abs(parseFloat(data.cpu) - parseFloat(previous.cpu)) >= 1.5 ||
|
||||
Math.abs(
|
||||
parseFloat(data.memoryPercent) - parseFloat(previous.memoryPercent),
|
||||
) >= 1 ||
|
||||
data.pids !== previous.pids;
|
||||
statsRef.current = data;
|
||||
setStats(data);
|
||||
return changed;
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, t("docker.failedToFetchStats")));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, containerState, t, docker]);
|
||||
|
||||
useAdaptivePolling(
|
||||
fetchStats,
|
||||
{
|
||||
minIntervalMs: 2_000,
|
||||
maxIntervalMs: 12_000,
|
||||
stablePollsPerStep: 2,
|
||||
maxRequestDutyCycle: 0.2,
|
||||
},
|
||||
containerState === "running",
|
||||
);
|
||||
|
||||
if (containerState !== "running") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<Activity className="size-16 mb-4" />
|
||||
<span className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("docker.containerNotRunning")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{t("docker.startContainerToViewStats")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !stats) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
|
||||
<RefreshCw className="size-8 animate-spin mb-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
{t("docker.loadingStats")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
|
||||
<Activity className="size-8 mb-4" />
|
||||
<span className="text-sm font-semibold text-destructive">
|
||||
{t("docker.errorLoadingStats")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground mt-1">{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<span className="text-sm font-semibold">
|
||||
{t("docker.noStatsAvailable")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cpuPercent = parseFloat(stats.cpu) || 0;
|
||||
const memPercent = parseFloat(stats.memoryPercent) || 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<SectionCard
|
||||
title={t("docker.cpuUsage")}
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-3xl font-bold text-accent-brand">
|
||||
{stats.cpu}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{t("docker.current")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-brand transition-all duration-500"
|
||||
style={{ width: `${Math.min(cpuPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("docker.memoryUsage")}
|
||||
icon={<MemoryStick className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-3xl font-bold text-accent-brand">
|
||||
{stats.memoryPercent}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{stats.memoryUsed} / {stats.memoryLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-brand transition-all duration-500"
|
||||
style={{ width: `${Math.min(memPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("docker.networkIo")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{t("docker.input")}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-bold">
|
||||
{stats.netInput}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{t("docker.output")}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-bold text-accent-brand">
|
||||
{stats.netOutput}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("docker.blockIo")}
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{t("docker.read")}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-bold">
|
||||
{stats.blockRead}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{t("docker.write")}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-bold text-accent-brand">
|
||||
{stats.blockWrite}
|
||||
</span>
|
||||
</div>
|
||||
{stats.pids && (
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{t("docker.pids")}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-bold">{stats.pids}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("docker.containerInformation")}
|
||||
icon={<Info className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1">
|
||||
<div className="flex justify-between items-center text-xs py-1 border-b border-border/50">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("docker.name")}
|
||||
</span>
|
||||
<span className="font-mono">{containerName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs py-1 border-b border-border/50">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("docker.id")}
|
||||
</span>
|
||||
<span className="font-mono">{containerId.substring(0, 12)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs py-1">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("docker.state")}
|
||||
</span>
|
||||
<DockerBadge
|
||||
state={
|
||||
containerState as import("../types").DockerContainer["state"]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
|
||||
type DetailTab = "logs" | "stats" | "console";
|
||||
|
||||
interface ContainerTableProps {
|
||||
containers: DockerContainer[];
|
||||
sessionId: string;
|
||||
onSelectContainer: (containerId: string, tab?: DetailTab) => void;
|
||||
selectedContainerId?: string | null;
|
||||
onRefresh?: () => void;
|
||||
search?: string;
|
||||
statusFilter?: string;
|
||||
}
|
||||
|
||||
export function ContainerTable({
|
||||
containers,
|
||||
sessionId,
|
||||
onSelectContainer,
|
||||
selectedContainerId = null,
|
||||
onRefresh,
|
||||
search = "",
|
||||
statusFilter = "all",
|
||||
}: ContainerTableProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [pendingId, setPendingId] = React.useState<string | null>(null);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
return containers.filter((c) => {
|
||||
const name = c.name.startsWith("/") ? c.name.slice(1) : c.name;
|
||||
const matchesSearch =
|
||||
name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.image.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.id.toLowerCase().includes(search.toLowerCase());
|
||||
return (
|
||||
matchesSearch && (statusFilter === "all" || c.state === statusFilter)
|
||||
);
|
||||
});
|
||||
}, [containers, search, statusFilter]);
|
||||
|
||||
const handleToggleRun = async (
|
||||
e: React.MouseEvent,
|
||||
container: DockerContainer,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
const containerName = container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name;
|
||||
setPendingId(container.id);
|
||||
try {
|
||||
if (container.state === "running") {
|
||||
await docker.containerAction(sessionId, container.id, "stop");
|
||||
toast.success(t("docker.containerStopped", { name: containerName }));
|
||||
} else {
|
||||
await docker.containerAction(sessionId, container.id, "start");
|
||||
toast.success(t("docker.containerStarted", { name: containerName }));
|
||||
}
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
container.state === "running"
|
||||
? t("docker.failedToStopContainer", { error: getErrorMessage(err) })
|
||||
: t("docker.failedToStartContainer", {
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (containers.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<Box className="size-16 mb-4" />
|
||||
<span className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("docker.noContainersFound")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{t("docker.noContainersFoundHint")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<Box className="size-16 mb-4" />
|
||||
<span className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("docker.noContainersMatchFilters")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{t("docker.noContainersMatchFiltersHint")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden border border-border bg-card">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("docker.name")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("docker.image")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("docker.state")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("docker.ports")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("docker.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">
|
||||
{t("docker.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((container) => {
|
||||
const containerName = container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name;
|
||||
const portsList = (container.ports ?? "")
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const isSelected = selectedContainerId === container.id;
|
||||
const isPending = pendingId === container.id;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={container.id}
|
||||
onClick={() => onSelectContainer(container.id, "logs")}
|
||||
className={`cursor-pointer transition-colors hover:bg-muted/40 ${
|
||||
isSelected ? "bg-accent-brand/5" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Box
|
||||
className={`size-3.5 shrink-0 ${container.state === "running" ? "text-accent-brand" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="min-w-0 truncate font-semibold">
|
||||
{containerName}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-[220px]">
|
||||
<span className="block truncate font-mono text-muted-foreground">
|
||||
{container.image}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<DockerBadge state={container.state} />
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-[200px]">
|
||||
{portsList.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{portsList.slice(0, 2).map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-[10px] font-mono px-1 border border-border bg-muted/30"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{portsList.length > 2 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{portsList.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground italic">
|
||||
{t("docker.noPorts")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{container.status}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div
|
||||
className="flex items-center justify-end gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title={t("docker.logs")}
|
||||
onClick={() => onSelectContainer(container.id, "logs")}
|
||||
>
|
||||
<List className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title={t("docker.stats")}
|
||||
onClick={() => onSelectContainer(container.id, "stats")}
|
||||
>
|
||||
<Box className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title={t("docker.consoleTab")}
|
||||
disabled={container.state !== "running"}
|
||||
onClick={() =>
|
||||
onSelectContainer(container.id, "console")
|
||||
}
|
||||
>
|
||||
<Terminal className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={
|
||||
container.state === "running"
|
||||
? "text-destructive"
|
||||
: "text-accent-brand"
|
||||
}
|
||||
disabled={isPending}
|
||||
title={
|
||||
container.state === "running"
|
||||
? t("docker.stop")
|
||||
: t("docker.start")
|
||||
}
|
||||
onClick={(e) => handleToggleRun(e, container)}
|
||||
>
|
||||
{isPending ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : container.state === "running" ? (
|
||||
<Square className="size-3" />
|
||||
) : (
|
||||
<Play className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Select2,
|
||||
Separator,
|
||||
useAdaptivePolling,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { DockerLogOptions } from "../types";
|
||||
import React from "react";
|
||||
import { Download, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
|
||||
interface LogViewerProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
}
|
||||
|
||||
function AdminToggle({
|
||||
on,
|
||||
onToggle,
|
||||
label,
|
||||
}: {
|
||||
on: boolean;
|
||||
onToggle: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${on ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getLogColor(line: string): string {
|
||||
if (line.includes(" WARN") || line.includes(" warn"))
|
||||
return "text-yellow-400/90";
|
||||
if (line.includes(" ERROR") || line.includes(" error"))
|
||||
return "text-destructive";
|
||||
if (line.includes(" DEBUG") || line.includes(" debug"))
|
||||
return "text-muted-foreground/60";
|
||||
return "text-foreground/90";
|
||||
}
|
||||
|
||||
export function LogViewer({
|
||||
sessionId,
|
||||
containerId,
|
||||
containerName,
|
||||
}: LogViewerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [rawLogs, setRawLogs] = React.useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
const [tailLines, setTailLines] = React.useState("100");
|
||||
const [showTimestamps, setShowTimestamps] = React.useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = React.useState(false);
|
||||
const [logSearch, setLogSearch] = React.useState("");
|
||||
const logsEndRef = React.useRef<HTMLDivElement>(null);
|
||||
const rawLogsRef = React.useRef(rawLogs);
|
||||
rawLogsRef.current = rawLogs;
|
||||
|
||||
const fetchLogs = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const options: DockerLogOptions = {
|
||||
tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
|
||||
timestamps: showTimestamps,
|
||||
};
|
||||
const data = await docker.logs(sessionId, containerId, options);
|
||||
const next = data.logs.split("\n").filter(Boolean);
|
||||
const changed =
|
||||
next.length !== rawLogsRef.current.length ||
|
||||
next.some((line, index) => line !== rawLogsRef.current[index]);
|
||||
if (changed) {
|
||||
rawLogsRef.current = next;
|
||||
setRawLogs(next);
|
||||
}
|
||||
return changed;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("docker.failedToFetchLogs", { error: getErrorMessage(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, tailLines, showTimestamps, docker, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
useAdaptivePolling(
|
||||
fetchLogs,
|
||||
{
|
||||
minIntervalMs: 3_000,
|
||||
maxIntervalMs: 18_000,
|
||||
stablePollsPerStep: 2,
|
||||
maxRequestDutyCycle: 0.2,
|
||||
},
|
||||
autoRefresh,
|
||||
{ runImmediately: false },
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoRefresh && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [rawLogs, autoRefresh]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const data = await docker.logs(sessionId, containerId, {
|
||||
timestamps: showTimestamps,
|
||||
});
|
||||
const blob = new Blob([data.logs], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${containerName.replace(/[^a-z0-9]/gi, "_")}_logs.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success(t("docker.logsDownloaded"));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("docker.failedToDownloadLogs", { error: getErrorMessage(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLogs = logSearch
|
||||
? rawLogs.filter((l) => l.toLowerCase().includes(logSearch.toLowerCase()))
|
||||
: rawLogs;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||
<div className="flex items-center justify-between bg-card border border-border px-3 py-2 gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3 shrink-0 flex-wrap">
|
||||
<AdminToggle
|
||||
on={autoRefresh}
|
||||
onToggle={() => setAutoRefresh(!autoRefresh)}
|
||||
label={t("docker.autoRefresh")}
|
||||
/>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<AdminToggle
|
||||
on={showTimestamps}
|
||||
onToggle={() => setShowTimestamps(!showTimestamps)}
|
||||
label={t("docker.timestamps")}
|
||||
/>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<Select2
|
||||
value={tailLines}
|
||||
onChange={(e) => setTailLines(e.target.value)}
|
||||
className="h-7 px-2 text-[10px] bg-background border border-border text-foreground outline-none uppercase font-bold"
|
||||
>
|
||||
<option value="50">{t("docker.last50")}</option>
|
||||
<option value="100">{t("docker.last100")}</option>
|
||||
<option value="500">{t("docker.last500")}</option>
|
||||
<option value="1000">{t("docker.last1000")}</option>
|
||||
<option value="all">{t("docker.allLogs")}</option>
|
||||
</Select2>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
|
||||
{filteredLogs.length}/{rawLogs.length} {t("docker.lines")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-64">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("docker.filterLogs")}
|
||||
value={logSearch}
|
||||
onChange={(e) => setLogSearch(e.target.value)}
|
||||
className="pl-7 h-7 text-xs bg-background border-border rounded-none"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5"
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("docker.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<Download className="size-3" />
|
||||
{t("docker.download")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5"
|
||||
onClick={() => setRawLogs([])}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
{t("docker.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-background border border-border p-3 overflow-auto font-mono text-xs leading-relaxed scrollbar-thin min-h-0">
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((line, i) => {
|
||||
const tsEnd = line.indexOf(" ", 1);
|
||||
const maybeTs = tsEnd > 10 ? line.substring(0, tsEnd) : null;
|
||||
const rest = maybeTs ? line.substring(tsEnd) : line;
|
||||
return (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
{maybeTs && (
|
||||
<span className="text-accent-brand/50">{maybeTs}</span>
|
||||
)}
|
||||
<span className={getLogColor(rest)}>{rest}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">
|
||||
{logSearch
|
||||
? t("docker.noLogsMatching", { query: logSearch })
|
||||
: t("docker.noLogsAvailable")}
|
||||
</span>
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
invokeAction,
|
||||
type PluginHostRecord,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/** What the SSH terminal's "terminal.resolveTheme" action answers. */
|
||||
export interface ConsoleLook {
|
||||
colors: Record<string, string | undefined> & {
|
||||
background: string;
|
||||
foreground: string;
|
||||
};
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
cursorStyle: "block" | "underline" | "bar";
|
||||
cursorBlink: boolean;
|
||||
letterSpacing: number;
|
||||
lineHeight: number;
|
||||
scrollback: number;
|
||||
}
|
||||
|
||||
/** Plain colors, for when the SSH terminal plugin is off. */
|
||||
export const FALLBACK_CONSOLE_LOOK: ConsoleLook = {
|
||||
colors: {
|
||||
background: "#0c0d0b",
|
||||
foreground: "#fafafa",
|
||||
cursor: "#fafafa",
|
||||
cursorAccent: "#0c0d0b",
|
||||
},
|
||||
fontFamily: '"SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
fontSize: 14,
|
||||
cursorStyle: "bar",
|
||||
cursorBlink: true,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1,
|
||||
scrollback: 10000,
|
||||
};
|
||||
|
||||
/** The look the SSH terminal uses on this host, or plain colors without it. */
|
||||
export function useConsoleLook(
|
||||
host: PluginHostRecord | null | undefined,
|
||||
appTheme: string,
|
||||
): ConsoleLook {
|
||||
const [look, setLook] = useState<ConsoleLook>(FALLBACK_CONSOLE_LOOK);
|
||||
// The terminal's settings live with the terminal, so it resolves them from
|
||||
// the host id.
|
||||
const hostId = host?.id;
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
invokeAction("terminal.resolveTheme", { hostId, appTheme })
|
||||
.then((resolved) => {
|
||||
if (active) setLook((resolved as ConsoleLook) ?? FALLBACK_CONSOLE_LOOK);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setLook(FALLBACK_CONSOLE_LOOK);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [hostId, appTheme]);
|
||||
|
||||
return look;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
type PluginApiClient,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { ConnectionStage } from "@termix/plugin-sdk/ui";
|
||||
import type {
|
||||
DockerContainer,
|
||||
DockerLogOptions,
|
||||
DockerStats,
|
||||
DockerValidation,
|
||||
} from "./types";
|
||||
|
||||
export interface ApiConnectionLog {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
stage: ConnectionStage;
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
/** One step of a connect: connected, a prompt to answer, or auth needed. */
|
||||
export interface ConnectResult {
|
||||
success?: boolean;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
requires_totp?: boolean;
|
||||
prompt?: string;
|
||||
isPassword?: boolean;
|
||||
retry?: boolean;
|
||||
requires_browser_sign_in?: boolean;
|
||||
url?: string | null;
|
||||
code?: string;
|
||||
label?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
}
|
||||
|
||||
/** An API failure carrying the server's message and connection logs. */
|
||||
export class DockerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly connectionLogs?: ApiConnectionLog[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DockerApiError";
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(error: unknown, action: string): DockerApiError {
|
||||
const response = (
|
||||
error as {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
};
|
||||
};
|
||||
}
|
||||
)?.response;
|
||||
const data = response?.data;
|
||||
const message =
|
||||
data?.error ||
|
||||
data?.message ||
|
||||
(error instanceof Error ? error.message : `Failed to ${action}`);
|
||||
return new DockerApiError(message, response?.status, data?.connectionLogs);
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
action: string,
|
||||
request: () => Promise<{ data: T }>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return (await request()).data;
|
||||
} catch (error) {
|
||||
throw apiError(error, action);
|
||||
}
|
||||
}
|
||||
|
||||
export type ContainerAction =
|
||||
"start" | "stop" | "restart" | "pause" | "unpause";
|
||||
|
||||
export function createDockerApi(api: PluginApiClient) {
|
||||
return {
|
||||
connect: (
|
||||
sessionId: string,
|
||||
hostId: number,
|
||||
credentials?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
},
|
||||
) =>
|
||||
call<ConnectResult>("connect to Docker", () =>
|
||||
api.post("/ssh/connect", { sessionId, hostId, ...credentials }),
|
||||
),
|
||||
|
||||
answerTotp: (sessionId: string, totpCode: string) =>
|
||||
call<ConnectResult>("verify the code", () =>
|
||||
api.post("/ssh/connect-totp", { sessionId, totpCode }),
|
||||
),
|
||||
|
||||
continueBrowserSignIn: (sessionId: string) =>
|
||||
call<ConnectResult>("continue after a browser sign-in", () =>
|
||||
api.post("/ssh/connect-browser-sign-in", { sessionId }),
|
||||
),
|
||||
|
||||
disconnect: (sessionId: string) =>
|
||||
call("disconnect from Docker", () =>
|
||||
api.post("/ssh/disconnect", { sessionId }),
|
||||
),
|
||||
|
||||
keepalive: (sessionId: string) =>
|
||||
call("keep the Docker session alive", () =>
|
||||
api.post("/ssh/keepalive", { sessionId }),
|
||||
),
|
||||
|
||||
validate: (sessionId: string) =>
|
||||
call<DockerValidation>("validate Docker", () =>
|
||||
api.get(`/validate/${sessionId}`),
|
||||
),
|
||||
|
||||
listContainers: (sessionId: string, all = true) =>
|
||||
call<DockerContainer[]>("list containers", () =>
|
||||
api.get(`/containers/${sessionId}`, { params: { all } }),
|
||||
),
|
||||
|
||||
containerAction: (
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
action: ContainerAction,
|
||||
) =>
|
||||
call<{ success: boolean; message: string }>(
|
||||
`${action} the container`,
|
||||
() => api.post(`/containers/${sessionId}/${containerId}/${action}`),
|
||||
),
|
||||
|
||||
removeContainer: (sessionId: string, containerId: string, force = false) =>
|
||||
call<{ success: boolean; message: string }>("remove the container", () =>
|
||||
api.delete(`/containers/${sessionId}/${containerId}/remove`, {
|
||||
params: { force },
|
||||
}),
|
||||
),
|
||||
|
||||
logs: (
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
) =>
|
||||
call<{ logs: string }>("load container logs", () =>
|
||||
api.get(`/containers/${sessionId}/${containerId}/logs`, {
|
||||
params: options,
|
||||
}),
|
||||
),
|
||||
|
||||
stats: (sessionId: string, containerId: string) =>
|
||||
call<DockerStats>("load container stats", () =>
|
||||
api.get(`/containers/${sessionId}/${containerId}/stats`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export type DockerApi = ReturnType<typeof createDockerApi>;
|
||||
|
||||
/** The Docker API on this plugin's own client. */
|
||||
export function useDockerApi(): DockerApi {
|
||||
const api = usePluginApi();
|
||||
return useMemo(() => createDockerApi(api), [api]);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { ExtensionContribution } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/** The homepage's grid step, in pixels. */
|
||||
export const GRID_SIZE = 30;
|
||||
|
||||
export interface DockerWidgetConfig {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
/** What the homepage hands a widget; only the fields this plugin reads. */
|
||||
export interface WidgetComponentProps<C> {
|
||||
widget: { title?: string | null };
|
||||
config: C;
|
||||
}
|
||||
|
||||
export interface WidgetEditFormProps<C> {
|
||||
config: C;
|
||||
onChange: (config: C) => void;
|
||||
}
|
||||
|
||||
/** What app.registerExtension("homepage.widgets", ...) takes. */
|
||||
export interface WidgetDefinition<C> extends ExtensionContribution {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: "links" | "info" | "system" | "monitoring";
|
||||
icon: ReactNode;
|
||||
defaultConfig: C;
|
||||
defaultSize: { w: number; h: number };
|
||||
minSize: { w: number; h: number };
|
||||
components: {
|
||||
view: ComponentType<WidgetComponentProps<C>>;
|
||||
editForm?: ComponentType<WidgetEditFormProps<C>>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// The terminal surfaces need xterm's own stylesheet.
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { ComponentType } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import type {
|
||||
StandaloneViewProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { DockerManager } from "./DockerManager";
|
||||
import DockerApp from "./DockerApp";
|
||||
import { dockerWidget } from "./DockerWidget";
|
||||
import { dockerEnabled, toDockerHost, type DockerHostSource } from "./types";
|
||||
|
||||
function DockerTab({ host, sshHost, label, isVisible }: TabProps) {
|
||||
const record = host ?? (sshHost as DockerHostSource | undefined);
|
||||
return (
|
||||
<DockerManager
|
||||
host={record ? toDockerHost(record) : undefined}
|
||||
title={label}
|
||||
isVisible={isVisible}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DockerStandalone({ hostId }: StandaloneViewProps) {
|
||||
return <DockerApp hostId={hostId} />;
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
// Host actions filter synchronously, so the permission is read once here.
|
||||
let canUse = true;
|
||||
void app.hasPermission("use").then((allowed) => {
|
||||
canUse = allowed;
|
||||
});
|
||||
|
||||
app.registerTab("docker", DockerTab, {
|
||||
icon: Box,
|
||||
titleKey: "nav.docker",
|
||||
requiresHost: true,
|
||||
noHostMessageKey: "docker.noHostSelected",
|
||||
persistent: true,
|
||||
activityTypes: ["docker"],
|
||||
standalone: DockerStandalone,
|
||||
preload: () => import("./DockerManager"),
|
||||
});
|
||||
|
||||
app.registerHostAction({
|
||||
id: "docker",
|
||||
titleKey: "nav.docker",
|
||||
icon: Box,
|
||||
kind: "open",
|
||||
order: 30,
|
||||
tabType: "docker",
|
||||
copyUrlView: "docker",
|
||||
when: (host) => canUse && !!host.enableSsh && dockerEnabled(host),
|
||||
});
|
||||
|
||||
app.registerExtension("homepage.widgets", dockerWidget);
|
||||
|
||||
app.registerSlotContribution("onboarding.features", {
|
||||
actionId: "docker.feature",
|
||||
titleKey: "onboarding.feature_docker",
|
||||
descriptionKey: "onboarding.feature_docker_desc",
|
||||
icon: Box as ComponentType<{ className?: string }>,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export interface DockerContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
state:
|
||||
| "created"
|
||||
| "running"
|
||||
| "paused"
|
||||
| "restarting"
|
||||
| "removing"
|
||||
| "exited"
|
||||
| "dead";
|
||||
ports: string;
|
||||
created: string;
|
||||
command?: string;
|
||||
labels?: Record<string, string>;
|
||||
networks?: string[];
|
||||
mounts?: string[];
|
||||
}
|
||||
|
||||
export interface DockerStats {
|
||||
cpu: string;
|
||||
memoryUsed: string;
|
||||
memoryLimit: string;
|
||||
memoryPercent: string;
|
||||
netInput: string;
|
||||
netOutput: string;
|
||||
blockRead: string;
|
||||
blockWrite: string;
|
||||
pids?: string;
|
||||
}
|
||||
|
||||
export interface DockerLogOptions {
|
||||
tail?: number;
|
||||
timestamps?: boolean;
|
||||
since?: string;
|
||||
until?: string;
|
||||
follow?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerValidation {
|
||||
available: boolean;
|
||||
version?: string;
|
||||
runtime?: "docker" | "podman";
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** What the Docker views read from a host. */
|
||||
export interface DockerHost {
|
||||
id: number;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
|
||||
}
|
||||
|
||||
/** The fields toDockerHost reads from a shell or API host. */
|
||||
export type DockerHostSource = Omit<DockerHost, "id" | "name"> & {
|
||||
id: string | number;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
/** Any host record the shell or an API hands over, as a DockerHost. */
|
||||
export function toDockerHost(record: DockerHostSource): DockerHost {
|
||||
return {
|
||||
id: Number(record.id),
|
||||
name: record.name ?? undefined,
|
||||
ip: record.ip,
|
||||
port: record.port,
|
||||
username: record.username,
|
||||
syncId: record.syncId ?? null,
|
||||
connectionOrigin: record.connectionOrigin ?? null,
|
||||
pluginSettings: record.pluginSettings,
|
||||
};
|
||||
}
|
||||
|
||||
function dockerSettings(
|
||||
host: object | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
const all = (host as { pluginSettings?: unknown } | null | undefined)
|
||||
?.pluginSettings as
|
||||
Record<string, Record<string, unknown> | undefined> | undefined;
|
||||
return all?.docker ?? {};
|
||||
}
|
||||
|
||||
export function dockerEnabled(host: object | null | undefined): boolean {
|
||||
return dockerSettings(host).enableDocker === true;
|
||||
}
|
||||
|
||||
export function hostTitle(host: DockerHost): string {
|
||||
return host.name || `${host.username ?? ""}@${host.ip}`;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import express from "express";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginWebSocketConnection } from "@termix/plugin-sdk/backend";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { manifest, sshHost } from "./server";
|
||||
import { FakeClient, FakeStream } from "./fake-ssh";
|
||||
|
||||
/** A ws WebSocket as far as the console uses one. */
|
||||
class FakeSocket extends EventEmitter {
|
||||
readyState = 1;
|
||||
sent: Array<Record<string, unknown>> = [];
|
||||
closedWith: [number, string] | null = null;
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(JSON.parse(data));
|
||||
}
|
||||
|
||||
ping() {}
|
||||
|
||||
close(code: number, reason: string) {
|
||||
if (this.readyState !== 1) return;
|
||||
this.readyState = 3;
|
||||
this.closedWith = [code, reason];
|
||||
this.emit("close");
|
||||
}
|
||||
|
||||
message(payload: unknown) {
|
||||
this.emit("message", Buffer.from(JSON.stringify(payload)));
|
||||
}
|
||||
}
|
||||
|
||||
function consoleClient() {
|
||||
const shells: FakeStream[] = [];
|
||||
const client = new FakeClient([
|
||||
[/which bash/, { stdout: "/bin/bash\n" }],
|
||||
[/exec -it abc123 \/bin\/bash/, (stream) => shells.push(stream)],
|
||||
]);
|
||||
return { client, shells };
|
||||
}
|
||||
|
||||
async function activated(client: FakeClient): Promise<MockPluginContext> {
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
router: () => express.Router(),
|
||||
permissions: ["docker.use"],
|
||||
sshHosts: [sshHost(7)],
|
||||
sshClient: client,
|
||||
});
|
||||
await mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||
// Like the runtime, disposing a connection ends its client.
|
||||
const connect = mock.ctx.ssh.connect;
|
||||
mock.ctx.ssh.connect = (async (...args: Parameters<typeof connect>) => ({
|
||||
...(await connect(...args)),
|
||||
dispose: () => client.end(),
|
||||
})) as typeof connect;
|
||||
await activate(mock.ctx);
|
||||
return mock;
|
||||
}
|
||||
|
||||
async function openConsole(mock: MockPluginContext, userId = "user-1") {
|
||||
const route = mock.wsRoutes.find((r) => r.path === "/console");
|
||||
expect(route?.options).toEqual({ public: true, optionalAuth: true });
|
||||
const socket = new FakeSocket();
|
||||
mock.setActor(userId || undefined);
|
||||
await route!.handler!({
|
||||
userId,
|
||||
request: {},
|
||||
socket,
|
||||
clientIp: "127.0.0.1",
|
||||
requestOrigin: "http://localhost",
|
||||
isDataUnlocked: () => true,
|
||||
} as PluginWebSocketConnection);
|
||||
return socket;
|
||||
}
|
||||
|
||||
async function until(check: () => boolean) {
|
||||
for (let i = 0; i < 50 && !check(); i++) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
expect(check()).toBe(true);
|
||||
}
|
||||
|
||||
let disposals: Array<() => void | Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dispose of disposals.reverse()) await dispose();
|
||||
disposals = [];
|
||||
});
|
||||
|
||||
describe("docker console", () => {
|
||||
it("refuses a socket without a user", async () => {
|
||||
const mock = await activated(consoleClient().client);
|
||||
disposals.push(...mock.disposals);
|
||||
const socket = await openConsole(mock, "");
|
||||
expect(socket.closedWith?.[0]).toBe(1008);
|
||||
});
|
||||
|
||||
it("closes an open console on disable, and the next enable works", async () => {
|
||||
const first = consoleClient();
|
||||
const mock = await activated(first.client);
|
||||
|
||||
const socket = await openConsole(mock);
|
||||
socket.message({
|
||||
type: "connect",
|
||||
data: {
|
||||
hostConfig: { id: 7, ip: "10.0.0.7" },
|
||||
containerId: "abc123",
|
||||
shell: "bash",
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
},
|
||||
});
|
||||
await until(() => socket.sent.some((m) => m.type === "connected"));
|
||||
socket.message({ type: "input", data: "ls\n" });
|
||||
await until(() => first.shells[0]?.written.includes("ls\n") ?? false);
|
||||
|
||||
// Disable: the runtime empties the disposable bag.
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
expect(first.shells[0].ended).toBe(true);
|
||||
expect(first.client.ended).toBe(true);
|
||||
expect(socket.closedWith?.[0]).toBe(1001);
|
||||
|
||||
// Enable again: a fresh activate on the same module serves a new console.
|
||||
const second = consoleClient();
|
||||
const again = await activated(second.client);
|
||||
disposals.push(...again.disposals);
|
||||
const next = await openConsole(again);
|
||||
next.message({
|
||||
type: "connect",
|
||||
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
|
||||
});
|
||||
await until(() => next.sent.some((m) => m.type === "connected"));
|
||||
second.shells[0].emit("data", Buffer.from("hello"));
|
||||
await until(() =>
|
||||
next.sent.some((m) => m.type === "output" && m.data === "hello"),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a host id that resolves to another machine", async () => {
|
||||
const mock = await activated(consoleClient().client);
|
||||
disposals.push(...mock.disposals);
|
||||
const socket = await openConsole(mock);
|
||||
socket.message({
|
||||
type: "connect",
|
||||
data: { hostConfig: { id: 7, ip: "10.9.9.9" }, containerId: "abc123" },
|
||||
});
|
||||
await until(() => socket.sent.some((m) => m.type === "error"));
|
||||
expect(String(socket.sent[0].message)).toMatch(/Host mismatch/);
|
||||
});
|
||||
|
||||
it("refuses a host with Docker switched off", async () => {
|
||||
const mock = await activated(consoleClient().client);
|
||||
disposals.push(...mock.disposals);
|
||||
await mock.ctx.settings.setHost(7, "enableDocker", false);
|
||||
const socket = await openConsole(mock);
|
||||
socket.message({
|
||||
type: "connect",
|
||||
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
|
||||
});
|
||||
await until(() => socket.sent.some((m) => m.type === "error"));
|
||||
expect(String(socket.sent[0].message)).toMatch(/not enabled/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
/** An ssh2 exec channel: stdout on "data", stderr on `stderr`, then "close". */
|
||||
export class FakeStream extends EventEmitter {
|
||||
stderr = new EventEmitter();
|
||||
written: string[] = [];
|
||||
ended = false;
|
||||
window: [number, number] | null = null;
|
||||
|
||||
write(data: string) {
|
||||
this.written.push(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
end() {
|
||||
this.ended = true;
|
||||
}
|
||||
|
||||
setWindow(rows: number, cols: number) {
|
||||
this.window = [rows, cols];
|
||||
}
|
||||
|
||||
/** Emits output and exits, on the next tick like a real channel. */
|
||||
finish(stdout: string, code = 0, stderr = "") {
|
||||
setImmediate(() => {
|
||||
if (stdout) this.emit("data", Buffer.from(stdout));
|
||||
if (stderr) this.stderr.emit("data", Buffer.from(stderr));
|
||||
this.emit("close", code);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type ExecReply =
|
||||
| { stdout?: string; stderr?: string; code?: number }
|
||||
| ((stream: FakeStream) => void);
|
||||
|
||||
/**
|
||||
* An ssh2 Client that answers exec by the first matching pattern. A reply
|
||||
* may be a function for a long-lived channel such as a console.
|
||||
*/
|
||||
export class FakeClient extends EventEmitter {
|
||||
commands: string[] = [];
|
||||
streams: FakeStream[] = [];
|
||||
ended = false;
|
||||
|
||||
constructor(private replies: Array<[RegExp, ExecReply]> = []) {
|
||||
super();
|
||||
}
|
||||
|
||||
reply(pattern: RegExp, reply: ExecReply) {
|
||||
this.replies.unshift([pattern, reply]);
|
||||
}
|
||||
|
||||
exec(
|
||||
command: string,
|
||||
optionsOrCallback: unknown,
|
||||
maybeCallback?: (err: Error | undefined, stream: FakeStream) => void,
|
||||
) {
|
||||
const callback = (
|
||||
typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback
|
||||
: maybeCallback
|
||||
) as (err: Error | undefined, stream: FakeStream) => void;
|
||||
this.commands.push(command);
|
||||
const stream = new FakeStream();
|
||||
this.streams.push(stream);
|
||||
const match = this.replies.find(([pattern]) => pattern.test(command));
|
||||
callback(undefined, stream);
|
||||
if (!match) {
|
||||
stream.finish("");
|
||||
return this;
|
||||
}
|
||||
const reply = match[1];
|
||||
if (typeof reply === "function") reply(stream);
|
||||
else stream.finish(reply.stdout ?? "", reply.code ?? 0, reply.stderr ?? "");
|
||||
return this;
|
||||
}
|
||||
|
||||
end() {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
this.emit("close");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import { createMockCtx } from "@termix/plugin-sdk/testing";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import {
|
||||
normalizeImportedHost,
|
||||
readDockerHostSettings,
|
||||
} from "../../src/backend/host-settings.js";
|
||||
import {
|
||||
diffContainerStates,
|
||||
parseContainerStates,
|
||||
} from "../../src/backend/container-state.js";
|
||||
import { manifest, startServer, type TestServer } from "./server";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("docker activate", () => {
|
||||
it("fails closed without network:serve", async () => {
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter(
|
||||
(cap) => cap !== "network:serve",
|
||||
),
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot reach a host without ssh:connect", async () => {
|
||||
server = await startServer({
|
||||
mock: {
|
||||
capabilities: manifest.capabilities.filter(
|
||||
(cap) => cap !== "ssh:connect",
|
||||
),
|
||||
},
|
||||
});
|
||||
const response = await server.request("POST", "/ssh/connect", {
|
||||
body: { sessionId: "s1", hostId: 7 },
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("registers its import normalizer", async () => {
|
||||
server = await startServer();
|
||||
expect(
|
||||
server.mock.ctx.registry.consume("docker.hostImportNormalizer"),
|
||||
).toBe(normalizeImportedHost);
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker host settings", () => {
|
||||
it("reads the switch and runtime with defaults", async () => {
|
||||
server = await startServer({ dockerOn: false });
|
||||
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
|
||||
enabled: false,
|
||||
runtime: "docker",
|
||||
});
|
||||
await server.mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
|
||||
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
|
||||
enabled: true,
|
||||
runtime: "podman",
|
||||
});
|
||||
});
|
||||
|
||||
it("takes an export's nested settings over the old flat fields", () => {
|
||||
expect(
|
||||
normalizeImportedHost({
|
||||
enableDocker: false,
|
||||
pluginSettings: {
|
||||
docker: { enableDocker: true, containerRuntime: "podman" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ enableDocker: true, containerRuntime: "podman" });
|
||||
});
|
||||
|
||||
it("reads 2.8 flat fields and proxmox-created rows", () => {
|
||||
expect(
|
||||
normalizeImportedHost({
|
||||
enableDocker: 1,
|
||||
dockerConfig: '{"runtime":"podman"}',
|
||||
}),
|
||||
).toEqual({ enableDocker: true, containerRuntime: "podman" });
|
||||
expect(normalizeImportedHost({ enableDocker: true })).toEqual({
|
||||
enableDocker: true,
|
||||
});
|
||||
expect(normalizeImportedHost({ name: "plain" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The polling side needs SSH, so what is tested here is the pure part:
|
||||
* turning `ps` output into states, and two snapshots into events.
|
||||
*/
|
||||
describe("container state", () => {
|
||||
it("reads name, state and health out of the ps output", () => {
|
||||
const states = parseContainerStates(
|
||||
[
|
||||
'{"name":"web","state":"running","status":"Up 2 hours"}',
|
||||
"",
|
||||
"not json at all",
|
||||
'{"state":"running"}',
|
||||
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}',
|
||||
].join("\n"),
|
||||
);
|
||||
expect([...states.keys()]).toEqual(["web", "api"]);
|
||||
expect(states.get("api")).toEqual({ state: "running", unhealthy: true });
|
||||
});
|
||||
|
||||
it("reports transitions and skips containers seen for the first time", () => {
|
||||
const running = { state: "running", unhealthy: false };
|
||||
const exited = { state: "exited", unhealthy: false };
|
||||
const restarting = { state: "restarting", unhealthy: false };
|
||||
expect(
|
||||
diffContainerStates(
|
||||
new Map([
|
||||
["web", running],
|
||||
["db", exited],
|
||||
["cache", running],
|
||||
]),
|
||||
new Map([
|
||||
["web", exited],
|
||||
["db", running],
|
||||
["cache", restarting],
|
||||
["new", running],
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
{ container: "web", event: "exited" },
|
||||
{ container: "db", event: "started" },
|
||||
{ container: "cache", event: "restarting" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { startServer, type TestServer } from "./server";
|
||||
import { FakeClient } from "./fake-ssh";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function connect(s: TestServer, sessionId = "s1", hostId = 7) {
|
||||
return s.request("POST", "/ssh/connect", { body: { sessionId, hostId } });
|
||||
}
|
||||
|
||||
/** Swaps ctx.ssh.connect for one that runs `flow` against the prompt channel. */
|
||||
function promptingConnect(
|
||||
s: TestServer,
|
||||
flow: (
|
||||
ask: NonNullable<PluginSshConnectOptions["prompt"]>["ask"],
|
||||
) => Promise<void>,
|
||||
) {
|
||||
s.mock.ctx.ssh.connect = (async (
|
||||
host: PluginSshHost,
|
||||
options?: PluginSshConnectOptions,
|
||||
) => {
|
||||
await flow(options!.prompt!.ask);
|
||||
return {
|
||||
client: s.client as never,
|
||||
jumpClient: null,
|
||||
host,
|
||||
dispose: () => s.client.end(),
|
||||
};
|
||||
}) as never;
|
||||
}
|
||||
|
||||
describe("docker routes", () => {
|
||||
it("refuses every route without docker.use", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
const calls: Array<[string, string]> = [
|
||||
["POST", "/ssh/connect"],
|
||||
["POST", "/ssh/connect-totp"],
|
||||
["POST", "/ssh/connect-browser-sign-in"],
|
||||
["POST", "/ssh/disconnect"],
|
||||
["POST", "/ssh/keepalive"],
|
||||
["GET", "/ssh/status?sessionId=s1"],
|
||||
["GET", "/validate/s1"],
|
||||
["GET", "/containers/s1"],
|
||||
["GET", "/containers/s1/abc"],
|
||||
["POST", "/containers/s1/abc/start"],
|
||||
["DELETE", "/containers/s1/abc/remove"],
|
||||
["GET", "/containers/s1/abc/logs"],
|
||||
["GET", "/containers/s1/abc/stats"],
|
||||
];
|
||||
for (const [method, path] of calls) {
|
||||
const response = await server.request(method, path, {
|
||||
body: method === "GET" ? undefined : {},
|
||||
});
|
||||
expect(response.status, `${method} ${path}`).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a host with Docker switched off", async () => {
|
||||
server = await startServer({ dockerOn: false });
|
||||
const response = await connect(server);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.code).toBe("DOCKER_DISABLED");
|
||||
});
|
||||
|
||||
it("answers 404 for a host the user cannot reach", async () => {
|
||||
server = await startServer();
|
||||
expect((await connect(server, "s1", 99)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("connects, validates and manages containers on the session", async () => {
|
||||
server = await startServer();
|
||||
const connected = await connect(server);
|
||||
expect(connected.status).toBe(200);
|
||||
expect(connected.body.success).toBe(true);
|
||||
expect(server.mock.statusReports).toContainEqual({ hostId: 7, ok: true });
|
||||
|
||||
const validation = await server.request("GET", "/validate/s1");
|
||||
expect(validation.body).toEqual({
|
||||
available: true,
|
||||
version: "27.1.1",
|
||||
runtime: "docker",
|
||||
});
|
||||
|
||||
const list = await server.request("GET", "/containers/s1");
|
||||
expect(list.body).toEqual([
|
||||
expect.objectContaining({ id: "abc123", name: "web", state: "running" }),
|
||||
]);
|
||||
|
||||
server.client.reply(/ start abc123$/, { stdout: "abc123" });
|
||||
const started = await server.request("POST", "/containers/s1/abc123/start");
|
||||
expect(started.status).toBe(200);
|
||||
expect(
|
||||
server.client.commands.some((c) => c.endsWith("docker start abc123")),
|
||||
).toBe(true);
|
||||
|
||||
server.client.reply(/ logs abc123/, { stdout: "line one\nline two\n" });
|
||||
const logs = await server.request(
|
||||
"GET",
|
||||
"/containers/s1/abc123/logs?tail=50&since=2026-01-01T00:00:00Z",
|
||||
);
|
||||
expect(logs.body).toEqual({ success: true, logs: "line one\nline two\n" });
|
||||
expect(server.client.commands.at(-1)).toContain(
|
||||
"logs abc123 --tail 50 --since 2026-01-01T00:00:00Z 2>&1",
|
||||
);
|
||||
|
||||
server.client.reply(/ stats abc123/, {
|
||||
stdout:
|
||||
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
|
||||
});
|
||||
const stats = await server.request("GET", "/containers/s1/abc123/stats");
|
||||
expect(stats.body).toMatchObject({
|
||||
cpu: "1.5%",
|
||||
memoryUsed: "10MiB",
|
||||
memoryLimit: "1GiB",
|
||||
netInput: "1kB",
|
||||
netOutput: "2kB",
|
||||
});
|
||||
|
||||
server.client.reply(/ rm abc123/, {
|
||||
code: 1,
|
||||
stderr: "Error: No such container: abc123",
|
||||
});
|
||||
const removed = await server.request(
|
||||
"DELETE",
|
||||
"/containers/s1/abc123/remove",
|
||||
);
|
||||
expect(removed.status).toBe(404);
|
||||
|
||||
expect(
|
||||
(await server.request("POST", "/containers/s1/abc123/explode")).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await server.request("GET", "/containers/s1/bad;id/logs")).status,
|
||||
).toBe(400);
|
||||
|
||||
await server.request("POST", "/ssh/disconnect", {
|
||||
body: { sessionId: "s1" },
|
||||
});
|
||||
const status = await server.request("GET", "/ssh/status?sessionId=s1");
|
||||
expect(status.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Podman when the host says so", async () => {
|
||||
server = await startServer();
|
||||
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
|
||||
await connect(server);
|
||||
await server.request("GET", "/containers/s1");
|
||||
expect(server.client.commands.at(-1)).toMatch(/ podman ps -a --format/);
|
||||
});
|
||||
|
||||
it("keeps another user's session to its owner", async () => {
|
||||
server = await startServer();
|
||||
await connect(server);
|
||||
const response = await server.request("GET", "/containers/s1", {
|
||||
user: "user-2",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const status = await server.request("GET", "/ssh/status?sessionId=s1", {
|
||||
user: "user-2",
|
||||
});
|
||||
expect(status.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it("parks a TOTP prompt and re-asks after a wrong code", async () => {
|
||||
server = await startServer();
|
||||
promptingConnect(server, async (ask) => {
|
||||
let code = await ask({ kind: "totp", prompt: "Code:", retry: false });
|
||||
while (code !== "123456") {
|
||||
if (code === null) throw new Error("Cancelled");
|
||||
code = await ask({ kind: "totp", prompt: "Code:", retry: true });
|
||||
}
|
||||
});
|
||||
|
||||
const first = await connect(server);
|
||||
expect(first.body).toMatchObject({ requires_totp: true, prompt: "Code:" });
|
||||
|
||||
const pending = await server.request("GET", "/containers/s1");
|
||||
expect(pending.body.code).toBe("AUTH_PENDING");
|
||||
|
||||
const wrong = await server.request("POST", "/ssh/connect-totp", {
|
||||
body: { sessionId: "s1", totpCode: "000000" },
|
||||
});
|
||||
expect(wrong.body).toMatchObject({ requires_totp: true, retry: true });
|
||||
|
||||
const stranger = await server.request("POST", "/ssh/connect-totp", {
|
||||
user: "user-2",
|
||||
body: { sessionId: "s1", totpCode: "123456" },
|
||||
});
|
||||
expect(stranger.status).toBe(404);
|
||||
|
||||
const right = await server.request("POST", "/ssh/connect-totp", {
|
||||
body: { sessionId: "s1", totpCode: "123456" },
|
||||
});
|
||||
expect(right.body).toMatchObject({ success: true, status: "success" });
|
||||
expect((await server.request("GET", "/containers/s1")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("continues a browser sign-in and refuses a code for it", async () => {
|
||||
server = await startServer();
|
||||
promptingConnect(server, async (ask) => {
|
||||
await ask({
|
||||
kind: "browser",
|
||||
id: "gateway",
|
||||
label: "Gateway",
|
||||
url: "https://gateway.example/login",
|
||||
code: "KEY",
|
||||
instructions: "",
|
||||
});
|
||||
});
|
||||
|
||||
const first = await connect(server);
|
||||
expect(first.body).toMatchObject({
|
||||
requires_browser_sign_in: true,
|
||||
label: "Gateway",
|
||||
url: "https://gateway.example/login",
|
||||
code: "KEY",
|
||||
});
|
||||
const wrongKind = await server.request("POST", "/ssh/connect-totp", {
|
||||
body: { sessionId: "s1", totpCode: "1" },
|
||||
});
|
||||
expect(wrongKind.status).toBe(400);
|
||||
|
||||
const done = await server.request("POST", "/ssh/connect-browser-sign-in", {
|
||||
body: { sessionId: "s1" },
|
||||
});
|
||||
expect(done.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it("asks for credentials when a host with no secret gets a password prompt", async () => {
|
||||
server = await startServer({
|
||||
mock: {
|
||||
sshHosts: [
|
||||
{
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "none",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
promptingConnect(server, async (ask) => {
|
||||
const answer = await ask({
|
||||
kind: "input",
|
||||
prompt: "Password:",
|
||||
echo: false,
|
||||
isPush: false,
|
||||
});
|
||||
if (answer === null)
|
||||
throw new Error("All configured authentication methods failed");
|
||||
});
|
||||
|
||||
const response = await connect(server);
|
||||
expect(response.body).toEqual({
|
||||
status: "auth_required",
|
||||
reason: "no_keyboard",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a failed login to core", async () => {
|
||||
const client = new FakeClient();
|
||||
server = await startServer({ client });
|
||||
server.mock.ctx.ssh.connect = (async () => {
|
||||
throw new Error("All configured authentication methods failed");
|
||||
}) as never;
|
||||
const response = await connect(server);
|
||||
expect(response.status).toBe(500);
|
||||
expect(server.mock.statusReports).toContainEqual({
|
||||
hostId: 7,
|
||||
ok: false,
|
||||
hostKeyChanged: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockContextOptions,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { FakeClient } from "./fake-ssh";
|
||||
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export const PS_LINE =
|
||||
'{"id":"abc123","name":"web","image":"nginx","status":"Up 2 hours","state":"running","ports":"80/tcp","created":"2026-01-01"}';
|
||||
|
||||
/** A host owned by user-1, as ctx.ssh.resolveHost sees it. */
|
||||
export function sshHost(id: number, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
userId: "user-1",
|
||||
name: `host-${id}`,
|
||||
ip: `10.0.0.${id}`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "pw",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** A client that looks like a Linux host with Docker running. */
|
||||
export function dockerClient(): FakeClient {
|
||||
return new FakeClient([
|
||||
[/^ver$/, { stdout: "" }],
|
||||
[/--version/, { stdout: "Docker version 27.1.1, build abc" }],
|
||||
[/ ps -a --format/, { stdout: `${PS_LINE}\n` }],
|
||||
[/ ps$/, { stdout: "" }],
|
||||
]);
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
mock: MockPluginContext;
|
||||
client: FakeClient;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** The plugin activated with its router served the way core mounts it. */
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
dockerOn?: boolean;
|
||||
client?: FakeClient;
|
||||
mock?: Partial<MockContextOptions>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const client = options.client ?? dockerClient();
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ["docker.use"],
|
||||
sshHosts: [sshHost(7), sshHost(8)],
|
||||
sshClient: client,
|
||||
...options.mock,
|
||||
});
|
||||
if (options.dockerOn !== false) {
|
||||
await mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||
}
|
||||
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
mock,
|
||||
client,
|
||||
async request(method, path, { user = "user-1", body } = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
for (const dispose of mock.disposals.reverse()) await dispose();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
DockerEvent,
|
||||
DockerEventsV1,
|
||||
DockerServiceV1,
|
||||
} from "../../src/backend/index.js";
|
||||
import { startServer, type TestServer } from "./server";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
const psState = (state: string, status = "Up") =>
|
||||
`{"name":"web","state":"${state}","status":"${status}"}\n`;
|
||||
|
||||
describe("docker.containers service", () => {
|
||||
it("lists containers and runs actions as the caller", async () => {
|
||||
server = await startServer();
|
||||
const docker = server.mock.services.get(
|
||||
"docker.containers",
|
||||
) as DockerServiceV1;
|
||||
|
||||
const containers = await server.mock.ctx.asUser("user-1", () =>
|
||||
docker.listContainers(7),
|
||||
);
|
||||
expect(containers).toEqual([
|
||||
expect.objectContaining({ id: "abc123", name: "web" }),
|
||||
]);
|
||||
|
||||
await server.mock.ctx.asUser("user-1", () =>
|
||||
docker.action(7, "web", "restart"),
|
||||
);
|
||||
expect(server.client.commands.at(-1)).toMatch(/ docker restart web$/);
|
||||
expect(server.mock.sshConnections.at(-1)).toMatchObject({
|
||||
host: 7,
|
||||
pool: "docker",
|
||||
});
|
||||
|
||||
await expect(
|
||||
server.mock.ctx.asUser("user-1", () =>
|
||||
docker.action(7, "web; rm -rf /", "stop"),
|
||||
),
|
||||
).rejects.toThrow(/Invalid container name/);
|
||||
});
|
||||
|
||||
it("needs a calling user", async () => {
|
||||
server = await startServer();
|
||||
const docker = server.mock.services.get(
|
||||
"docker.containers",
|
||||
) as DockerServiceV1;
|
||||
await expect(docker.listContainers(7)).rejects.toThrow(/calling user/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker.events service", () => {
|
||||
it("reports state changes between polls to every subscriber", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
server = await startServer();
|
||||
const events = server.mock.services.get("docker.events") as DockerEventsV1;
|
||||
const seen: DockerEvent[] = [];
|
||||
const listener = (event: DockerEvent) => seen.push(event);
|
||||
|
||||
const unsubscribe = await server.mock.ctx.asUser("user-1", () =>
|
||||
events.subscribe(7, listener),
|
||||
);
|
||||
// Subscribing the same listener again is a no-op.
|
||||
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, listener));
|
||||
|
||||
const poll = async (output: string) => {
|
||||
server!.client.reply(/ ps -a --format/, { stdout: output });
|
||||
vi.setSystemTime(Date.now() + 61_000);
|
||||
await server!.mock.runScheduled();
|
||||
};
|
||||
|
||||
await poll(psState("running"));
|
||||
expect(seen).toEqual([]);
|
||||
|
||||
await poll(psState("exited", "Exited (1)"));
|
||||
expect(seen).toEqual([{ hostId: 7, container: "web", event: "exited" }]);
|
||||
|
||||
await poll(psState("running", "Up 1 second (unhealthy)"));
|
||||
expect(seen.slice(1)).toEqual([
|
||||
{ hostId: 7, container: "web", event: "started" },
|
||||
{ hostId: 7, container: "web", event: "unhealthy" },
|
||||
]);
|
||||
|
||||
unsubscribe();
|
||||
const before = server.client.commands.length;
|
||||
await poll(psState("exited"));
|
||||
expect(server.client.commands.length).toBe(before);
|
||||
expect(seen).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("stops polling once the plugin is disposed", async () => {
|
||||
server = await startServer();
|
||||
const events = server.mock.services.get("docker.events") as DockerEventsV1;
|
||||
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, () => {}));
|
||||
for (const dispose of [...server.mock.disposals].reverse()) await dispose();
|
||||
expect(server.mock.scheduled.every((job) => job.stopped)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
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;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
function host(enableDocker: boolean) {
|
||||
return {
|
||||
id: "7",
|
||||
name: "box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "root",
|
||||
enableSsh: true,
|
||||
pluginSettings: { docker: { enableDocker } },
|
||||
};
|
||||
}
|
||||
|
||||
function stubApi(connectResult: Record<string, unknown>) {
|
||||
const post = vi.fn(async (url: string) => {
|
||||
if (url === "/ssh/connect") return { data: connectResult };
|
||||
return { data: { success: true } };
|
||||
});
|
||||
const get = vi.fn(async (url: string) => {
|
||||
if (url.startsWith("/validate/")) {
|
||||
return {
|
||||
data: { available: true, version: "27.1.1", runtime: "docker" },
|
||||
};
|
||||
}
|
||||
if (url.startsWith("/containers/")) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: "abc123",
|
||||
name: "web",
|
||||
image: "nginx",
|
||||
status: "Up",
|
||||
state: "running",
|
||||
ports: "",
|
||||
created: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { data: {} };
|
||||
});
|
||||
return {
|
||||
post,
|
||||
get,
|
||||
delete: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
} as unknown as {
|
||||
post: typeof post;
|
||||
get: typeof get;
|
||||
} & PluginApiClient;
|
||||
}
|
||||
|
||||
describe("DockerManager", () => {
|
||||
it("says Docker is off for a host without the switch", async () => {
|
||||
const api = stubApi({ success: true });
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
hosts: [host(false)],
|
||||
api,
|
||||
});
|
||||
rendered.renderTab("docker", {
|
||||
host: host(false),
|
||||
label: "box",
|
||||
isVisible: true,
|
||||
});
|
||||
expect(
|
||||
await screen.findByText("Docker is not enabled for this host"),
|
||||
).toBeTruthy();
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("connects through the plugin api and lists containers", async () => {
|
||||
const api = stubApi({ success: true, status: "success" });
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
hosts: [host(true)],
|
||||
api,
|
||||
});
|
||||
rendered.renderTab("docker", {
|
||||
host: host(true),
|
||||
label: "box",
|
||||
isVisible: true,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/ssh/connect",
|
||||
expect.objectContaining({ hostId: 7 }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.get).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\/validate\//),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the code prompt when the host asks for one", async () => {
|
||||
const api = stubApi({
|
||||
requires_totp: true,
|
||||
sessionId: "s1",
|
||||
prompt: "Verification code:",
|
||||
});
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
hosts: [host(true)],
|
||||
api,
|
||||
});
|
||||
rendered.renderTab("docker", {
|
||||
host: host(true),
|
||||
label: "box",
|
||||
isVisible: true,
|
||||
});
|
||||
expect(await screen.findByText("Verification code:")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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);
|
||||
}
|
||||
// Host settings render from the manifest; there is no editor tab.
|
||||
expect(rendered.registered.hostEditorSections()).toEqual([]);
|
||||
expect(rendered.registered.hostActions()).toEqual([
|
||||
expect.objectContaining({ id: "docker", tabType: "docker" }),
|
||||
]);
|
||||
});
|
||||
|
||||
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,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
FALLBACK_CONSOLE_LOOK,
|
||||
useConsoleLook,
|
||||
} from "../../../src/frontend/components/console-look";
|
||||
|
||||
describe("useConsoleLook", () => {
|
||||
it("uses plain colors while the SSH terminal is off", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConsoleLook({ id: "1", name: "h", ip: "10.0.0.1", port: 22 }, "dark"),
|
||||
);
|
||||
await waitFor(() => expect(result.current).toBe(FALLBACK_CONSOLE_LOOK));
|
||||
expect(result.current.colors.background).toBe("#0c0d0b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user