mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15: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.
|
||||
|
||||
Shows live CPU, memory, disk, network and process stats for your hosts, with history charts. Also adds tools to manage services, packages, firewall rules, cron jobs, users, SSL certificates, logs and WireGuard.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Host Metrics
|
||||
|
||||
Live CPU, memory, disk, network and process stats, plus tools to manage services, packages, firewall rules, cron jobs, users and more.
|
||||
|
||||
## Features
|
||||
|
||||
- Live CPU, memory, disk, network, temperature and process stats.
|
||||
- History charts for each host.
|
||||
- Tools to manage services, packages, firewall rules, cron jobs, users, SSL certificates, logs and WireGuard.
|
||||
- A drag and drop layout you can change per host.
|
||||
- Other features can add their own tools. Tailscale does.
|
||||
|
||||
## Setup
|
||||
|
||||
The online dot in the host list comes from Termix itself, not this plugin. A working metrics login is what marks a reachable host as online.
|
||||
|
||||
## Settings
|
||||
|
||||
### Admin
|
||||
|
||||
- Metrics interval (seconds): how often a host is checked while someone views it.
|
||||
- History retention (days): how long history is kept.
|
||||
- Metrics on for new hosts: whether new hosts start with metrics turned on.
|
||||
|
||||
### User
|
||||
|
||||
- Temperature unit: Celsius or Fahrenheit.
|
||||
|
||||
### Host
|
||||
|
||||
- Collect metrics: turn metrics on for this host.
|
||||
- Metrics interval (seconds): override the admin interval.
|
||||
- Enabled widgets: which widgets to show.
|
||||
- Excluded mounts and Monitored paths: which disks to hide or watch.
|
||||
|
||||
## Permissions
|
||||
|
||||
- `host-metrics.use`: View metrics and use the host tools. Admins and users have it by default.
|
||||
|
||||
## Services
|
||||
|
||||
Provides to other plugins:
|
||||
|
||||
- `host-metrics.viewers`: read a host's current metrics.
|
||||
|
||||
## 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,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Host Metrics"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"network": "Network",
|
||||
"uptime": "Uptime",
|
||||
"processes": "Processes",
|
||||
"available": "Available",
|
||||
"free": "Free",
|
||||
"connecting": "Connecting...",
|
||||
"connectionFailed": "Failed to connect to server",
|
||||
"naCpus": "N/A CPU(s)",
|
||||
"cpuCores_one": "{{count}} Core",
|
||||
"cpuCores_other": "{{count}} Cores",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"diskUsage": "Disk Usage",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Temperature",
|
||||
"highestTemperature": "Highest temperature",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Failed to fetch host configuration",
|
||||
"serverOffline": "Server Offline",
|
||||
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||
"totpFailed": "TOTP verification failed",
|
||||
"noneAuthNotSupported": "Host Metrics does not support 'none' authentication type.",
|
||||
"noHostSelected": "No host selected",
|
||||
"load": "Load",
|
||||
"systemInfo": "System Information",
|
||||
"hostname": "Hostname",
|
||||
"operatingSystem": "Operating System",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "seconds",
|
||||
"networkInterfaces": "Network Interfaces",
|
||||
"noInterfacesFound": "No network interfaces found",
|
||||
"noProcessesFound": "No processes found",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "running",
|
||||
"loginStats": "SSH Login Statistics",
|
||||
"noRecentLoginData": "No recent login data",
|
||||
"ports": {
|
||||
"title": "Listening Ports",
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"address": "Address",
|
||||
"process": "Process",
|
||||
"search": "Search ports...",
|
||||
"allProtocols": "All",
|
||||
"noData": "No listening ports data"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inactive",
|
||||
"policy": "Policy",
|
||||
"rules": "rules",
|
||||
"noRules": "No rules in this chain",
|
||||
"noData": "No firewall data available",
|
||||
"action": "Action",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Source",
|
||||
"anywhere": "Anywhere",
|
||||
"chains": "chains"
|
||||
},
|
||||
"loadAvg": "Load Avg",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architecture",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"customize": "Customize layout",
|
||||
"reset": "Reset",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Drag cards to rearrange, drag the bottom edge to resize, and use the width button to change a card's span. Add or remove cards below.",
|
||||
"managers": {
|
||||
"services": "Services",
|
||||
"processInspector": "Process Inspector",
|
||||
"logViewer": "Log Viewer",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Packages",
|
||||
"ssl": "SSL Certificates",
|
||||
"firewall": "Firewall",
|
||||
"users": "Users & Permissions",
|
||||
"healthCheck": "Health Checks",
|
||||
"diskBreakdown": "Disk Breakdown",
|
||||
"systemdTimers": "Timers",
|
||||
"topMemory": "Top by Memory",
|
||||
"noData": "No data",
|
||||
"sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.",
|
||||
"filter": "Filter...",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"actionDone": "{{name}} updated",
|
||||
"actionFailed": "Action failed",
|
||||
"signalSent": "Signal sent to PID {{pid}}",
|
||||
"killHint": "Click: terminate (SIGTERM). Right-click: force kill (SIGKILL).",
|
||||
"working": "Working...",
|
||||
"update": "Update",
|
||||
"upgradeAll": "Upgrade all",
|
||||
"allUpToDate": "Everything is up to date",
|
||||
"save": "Save",
|
||||
"command": "Command",
|
||||
"enabled": "Enabled",
|
||||
"cronSaved": "Crontab updated",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Dry run",
|
||||
"renew": "Renew",
|
||||
"noAcmeClient": "No ACME client (certbot or acme.sh) found on this host.",
|
||||
"addInputRule": "Add INPUT rule",
|
||||
"firewallWarning": "Changes are runtime-only until persisted. Be careful not to lock yourself out.",
|
||||
"ruleApplied": "Rule applied",
|
||||
"invalidPort": "Enter a valid port (1-65535)",
|
||||
"newUsername": "New username",
|
||||
"addUser": "Add",
|
||||
"deleteUser": "Delete user",
|
||||
"noHealthChecks": "No health checks configured yet.",
|
||||
"follow": "Follow",
|
||||
"noLogData": "No log output.",
|
||||
"tree": "Tree",
|
||||
"enableDisable": "Enable / disable at boot",
|
||||
"grantSudo": "Grant sudo",
|
||||
"revokeSudo": "Revoke sudo",
|
||||
"sslIssueCert": "Issue certificate",
|
||||
"sslExpired": "Expired",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Enter at least one domain",
|
||||
"sslIssued": "Certificate issued",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (standalone)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS provider (e.g. cloudflare)",
|
||||
"sslIssueHint": "DNS provider credentials must already be configured on the host.",
|
||||
"sslRevoke": "Revoke certificate",
|
||||
"sslRevoked": "Certificate revoked",
|
||||
"sslRevokeConfirm": "Revoke and remove the certificate \"{{name}}\"? This cannot be undone.",
|
||||
"healthRun": "Run",
|
||||
"healthName": "Name",
|
||||
"healthTarget": "Host / address",
|
||||
"healthAddCheck": "Add check",
|
||||
"healthSaved": "Health checks saved",
|
||||
"healthMissingFields": "Each check needs a name and target",
|
||||
"logFile": "File",
|
||||
"logUnit": "Unit",
|
||||
"logCustomPath": "Custom path under /var/log (optional)",
|
||||
"logGrep": "Filter lines...",
|
||||
"firewallPersist": "Persist rules",
|
||||
"firewallPersisted": "Firewall rules persisted",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard is not installed on this host",
|
||||
"wgNoInterfaces": "No WireGuard interfaces configured",
|
||||
"wgInterfaceUp": "Bring up",
|
||||
"wgInterfaceDown": "Bring down",
|
||||
"wgBringingUp": "Bringing {{name}} up...",
|
||||
"wgBringingDown": "Bringing {{name}} down...",
|
||||
"wgInterfaceUpDone": "{{name}} is up",
|
||||
"wgInterfaceDownDone": "{{name}} is down",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Public key",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "Allowed IPs",
|
||||
"wgLastHandshake": "Last handshake",
|
||||
"wgHandshakeNever": "Never",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrics History",
|
||||
"historySuffix": "History",
|
||||
"cpuMemoryDisk": "CPU / Memory / Disk",
|
||||
"network": "Network",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "No history data available for this time range.",
|
||||
"custom": "Custom",
|
||||
"to": "to",
|
||||
"apply": "Apply",
|
||||
"viewHistory": "View History"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Host Metrics",
|
||||
"metricsCollectionLabel": "Metrics Collection",
|
||||
"enableMetricsLabel": "Enable Metrics",
|
||||
"enableMetricsDesc": "Collect CPU, RAM, disk, and other metrics from this host",
|
||||
"useGlobalMetrics": "Use Global Interval",
|
||||
"useGlobalMetricsDesc": "Override with the server-wide metrics interval",
|
||||
"metricsIntervalS": "Metrics Interval (s)",
|
||||
"metricsIntervalDesc2": "Seconds between metric snapshots",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Label (optional)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Add",
|
||||
"visibleWidgets": "Visible Widgets",
|
||||
"widgetsMovedToHostMetrics": "Cards are now added, arranged, and resized directly in the Host Metrics tab. Open Host Metrics for this host and use Customize to choose which cards are shown.",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrics History Retention",
|
||||
"metricsHistoryRetentionRange": "1 to 90 days",
|
||||
"days": "days"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "No data available",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range",
|
||||
"host": "Host",
|
||||
"loading": "Loading...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Hostname",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memory",
|
||||
"metricNetwork": "Network",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processes",
|
||||
"metricProcessesRunning": "running",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Uptime",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"noHostSelected": "No host selected",
|
||||
"selectHost": "Select a host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connected"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Gasheermetrieke"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "SVE",
|
||||
"memory": "Geheue",
|
||||
"disk": "Skyf",
|
||||
"network": "Netwerk",
|
||||
"uptime": "Optyd",
|
||||
"processes": "Prosesse",
|
||||
"available": "Beskikbaar",
|
||||
"free": "Gratis",
|
||||
"connecting": "Verbind...",
|
||||
"connectionFailed": "Kon nie aan bediener koppel nie",
|
||||
"naCpus": "N/A SVE(s)",
|
||||
"cpuCores_one": "{{count}} Kern",
|
||||
"cpuCores_other": "{{count}} Kerne",
|
||||
"cpuUsage": "SVE-gebruik",
|
||||
"memoryUsage": "Geheuegebruik",
|
||||
"diskUsage": "Skyfgebruik",
|
||||
"selectFilesystem": "Kies lêerstelsel",
|
||||
"temperature": "Temperatuur",
|
||||
"highestTemperature": "Hoogste temperatuur",
|
||||
"primaryTemperatureSensor": "Primêre sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "Geen NVIDIA GPU bespeur nie. Installeer die NVIDIA-drywer (dit verskaf nvidia-smi) om GPU-metrieke te sien.",
|
||||
"driver": "Bestuurder {{version}}",
|
||||
"utilization": "Benutting",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temperatuur",
|
||||
"power": "Krag",
|
||||
"fan": "Waaier",
|
||||
"processes": "GPU-prosesse",
|
||||
"noProcesses": "Geen prosesse gebruik die GPU nie"
|
||||
},
|
||||
"failedToFetchHostConfig": "Kon nie gasheerkonfigurasie haal nie",
|
||||
"serverOffline": "Bediener vanlyn",
|
||||
"cannotFetchMetrics": "Kan nie statistieke van vanlyn bediener haal nie",
|
||||
"totpFailed": "TOTP-verifikasie het misluk",
|
||||
"noneAuthNotSupported": "Host Metrics ondersteun nie die 'geen'-verifikasietipe nie.",
|
||||
"noHostSelected": "Geen gasheer gekies nie",
|
||||
"load": "Laai",
|
||||
"systemInfo": "Stelselinligting",
|
||||
"hostname": "Gasheernaam",
|
||||
"operatingSystem": "Bedryfstelsel",
|
||||
"kernel": "Kern",
|
||||
"seconds": "sekondes",
|
||||
"networkInterfaces": "Netwerkkoppelvlakke",
|
||||
"noInterfacesFound": "Geen netwerkkoppelvlakke gevind nie",
|
||||
"noProcessesFound": "Geen prosesse gevind nie",
|
||||
"processesTotal": "totaal",
|
||||
"processesRunning": "hardloop",
|
||||
"loginStats": "SSH-aanmeldingstatistieke",
|
||||
"noRecentLoginData": "Geen onlangse aanmelddata nie",
|
||||
"ports": {
|
||||
"title": "Luisterpoorte",
|
||||
"protocol": "Protokol",
|
||||
"port": "Hawe",
|
||||
"address": "Adres",
|
||||
"process": "Proses",
|
||||
"search": "Soek poorte...",
|
||||
"allProtocols": "Alles",
|
||||
"noData": "Geen luisterpoorte data nie"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Onaktief",
|
||||
"policy": "Beleid",
|
||||
"rules": "reëls",
|
||||
"noRules": "Geen reëls in hierdie ketting nie",
|
||||
"noData": "Geen firewalldata beskikbaar nie",
|
||||
"action": "Aksie",
|
||||
"protocol": "Proto",
|
||||
"port": "Hawe",
|
||||
"source": "Bron",
|
||||
"anywhere": "Enige plek",
|
||||
"chains": "kettings"
|
||||
},
|
||||
"loadAvg": "Laai Gem.",
|
||||
"swap": "Ruil",
|
||||
"architecture": "Argitektuur",
|
||||
"refresh": "Verfris",
|
||||
"retry": "Probeer weer",
|
||||
"customize": "Pasmaak uitleg",
|
||||
"reset": "Herstel",
|
||||
"tabLive": "Lewendig",
|
||||
"editModeInstructions": "Sleep kaarte om te herrangskik, sleep die onderste rand om die grootte te verander, en gebruik die breedteknoppie om 'n kaart se omvang te verander. Voeg kaarte hieronder by of verwyder hulle.",
|
||||
"managers": {
|
||||
"services": "Dienste",
|
||||
"processInspector": "Prosesinspekteur",
|
||||
"logViewer": "Logkyker",
|
||||
"cron": "Cron-werk",
|
||||
"packages": "Pakkette",
|
||||
"ssl": "SSL-sertifikate",
|
||||
"firewall": "Firewall",
|
||||
"users": "Gebruikers en Toestemmings",
|
||||
"healthCheck": "Gesondheidstoetse",
|
||||
"diskBreakdown": "Skyfverdeling",
|
||||
"systemdTimers": "Tydtellers",
|
||||
"topMemory": "Top volgens geheue",
|
||||
"noData": "Geen data nie",
|
||||
"sudoHint": "Stel 'n sudo-wagwoord vir hierdie gasheer in die gasheerredigeerder om bevoorregte aksies moontlik te maak.",
|
||||
"filter": "Filter...",
|
||||
"start": "Begin",
|
||||
"stop": "Stop",
|
||||
"restart": "Herbegin",
|
||||
"actionDone": "{{name}} opgedateer",
|
||||
"actionFailed": "Aksie het misluk",
|
||||
"signalSent": "Sein gestuur na PID {{pid}}",
|
||||
"killHint": "Klik: beëindig (SIGTERM). Regskliek: forseer doodmaak (SIGKILL).",
|
||||
"working": "Werk...",
|
||||
"update": "Opdatering",
|
||||
"upgradeAll": "Gradeer alles op",
|
||||
"allUpToDate": "Alles is op datum",
|
||||
"save": "Stoor",
|
||||
"command": "Bevel",
|
||||
"enabled": "Geaktiveer",
|
||||
"cronSaved": "Crontab opgedateer",
|
||||
"clients": "Kliënte",
|
||||
"dryRun": "Droë lopie",
|
||||
"renew": "Hernu",
|
||||
"noAcmeClient": "Geen ACME-kliënt (certbot of acme.sh) op hierdie gasheer gevind nie.",
|
||||
"addInputRule": "Voeg INPUT-reël by",
|
||||
"firewallWarning": "Veranderinge is slegs looptyd totdat dit voortduur. Wees versigtig om jouself nie uit te sluit nie.",
|
||||
"ruleApplied": "Reël toegepas",
|
||||
"invalidPort": "Voer 'n geldige poort in (1-65535)",
|
||||
"newUsername": "Nuwe gebruikersnaam",
|
||||
"addUser": "Voeg by",
|
||||
"deleteUser": "Vee gebruiker uit",
|
||||
"noHealthChecks": "Geen gesondheidskontroles is nog opgestel nie.",
|
||||
"follow": "Volg",
|
||||
"noLogData": "Geen loguitvoer nie.",
|
||||
"tree": "Boom",
|
||||
"enableDisable": "Aktiveer/deaktiveer tydens opstart",
|
||||
"grantSudo": "Gee sudo toe",
|
||||
"revokeSudo": "Herroep sudo",
|
||||
"sslIssueCert": "Uitgawesertifikaat",
|
||||
"sslExpired": "Verstryk",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Voer ten minste een domein in",
|
||||
"sslIssued": "Sertifikaat uitgereik",
|
||||
"sslDomainsPlaceholder": "voorbeeld.com, www.voorbeeld.com",
|
||||
"sslHttpStandalone": "HTTP (losstaande)",
|
||||
"sslHttpWebroot": "HTTP (webwortel)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS-verskaffer (bv. cloudflare)",
|
||||
"sslIssueHint": "DNS-verskaffer se geloofsbriewe moet reeds op die gasheer gekonfigureer wees.",
|
||||
"sslRevoke": "Herroep sertifikaat",
|
||||
"sslRevoked": "Sertifikaat herroep",
|
||||
"sslRevokeConfirm": "Herroep en verwyder die sertifikaat \"{{name}}\"? Dit kan nie ongedaan gemaak word nie.",
|
||||
"healthRun": "Hardloop",
|
||||
"healthName": "Naam",
|
||||
"healthTarget": "Gasheer / adres",
|
||||
"healthAddCheck": "Voeg tjek by",
|
||||
"healthSaved": "Gesondheidskontroles gestoor",
|
||||
"healthMissingFields": "Elke tjek benodig 'n naam en teiken",
|
||||
"logFile": "Lêer",
|
||||
"logUnit": "Eenheid",
|
||||
"logCustomPath": "Pasgemaakte pad onder /var/log (opsioneel)",
|
||||
"logGrep": "Filterlyne...",
|
||||
"firewallPersist": "Behou reëls",
|
||||
"firewallPersisted": "Firewall-reëls het voortgeduur",
|
||||
"wireguard": "Draadbeskerming",
|
||||
"wgNotInstalled": "WireGuard is nie op hierdie gasheer geïnstalleer nie",
|
||||
"wgNoInterfaces": "Geen WireGuard-koppelvlakke gekonfigureer nie",
|
||||
"wgInterfaceUp": "Bring op",
|
||||
"wgInterfaceDown": "Bring af",
|
||||
"wgBringingUp": "Bring {{name}} op...",
|
||||
"wgBringingDown": "Bring {{name}} af...",
|
||||
"wgInterfaceUpDone": "{{name}} is op",
|
||||
"wgInterfaceDownDone": "{{name}} is af",
|
||||
"wgListenPort": "Hawe",
|
||||
"wgPublicKey": "Publieke sleutel",
|
||||
"wgEndpoint": "Eindpunt",
|
||||
"wgAllowedIPs": "Toegelate IP's",
|
||||
"wgLastHandshake": "Laaste handdruk",
|
||||
"wgHandshakeNever": "Nooit",
|
||||
"wgTransfer": "Oordrag",
|
||||
"wgNoPeers": "Geen eweknieë nie"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrieke Geskiedenis",
|
||||
"historySuffix": "Geskiedenis",
|
||||
"cpuMemoryDisk": "SVE / Geheue / Skyf",
|
||||
"network": "Netwerk",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Geen geskiedenisdata beskikbaar vir hierdie tydperk nie.",
|
||||
"custom": "Pasgemaak",
|
||||
"to": "om",
|
||||
"apply": "Dien toe",
|
||||
"viewHistory": "Bekyk Geskiedenis"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Gasheermetrieke",
|
||||
"metricsCollectionLabel": "Metrieke-versameling",
|
||||
"enableMetricsLabel": "Aktiveer Metrieke",
|
||||
"enableMetricsDesc": "Versamel SVE-, RAM-, skyf- en ander statistieke van hierdie gasheer",
|
||||
"useGlobalMetrics": "Gebruik globale interval",
|
||||
"useGlobalMetricsDesc": "Oorskryf met die bedienerwye metrieke-interval",
|
||||
"metricsIntervalS": "Metrieke Interval (s)",
|
||||
"metricsIntervalDesc2": "Sekondes tussen metrieke momentopnames",
|
||||
"excludedMountsLabel": "Uitgesluite Monterings",
|
||||
"monitoredMountsLabel": "Pasgemaakte gemonitorde paaie",
|
||||
"monitoredMountsDesc": "Voeg paaie by wat nie as aparte monterings blootgestel word nie, insluitend bind-gemonteerde Home Assistant-paaie soos /config.",
|
||||
"monitoredMountPathPlaceholder": "Pad (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Etiket (opsioneel)",
|
||||
"addMonitoredMount": "Voeg pad by",
|
||||
"excludedMountsDesc": "Versteek spesifieke koppelpunte of lêerstelseltipes (bv. nfs, cifs) van skyfgebruik en waarskuwings vir hierdie gasheer.",
|
||||
"excludedMountsPlaceholder": "Monteerpad (/mnt/nas) of lêerstelseltipe (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Geen koppelstukke uitgesluit nie. Alle opgespoorde lêerstelsels word getoon.",
|
||||
"addExcludedMount": "Voeg by",
|
||||
"visibleWidgets": "Sigbare Widgets",
|
||||
"widgetsMovedToHostMetrics": "Kaarte word nou direk in die Gasheermetrieke-oortjie bygevoeg, gerangskik en herskaal. Maak Gasheermetrieke vir hierdie gasheer oop en gebruik Pasmaak om te kies watter kaarte gewys word.",
|
||||
"hostNotFound": "Gasheer nie gevind nie",
|
||||
"loadingHost": "Laai gasheer..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrieke en waarskuwings",
|
||||
"feature_metrics_desc": "Volg die SVE, geheue en skyf oor tyd en word gewaarsku wanneer hulle sleg word."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrieke Geskiedenis Behoud",
|
||||
"metricsHistoryRetentionRange": "1 tot 90 dae",
|
||||
"days": "dae"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Geen data beskikbaar nie",
|
||||
"showCurrentValue": "Wys huidige waarde",
|
||||
"chartMetric": "Metrieke",
|
||||
"metricRange": "Reikwydte",
|
||||
"host": "Gasheer",
|
||||
"loading": "Laai tans...",
|
||||
"metricCpu": "SVE",
|
||||
"metricDisk": "Skyf",
|
||||
"metricHostname": "Gasheernaam",
|
||||
"metricKernel": "Kern",
|
||||
"metricMemory": "Geheue",
|
||||
"metricNetwork": "Netwerk",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Prosesse",
|
||||
"metricProcessesRunning": "hardloop",
|
||||
"metricProcessesTotal": "totaal",
|
||||
"metricUptime": "Optyd",
|
||||
"metricsNotAvailable": "Metrieke nie beskikbaar nie",
|
||||
"noHostSelected": "Geen gasheer gekies nie",
|
||||
"selectHost": "Kies 'n gasheer..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Regstreekse statistieke is nie beskikbaar nie"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "SVE",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Skyf"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Verbonde"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Gebruik gasheermetrieke",
|
||||
"description": "Bekyk gasheerstatistieke en gebruik die gasheerbestuurders (dienste, pakkette, firewall en meer) op gashere wat jy kan bereik."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrieke-interval (sekondes)",
|
||||
"description": "Hoe gereeld 'n gasheer wat besigtig word, gemonster word, tensy die gasheer sy eie interval stel."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Geskiedenisbewaring (dae)",
|
||||
"description": "Hoe lank word metrieke-monsters vir die geskiedenisgrafieke gehou."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrieke aan vir nuwe gashere",
|
||||
"description": "Of nuwe gashere begin met metrieke-insameling aangeskakel."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperatuureenheid",
|
||||
"description": "Hoe temperature in Host Metrics getoon word.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Versamel statistieke"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrieke-interval (sekondes)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Geaktiveerde widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Uitgesluite monterings"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Gemonitorde paaie"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "مقاييس المضيف"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "المعالج",
|
||||
"memory": "الذاكرة",
|
||||
"disk": "القرص",
|
||||
"network": "الشبكة",
|
||||
"uptime": "مدة التشغيل",
|
||||
"processes": "العمليات",
|
||||
"available": "المتاح",
|
||||
"free": "الخالي",
|
||||
"connecting": "جارٍ الاتصال...",
|
||||
"connectionFailed": "فشل الاتصال بالخادم",
|
||||
"naCpus": "غير متوفر",
|
||||
"cpuCores_one": "{{count}} نواة",
|
||||
"cpuCores_other": "{{count}} نوى",
|
||||
"cpuUsage": "استخدام المعالج",
|
||||
"memoryUsage": "استخدام الذاكرة",
|
||||
"diskUsage": "استخدام القرص",
|
||||
"selectFilesystem": "حدد نظام الملفات",
|
||||
"temperature": "درجة الحرارة",
|
||||
"highestTemperature": "أعلى درجة حرارة",
|
||||
"primaryTemperatureSensor": "المستشعر الأساسي",
|
||||
"gpu": {
|
||||
"title": "وحدة معالجة الرسومات",
|
||||
"noDevice": "لم يتم الكشف عن وحدة معالجة رسومات NVIDIA. قم بتثبيت برنامج تشغيل NVIDIA (يوفر nvidia-smi) لعرض مقاييس وحدة معالجة الرسومات.",
|
||||
"driver": "السائق {{version}}",
|
||||
"utilization": "استخدام",
|
||||
"memory": "ذاكرة الوصول العشوائي للفيديو",
|
||||
"temperature": "درجة حرارة",
|
||||
"power": "قوة",
|
||||
"fan": "معجب",
|
||||
"processes": "عمليات وحدة معالجة الرسومات",
|
||||
"noProcesses": "لا توجد عمليات تستخدم وحدة معالجة الرسومات (GPU)."
|
||||
},
|
||||
"failedToFetchHostConfig": "فشل جلب تكوين المضيف",
|
||||
"serverOffline": "الخادم غير متصل",
|
||||
"cannotFetchMetrics": "لا يمكن جلب المؤشرات من خادم غير متصل",
|
||||
"totpFailed": "فشل التحقق من TOTP",
|
||||
"noneAuthNotSupported": "مقاييس المضيف لا تدعم نوع المصادقة 'none'.",
|
||||
"noHostSelected": "لم يتم تحديد مضيف",
|
||||
"load": "الحمل",
|
||||
"systemInfo": "معلومات النظام",
|
||||
"hostname": "اسم المضيف",
|
||||
"operatingSystem": "نظام التشغيل",
|
||||
"kernel": "النواة",
|
||||
"seconds": "ثوانٍ",
|
||||
"networkInterfaces": "واجهات الشبكة",
|
||||
"noInterfacesFound": "لم يتم العثور على واجهات شبكة",
|
||||
"noProcessesFound": "لم يتم العثور على عمليات",
|
||||
"processesTotal": "الإجمالي",
|
||||
"processesRunning": "قيد التشغيل",
|
||||
"loginStats": "إحصائيات تسجيل الدخول عبر SSH",
|
||||
"noRecentLoginData": "لا توجد بيانات تسجيل دخول حديثة",
|
||||
"ports": {
|
||||
"title": "المنافذ المستمعة",
|
||||
"protocol": "البروتوكول",
|
||||
"port": "المنفذ",
|
||||
"address": "العنوان",
|
||||
"process": "العملية",
|
||||
"search": "بحث في المنافذ...",
|
||||
"allProtocols": "الكل",
|
||||
"noData": "لا توجد بيانات للمنافذ المستمعة"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "جدار الحماية",
|
||||
"inactive": "غير نشط",
|
||||
"policy": "السياسة",
|
||||
"rules": "قواعد",
|
||||
"noRules": "لا توجد قواعد في هذه السلسلة",
|
||||
"noData": "لا توجد بيانات لجدار الحماية",
|
||||
"action": "الإجراء",
|
||||
"protocol": "البروتوكول",
|
||||
"port": "المنفذ",
|
||||
"source": "المصدر",
|
||||
"anywhere": "أي مكان",
|
||||
"chains": "السلاسل"
|
||||
},
|
||||
"loadAvg": "متوسط التحميل",
|
||||
"swap": "التبديل",
|
||||
"architecture": "المعمارية",
|
||||
"refresh": "تحديث",
|
||||
"retry": "إعادة المحاولة",
|
||||
"customize": "تخصيص التخطيط",
|
||||
"reset": "إعادة تعيين",
|
||||
"tabLive": "مباشر",
|
||||
"editModeInstructions": "اسحب البطاقات لإعادة ترتيبها، واسحب الحافة السفلية لتغيير حجمها، واستخدم زر العرض لتغيير امتداد البطاقة. أضف البطاقات أو أزلها أدناه.",
|
||||
"managers": {
|
||||
"services": "الخدمات",
|
||||
"processInspector": "مفتش العمليات",
|
||||
"logViewer": "عارض السجلات",
|
||||
"cron": "مهام كرون",
|
||||
"packages": "الحزم",
|
||||
"ssl": "شهادات SSL",
|
||||
"firewall": "جدار الحماية",
|
||||
"users": "المستخدمون والأذونات",
|
||||
"healthCheck": "فحوصات الصحة",
|
||||
"diskBreakdown": "تفصيل القرص",
|
||||
"systemdTimers": "المؤقتات",
|
||||
"topMemory": "الأعلى استهلاكًا للذاكرة",
|
||||
"noData": "لا توجد بيانات",
|
||||
"sudoHint": "قم بتعيين كلمة مرور sudo لهذا المضيف في محرر المضيف لتمكين الإجراءات المميزة.",
|
||||
"filter": "تصفية...",
|
||||
"start": "بدء",
|
||||
"stop": "إيقاف",
|
||||
"restart": "إعادة تشغيل",
|
||||
"actionDone": "تم تحديث {{name}}",
|
||||
"actionFailed": "فشل الإجراء",
|
||||
"signalSent": "تم إرسال إشارة إلى PID {{pid}}",
|
||||
"killHint": "نقرة: إنهاء (SIGTERM). نقرة بزر الماوس الأيمن: إنهاء إجباري (SIGKILL).",
|
||||
"working": "جارٍ العمل...",
|
||||
"update": "تحديث",
|
||||
"upgradeAll": "ترقية الكل",
|
||||
"allUpToDate": "كل شيء محدث",
|
||||
"save": "حفظ",
|
||||
"command": "أمر",
|
||||
"enabled": "مفعل",
|
||||
"cronSaved": "تم تحديث crontab",
|
||||
"clients": "العملاء",
|
||||
"dryRun": "تشغيل تجريبي",
|
||||
"renew": "تجديد",
|
||||
"noAcmeClient": "لم يتم العثور على عميل ACME (certbot أو acme.sh) على هذا المضيف.",
|
||||
"addInputRule": "إضافة قاعدة INPUT",
|
||||
"firewallWarning": "التغييرات مؤقتة حتى يتم حفظها. احذر من قفل وصولك.",
|
||||
"ruleApplied": "تم تطبيق القاعدة",
|
||||
"invalidPort": "أدخل منفذًا صالحًا (1-65535)",
|
||||
"newUsername": "اسم مستخدم جديد",
|
||||
"addUser": "إضافة",
|
||||
"deleteUser": "حذف المستخدم",
|
||||
"noHealthChecks": "لم يتم تكوين فحوصات الصحة بعد.",
|
||||
"follow": "متابعة",
|
||||
"noLogData": "لا توجد مخرجات سجل.",
|
||||
"tree": "شجري",
|
||||
"enableDisable": "تفعيل / تعطيل عند الإقلاع",
|
||||
"grantSudo": "منح sudo",
|
||||
"revokeSudo": "سحب sudo",
|
||||
"sslIssueCert": "إصدار شهادة",
|
||||
"sslExpired": "منتهية الصلاحية",
|
||||
"sslInDays": "خلال {{days}} يومًا",
|
||||
"sslNeedDomain": "أدخل نطاقًا واحدًا على الأقل",
|
||||
"sslIssued": "تم إصدار الشهادة",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (مستقل)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "مزود DNS (مثل cloudflare)",
|
||||
"sslIssueHint": "يجب أن تكون بيانات اعتماد مزود DNS قد تم تكوينها مسبقًا على المضيف.",
|
||||
"sslRevoke": "إلغاء الشهادة",
|
||||
"sslRevoked": "تم إلغاء الشهادة",
|
||||
"sslRevokeConfirm": "هل تريد إلغاء الشهادة \"{{name}}\" وإزالتها؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"healthRun": "تشغيل",
|
||||
"healthName": "الاسم",
|
||||
"healthTarget": "المضيف / العنوان",
|
||||
"healthAddCheck": "إضافة فحص",
|
||||
"healthSaved": "تم حفظ فحوصات الصحة",
|
||||
"healthMissingFields": "يتطلب كل فحص اسمًا وهدفًا",
|
||||
"logFile": "ملف",
|
||||
"logUnit": "وحدة",
|
||||
"logCustomPath": "مسار مخصص تحت /var/log (اختياري)",
|
||||
"logGrep": "تصفية السطور...",
|
||||
"firewallPersist": "حفظ القواعد",
|
||||
"firewallPersisted": "تم حفظ قواعد جدار الحماية",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard غير مثبت على هذا المضيف",
|
||||
"wgNoInterfaces": "لا توجد واجهات WireGuard مُعدّة",
|
||||
"wgInterfaceUp": "رفع",
|
||||
"wgInterfaceDown": "إنزال",
|
||||
"wgBringingUp": "جارٍ رفع {{name}}...",
|
||||
"wgBringingDown": "جارٍ إنزال {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} مرفوع",
|
||||
"wgInterfaceDownDone": "{{name}} منزّل",
|
||||
"wgListenPort": "المنفذ",
|
||||
"wgPublicKey": "المفتاح العام",
|
||||
"wgEndpoint": "نقطة النهاية",
|
||||
"wgAllowedIPs": "عناوين IP المسموح بها",
|
||||
"wgLastHandshake": "آخر مصافحة",
|
||||
"wgHandshakeNever": "أبدًا",
|
||||
"wgTransfer": "نقل",
|
||||
"wgNoPeers": "لا يوجد نظراء"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "سجل المقاييس",
|
||||
"historySuffix": "السجل",
|
||||
"cpuMemoryDisk": "وحدة المعالجة المركزية / الذاكرة / القرص",
|
||||
"network": "الشبكة",
|
||||
"download": "استقبال",
|
||||
"upload": "إرسال",
|
||||
"noData": "لا توجد بيانات تاريخية متاحة لهذا النطاق الزمني.",
|
||||
"custom": "مخصص",
|
||||
"to": "إلى",
|
||||
"apply": "تطبيق",
|
||||
"viewHistory": "عرض السجل"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "مقاييس المضيف",
|
||||
"metricsCollectionLabel": "تجميع المقاييس",
|
||||
"enableMetricsLabel": "تمكين المقاييس",
|
||||
"enableMetricsDesc": "تجميع مقاييس وحدة المعالجة المركزية والذاكرة والقرص وغيرها من هذا المضيف",
|
||||
"useGlobalMetrics": "استخدام الفاصل الزمني العام",
|
||||
"useGlobalMetricsDesc": "تجاوز باستخدام فترة المقاييس على مستوى الخادم",
|
||||
"metricsIntervalS": "فترة المقاييس (ثانية)",
|
||||
"metricsIntervalDesc2": "الثواني بين لقطات المقاييس",
|
||||
"excludedMountsLabel": "التركيبات المستثناة",
|
||||
"monitoredMountsLabel": "مسارات مراقبة مخصصة",
|
||||
"monitoredMountsDesc": "أضف المسارات التي لا يتم عرضها كعمليات ربط منفصلة، بما في ذلك مسارات Home Assistant المرتبطة مثل /config.",
|
||||
"monitoredMountPathPlaceholder": "المسار (/config، /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "وسم (اختياري)",
|
||||
"addMonitoredMount": "إضافة مسار",
|
||||
"excludedMountsDesc": "إخفاء نقاط التحميل المحددة أو أنواع أنظمة الملفات (مثل nfs و cifs) من استخدام القرص والتنبيهات لهذا المضيف.",
|
||||
"excludedMountsPlaceholder": "مسار التثبيت (/mnt/nas) أو نوع نظام الملفات (nfs، cifs)",
|
||||
"excludedMountsEmpty": "لم يتم استبعاد أي وحدات تخزين. يتم عرض جميع أنظمة الملفات المكتشفة.",
|
||||
"addExcludedMount": "إضافة",
|
||||
"visibleWidgets": "الأدوات المرئية",
|
||||
"widgetsMovedToHostMetrics": "تتم الآن إضافة البطاقات وترتيبها وتغيير حجمها مباشرةً في علامة تبويب مقاييس المضيف. افتح مقاييس المضيف لهذا المضيف واستخدم تخصيص لاختيار البطاقات التي ستظهر.",
|
||||
"hostNotFound": "المضيف غير موجود",
|
||||
"loadingHost": "جارٍ تحميل المضيف..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "المقاييس والتنبيهات",
|
||||
"feature_metrics_desc": "تتبع أداء وحدة المعالجة المركزية والذاكرة والقرص بمرور الوقت، واحصل على تنبيهات عند حدوث مشاكل فيها."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "الاحتفاظ بسجل المقاييس",
|
||||
"metricsHistoryRetentionRange": "من 1 إلى 90 يومًا",
|
||||
"days": "أيام"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "لا توجد بيانات",
|
||||
"showCurrentValue": "إظهار القيمة الحالية",
|
||||
"chartMetric": "المقياس",
|
||||
"metricRange": "النطاق",
|
||||
"host": "المضيف",
|
||||
"loading": "جار التحميل...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "القرص",
|
||||
"metricHostname": "اسم المضيف",
|
||||
"metricKernel": "النواة",
|
||||
"metricMemory": "الذاكرة",
|
||||
"metricNetwork": "الشبكة",
|
||||
"metricOs": "نظام التشغيل",
|
||||
"metricProcesses": "العمليات",
|
||||
"metricProcessesRunning": "قيد التشغيل",
|
||||
"metricProcessesTotal": "إجمالي",
|
||||
"metricUptime": "وقت التشغيل",
|
||||
"metricsNotAvailable": "المقاييس غير متوفرة",
|
||||
"noHostSelected": "لم يتم تحديد أي مضيف",
|
||||
"selectHost": "اختيار مضيف..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "الإحصائيات المباشرة غير متوفرة"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "المعالج",
|
||||
"ram": "الذاكرة"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "القرص"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "متصل"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "استخدام مقاييس المضيف",
|
||||
"description": "يمكنك عرض مقاييس المضيف واستخدام مديري المضيف (الخدمات والحزم وجدار الحماية والمزيد) على المضيفين الذين يمكنك الوصول إليهم."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "فاصل القياسات (بالثواني)",
|
||||
"description": "عدد مرات أخذ عينات من المضيف الذي تتم مشاهدته، ما لم يحدد المضيف الفاصل الزمني الخاص به."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "مدة الاحتفاظ بالسجل (بالأيام)",
|
||||
"description": "مدة الاحتفاظ بعينات المقاييس في مخططات التاريخ."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "تم تفعيل المقاييس للمضيفين الجدد",
|
||||
"description": "ما إذا كانت الأجهزة المضيفة الجديدة تبدأ بتفعيل خاصية جمع البيانات الإحصائية."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "وحدة قياس درجة الحرارة",
|
||||
"description": "كيفية عرض درجات الحرارة في مقاييس المضيف.",
|
||||
"celsius": "درجة مئوية",
|
||||
"fahrenheit": "فهرنهايت"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "جمع المقاييس"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "فاصل القياسات (بالثواني)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "الأدوات المُفعّلة"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "التركيبات المستثناة"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "المسارات المراقبة"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Метрики на хоста"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "Процесор",
|
||||
"memory": "Памет",
|
||||
"disk": "Диск",
|
||||
"network": "Мрежа",
|
||||
"uptime": "Време на работа",
|
||||
"processes": "Процеси",
|
||||
"available": "Налично",
|
||||
"free": "Безплатно",
|
||||
"connecting": "Свързване...",
|
||||
"connectionFailed": "Неуспешно свързване със сървъра",
|
||||
"naCpus": "Няма процесор(и)",
|
||||
"cpuCores_one": "{{count}} Ядро",
|
||||
"cpuCores_other": "{{count}} Ядра",
|
||||
"cpuUsage": "Използване на процесора",
|
||||
"memoryUsage": "Използване на паметта",
|
||||
"diskUsage": "Използване на диска",
|
||||
"selectFilesystem": "Изберете файлова система",
|
||||
"temperature": "Температура",
|
||||
"highestTemperature": "Най-висока температура",
|
||||
"primaryTemperatureSensor": "Първичен сензор",
|
||||
"gpu": {
|
||||
"title": "Графичен процесор",
|
||||
"noDevice": "Не е открит графичен процесор NVIDIA. Инсталирайте драйвера на NVIDIA (той предоставя nvidia-smi), за да видите показателите на графичния процесор.",
|
||||
"driver": "Шофьор {{version}}",
|
||||
"utilization": "Употреба",
|
||||
"memory": "Видео памет (VRAM)",
|
||||
"temperature": "Температура",
|
||||
"power": "Мощност",
|
||||
"fan": "Вентилатор",
|
||||
"processes": "Процеси на графичния процесор",
|
||||
"noProcesses": "Няма процеси, които да използват графичния процесор"
|
||||
},
|
||||
"failedToFetchHostConfig": "Неуспешно извличане на конфигурацията на хоста",
|
||||
"serverOffline": "Сървърът е офлайн",
|
||||
"cannotFetchMetrics": "Не могат да се извлекат показатели от офлайн сървър",
|
||||
"totpFailed": "Проверката на TOTP не бе успешна",
|
||||
"noneAuthNotSupported": "Метриките на хоста не поддържат тип удостоверяване „няма“.",
|
||||
"noHostSelected": "Няма избран хост",
|
||||
"load": "Зареждане",
|
||||
"systemInfo": "Системна информация",
|
||||
"hostname": "Име на хост",
|
||||
"operatingSystem": "Операционна система",
|
||||
"kernel": "Ядро",
|
||||
"seconds": "секунди",
|
||||
"networkInterfaces": "Мрежови интерфейси",
|
||||
"noInterfacesFound": "Не са намерени мрежови интерфейси",
|
||||
"noProcessesFound": "Не са намерени процеси",
|
||||
"processesTotal": "общо",
|
||||
"processesRunning": "бягане",
|
||||
"loginStats": "Статистика за SSH вход",
|
||||
"noRecentLoginData": "Няма скорошни данни за вход",
|
||||
"ports": {
|
||||
"title": "Портове за слушане",
|
||||
"protocol": "Протокол",
|
||||
"port": "Порт",
|
||||
"address": "Адрес",
|
||||
"process": "Процес",
|
||||
"search": "Търсене на портове...",
|
||||
"allProtocols": "Всички",
|
||||
"noData": "Няма данни за портове за слушане"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Защитна стена",
|
||||
"inactive": "Неактивен",
|
||||
"policy": "Политика",
|
||||
"rules": "правила",
|
||||
"noRules": "Няма правила в тази верига",
|
||||
"noData": "Няма налични данни за защитната стена",
|
||||
"action": "Действие",
|
||||
"protocol": "Прото",
|
||||
"port": "Порт",
|
||||
"source": "Източник",
|
||||
"anywhere": "Навсякъде",
|
||||
"chains": "вериги"
|
||||
},
|
||||
"loadAvg": "Зареждане на средно",
|
||||
"swap": "Размяна",
|
||||
"architecture": "Архитектура",
|
||||
"refresh": "Обновяване",
|
||||
"retry": "Опитай отново",
|
||||
"customize": "Персонализиране на оформлението",
|
||||
"reset": "Нулиране",
|
||||
"tabLive": "На живо",
|
||||
"editModeInstructions": "Плъзнете картите, за да ги пренаредите, плъзнете долния ръб, за да промените размера им, и използвайте бутона за ширина, за да промените обхвата на картата. Добавете или премахнете карти по-долу.",
|
||||
"managers": {
|
||||
"services": "Услуги",
|
||||
"processInspector": "Инспектор на процеси",
|
||||
"logViewer": "Преглед на лог файлове",
|
||||
"cron": "Cron задачи",
|
||||
"packages": "Пакети",
|
||||
"ssl": "SSL сертификати",
|
||||
"firewall": "Защитна стена",
|
||||
"users": "Потребители и разрешения",
|
||||
"healthCheck": "Проверки на състоянието",
|
||||
"diskBreakdown": "Разбивка на диска",
|
||||
"systemdTimers": "Таймери",
|
||||
"topMemory": "Най-горе по памет",
|
||||
"noData": "Няма данни",
|
||||
"sudoHint": "Задайте парола за sudo за този хост в редактора на хостове, за да активирате привилегировани действия.",
|
||||
"filter": "Филтър...",
|
||||
"start": "Старт",
|
||||
"stop": "Спри",
|
||||
"restart": "Рестартиране",
|
||||
"actionDone": "{{name}} актуализирано",
|
||||
"actionFailed": "Действието е неуспешно",
|
||||
"signalSent": "Сигнал, изпратен към PID {{pid}}",
|
||||
"killHint": "Щракване: прекратяване (SIGTERM). Щракване с десен бутон: принудително прекъсване (SIGKILL).",
|
||||
"working": "Работа...",
|
||||
"update": "Актуализация",
|
||||
"upgradeAll": "Надстройте всички",
|
||||
"allUpToDate": "Всичко е актуално",
|
||||
"save": "Запазване",
|
||||
"command": "Команда",
|
||||
"enabled": "Активирано",
|
||||
"cronSaved": "Crontab е актуализиран",
|
||||
"clients": "Клиенти",
|
||||
"dryRun": "Сухо изпълнение",
|
||||
"renew": "Подновяване",
|
||||
"noAcmeClient": "Не е намерен ACME клиент (certbot или acme.sh) на този хост.",
|
||||
"addInputRule": "Добавяне на правило INPUT",
|
||||
"firewallWarning": "Промените са само по време на изпълнение, докато не бъдат запазени. Внимавайте да не се заключите.",
|
||||
"ruleApplied": "Правилото е приложено",
|
||||
"invalidPort": "Въведете валиден порт (1-65535)",
|
||||
"newUsername": "Ново потребителско име",
|
||||
"addUser": "Добавяне",
|
||||
"deleteUser": "Изтриване на потребител",
|
||||
"noHealthChecks": "Все още няма конфигурирани проверки за състоянието.",
|
||||
"follow": "Следвай",
|
||||
"noLogData": "Няма изходен лог.",
|
||||
"tree": "Дърво",
|
||||
"enableDisable": "Активиране/деактивиране при зареждане",
|
||||
"grantSudo": "Грант sudo",
|
||||
"revokeSudo": "Отмяна на sudo",
|
||||
"sslIssueCert": "Издаване на сертификат",
|
||||
"sslExpired": "Изтекъл",
|
||||
"sslInDays": "в {{days}}дни",
|
||||
"sslNeedDomain": "Въведете поне един домейн",
|
||||
"sslIssued": "Издаден сертификат",
|
||||
"sslDomainsPlaceholder": "пример.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (самостоятелен)",
|
||||
"sslHttpWebroot": "HTTP (уебкорен)",
|
||||
"sslDns": "ДНС",
|
||||
"sslDnsProvider": "DNS доставчик (напр. cloudflare)",
|
||||
"sslIssueHint": "Идентификационните данни на DNS доставчика трябва вече да са конфигурирани на хоста.",
|
||||
"sslRevoke": "Анулиране на сертификат",
|
||||
"sslRevoked": "Сертификатът е отменен",
|
||||
"sslRevokeConfirm": "Да се отмени и премахне сертификатът „{{name}}“? Това не може да бъде отменено.",
|
||||
"healthRun": "Бягане",
|
||||
"healthName": "Име",
|
||||
"healthTarget": "Хост / адрес",
|
||||
"healthAddCheck": "Добавяне на чек",
|
||||
"healthSaved": "Запазени са здравни проверки",
|
||||
"healthMissingFields": "Всяка проверка се нуждае от име и цел",
|
||||
"logFile": "Файл",
|
||||
"logUnit": "Единица",
|
||||
"logCustomPath": "Персонализиран път в /var/log (по избор)",
|
||||
"logGrep": "Филтърни линии...",
|
||||
"firewallPersist": "Запазване на правилата",
|
||||
"firewallPersisted": "Правилата на защитната стена се запазиха",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard не е инсталиран на този хост",
|
||||
"wgNoInterfaces": "Няма конфигурирани WireGuard интерфейси",
|
||||
"wgInterfaceUp": "Доведете",
|
||||
"wgInterfaceDown": "Свалете",
|
||||
"wgBringingUp": "Извеждане на {{name}} нагоре...",
|
||||
"wgBringingDown": "Сваляне на {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} е нагоре",
|
||||
"wgInterfaceDownDone": "{{name}} не работи",
|
||||
"wgListenPort": "Порт",
|
||||
"wgPublicKey": "Публичен ключ",
|
||||
"wgEndpoint": "Крайна точка",
|
||||
"wgAllowedIPs": "Разрешени IP адреси",
|
||||
"wgLastHandshake": "Последно ръкостискане",
|
||||
"wgHandshakeNever": "Никога",
|
||||
"wgTransfer": "Трансфер",
|
||||
"wgNoPeers": "Няма връстници"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "История на показателите",
|
||||
"historySuffix": "История",
|
||||
"cpuMemoryDisk": "Процесор / Памет / Диск",
|
||||
"network": "Мрежа",
|
||||
"download": "Рецепта",
|
||||
"upload": "Тексас",
|
||||
"noData": "Няма налични исторически данни за този период от време.",
|
||||
"custom": "Персонализирано",
|
||||
"to": "до",
|
||||
"apply": "Приложи",
|
||||
"viewHistory": "Преглед на историята"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Метрики на хоста",
|
||||
"metricsCollectionLabel": "Колекция от показатели",
|
||||
"enableMetricsLabel": "Активиране на показатели",
|
||||
"enableMetricsDesc": "Събиране на данни за процесора, RAM, диска и други показатели от този хост",
|
||||
"useGlobalMetrics": "Използване на глобален интервал",
|
||||
"useGlobalMetricsDesc": "Замяна с интервала на показателите за целия сървър",
|
||||
"metricsIntervalS": "Интервал(и) на показателите",
|
||||
"metricsIntervalDesc2": "Секунди между моментните снимки на показателите",
|
||||
"excludedMountsLabel": "Изключени монтировки",
|
||||
"monitoredMountsLabel": "Персонализирани наблюдавани пътища",
|
||||
"monitoredMountsDesc": "Добавете пътища, които не са изложени като отделни монтирания, включително пътища за монтиран Home Assistant чрез свързване, като например /config.",
|
||||
"monitoredMountPathPlaceholder": "Път (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Етикет (по избор)",
|
||||
"addMonitoredMount": "Добавяне на път",
|
||||
"excludedMountsDesc": "Скриване на специфични точки на монтиране или типове файлови системи (напр. nfs, cifs) от използването на диска и предупрежденията за този хост.",
|
||||
"excludedMountsPlaceholder": "Път за монтиране (/mnt/nas) или тип файлова система (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Няма изключени монтирания. Показват се всички открити файлови системи.",
|
||||
"addExcludedMount": "Добавяне",
|
||||
"visibleWidgets": "Видими джаджи",
|
||||
"widgetsMovedToHostMetrics": "Картите вече се добавят, подреждат и преоразмеряват директно в раздела „Показатели за хоста“. Отворете „Показатели за хоста“ за този хост и използвайте „Персонализиране“, за да изберете кои карти да се показват.",
|
||||
"hostNotFound": "Хостът не е намерен",
|
||||
"loadingHost": "Зареждане на хоста..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Показатели и сигнали",
|
||||
"feature_metrics_desc": "Проследявайте процесора, паметта и диска с течение на времето и получавайте известия, когато се повредят."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Запазване на историята на показателите",
|
||||
"metricsHistoryRetentionRange": "от 1 до 90 дни",
|
||||
"days": "дни"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Няма налични данни",
|
||||
"showCurrentValue": "Показване на текущата стойност",
|
||||
"chartMetric": "Метричен",
|
||||
"metricRange": "Диапазон",
|
||||
"host": "Домакин",
|
||||
"loading": "Зареждане...",
|
||||
"metricCpu": "Процесор",
|
||||
"metricDisk": "Диск",
|
||||
"metricHostname": "Име на хост",
|
||||
"metricKernel": "Ядро",
|
||||
"metricMemory": "Памет",
|
||||
"metricNetwork": "Мрежа",
|
||||
"metricOs": "ОС",
|
||||
"metricProcesses": "Процеси",
|
||||
"metricProcessesRunning": "бягане",
|
||||
"metricProcessesTotal": "общо",
|
||||
"metricUptime": "Време на работа",
|
||||
"metricsNotAvailable": "Показателите не са налични",
|
||||
"noHostSelected": "Няма избран хост",
|
||||
"selectHost": "Изберете хост..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Статистически данни в реално време не са налични"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "Процесор",
|
||||
"ram": "RAM памет"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Диск"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Свързан"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Използвайте показатели за хоста",
|
||||
"description": "Преглеждайте показателите на хоста и използвайте мениджърите на хостове (услуги, пакети, защитна стена и други) на хостовете, до които можете да достигнете."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Интервал на показателите (секунди)",
|
||||
"description": "Колко често се семплира даден хост, освен ако хостът не зададе свой собствен интервал."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Запазване на историята (дни)",
|
||||
"description": "Колко дълго се съхраняват извадки от показатели за историческите графики."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Метрики за нови хостове",
|
||||
"description": "Дали новите хостове започват с включено събиране на показатели."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Температурна единица",
|
||||
"description": "Как се показват температурите в Host Metrics.",
|
||||
"celsius": "Целзий",
|
||||
"fahrenheit": "Фаренхайт"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Събиране на показатели"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Интервал на показателите (секунди)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Активирани джаджи"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Изключени монтировки"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Мониторирани пътища"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Host Metrics"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"network": "Network",
|
||||
"uptime": "Uptime",
|
||||
"processes": "Processes",
|
||||
"available": "Available",
|
||||
"free": "Free",
|
||||
"connecting": "Connecting...",
|
||||
"connectionFailed": "Failed to connect to server",
|
||||
"naCpus": "N/A CPU(s)",
|
||||
"cpuCores_one": "{{count}} Core",
|
||||
"cpuCores_other": "{{count}} Cores",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"diskUsage": "Disk Usage",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Temperature",
|
||||
"highestTemperature": "Highest temperature",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Failed to fetch host configuration",
|
||||
"serverOffline": "Server Offline",
|
||||
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||
"totpFailed": "TOTP verification failed",
|
||||
"noneAuthNotSupported": "Host Metrics does not support 'none' authentication type.",
|
||||
"noHostSelected": "No host selected",
|
||||
"load": "Load",
|
||||
"systemInfo": "System Information",
|
||||
"hostname": "Hostname",
|
||||
"operatingSystem": "Operating System",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "seconds",
|
||||
"networkInterfaces": "Network Interfaces",
|
||||
"noInterfacesFound": "No network interfaces found",
|
||||
"noProcessesFound": "No processes found",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "running",
|
||||
"loginStats": "SSH Login Statistics",
|
||||
"noRecentLoginData": "No recent login data",
|
||||
"ports": {
|
||||
"title": "Listening Ports",
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"address": "Address",
|
||||
"process": "Process",
|
||||
"search": "Search ports...",
|
||||
"allProtocols": "All",
|
||||
"noData": "No listening ports data"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inactive",
|
||||
"policy": "Policy",
|
||||
"rules": "rules",
|
||||
"noRules": "No rules in this chain",
|
||||
"noData": "No firewall data available",
|
||||
"action": "Action",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Source",
|
||||
"anywhere": "Anywhere",
|
||||
"chains": "chains"
|
||||
},
|
||||
"loadAvg": "Load Avg",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architecture",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"customize": "Customize layout",
|
||||
"reset": "Reset",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Drag cards to rearrange, drag the bottom edge to resize, and use the width button to change a card's span. Add or remove cards below.",
|
||||
"managers": {
|
||||
"services": "Services",
|
||||
"processInspector": "Process Inspector",
|
||||
"logViewer": "Log Viewer",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Packages",
|
||||
"ssl": "SSL Certificates",
|
||||
"firewall": "Firewall",
|
||||
"users": "Users & Permissions",
|
||||
"healthCheck": "Health Checks",
|
||||
"diskBreakdown": "Disk Breakdown",
|
||||
"systemdTimers": "Timers",
|
||||
"topMemory": "Top by Memory",
|
||||
"noData": "No data",
|
||||
"sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.",
|
||||
"filter": "Filter...",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"actionDone": "{{name}} updated",
|
||||
"actionFailed": "Action failed",
|
||||
"signalSent": "Signal sent to PID {{pid}}",
|
||||
"killHint": "Click: terminate (SIGTERM). Right-click: force kill (SIGKILL).",
|
||||
"working": "Working...",
|
||||
"update": "Update",
|
||||
"upgradeAll": "Upgrade all",
|
||||
"allUpToDate": "Everything is up to date",
|
||||
"save": "Save",
|
||||
"command": "Command",
|
||||
"enabled": "Enabled",
|
||||
"cronSaved": "Crontab updated",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Dry run",
|
||||
"renew": "Renew",
|
||||
"noAcmeClient": "No ACME client (certbot or acme.sh) found on this host.",
|
||||
"addInputRule": "Add INPUT rule",
|
||||
"firewallWarning": "Changes are runtime-only until persisted. Be careful not to lock yourself out.",
|
||||
"ruleApplied": "Rule applied",
|
||||
"invalidPort": "Enter a valid port (1-65535)",
|
||||
"newUsername": "New username",
|
||||
"addUser": "Add",
|
||||
"deleteUser": "Delete user",
|
||||
"noHealthChecks": "No health checks configured yet.",
|
||||
"follow": "Follow",
|
||||
"noLogData": "No log output.",
|
||||
"tree": "Tree",
|
||||
"enableDisable": "Enable / disable at boot",
|
||||
"grantSudo": "Grant sudo",
|
||||
"revokeSudo": "Revoke sudo",
|
||||
"sslIssueCert": "Issue certificate",
|
||||
"sslExpired": "Expired",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Enter at least one domain",
|
||||
"sslIssued": "Certificate issued",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (standalone)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS provider (e.g. cloudflare)",
|
||||
"sslIssueHint": "DNS provider credentials must already be configured on the host.",
|
||||
"sslRevoke": "Revoke certificate",
|
||||
"sslRevoked": "Certificate revoked",
|
||||
"sslRevokeConfirm": "Revoke and remove the certificate \"{{name}}\"? This cannot be undone.",
|
||||
"healthRun": "Run",
|
||||
"healthName": "Name",
|
||||
"healthTarget": "Host / address",
|
||||
"healthAddCheck": "Add check",
|
||||
"healthSaved": "Health checks saved",
|
||||
"healthMissingFields": "Each check needs a name and target",
|
||||
"logFile": "File",
|
||||
"logUnit": "Unit",
|
||||
"logCustomPath": "Custom path under /var/log (optional)",
|
||||
"logGrep": "Filter lines...",
|
||||
"firewallPersist": "Persist rules",
|
||||
"firewallPersisted": "Firewall rules persisted",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard is not installed on this host",
|
||||
"wgNoInterfaces": "No WireGuard interfaces configured",
|
||||
"wgInterfaceUp": "Bring up",
|
||||
"wgInterfaceDown": "Bring down",
|
||||
"wgBringingUp": "Bringing {{name}} up...",
|
||||
"wgBringingDown": "Bringing {{name}} down...",
|
||||
"wgInterfaceUpDone": "{{name}} is up",
|
||||
"wgInterfaceDownDone": "{{name}} is down",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Public key",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "Allowed IPs",
|
||||
"wgLastHandshake": "Last handshake",
|
||||
"wgHandshakeNever": "Never",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrics History",
|
||||
"historySuffix": "History",
|
||||
"cpuMemoryDisk": "CPU / Memory / Disk",
|
||||
"network": "Network",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "No history data available for this time range.",
|
||||
"custom": "Custom",
|
||||
"to": "to",
|
||||
"apply": "Apply",
|
||||
"viewHistory": "View History"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Host Metrics",
|
||||
"metricsCollectionLabel": "Metrics Collection",
|
||||
"enableMetricsLabel": "Enable Metrics",
|
||||
"enableMetricsDesc": "Collect CPU, RAM, disk, and other metrics from this host",
|
||||
"useGlobalMetrics": "Use Global Interval",
|
||||
"useGlobalMetricsDesc": "Override with the server-wide metrics interval",
|
||||
"metricsIntervalS": "Metrics Interval (s)",
|
||||
"metricsIntervalDesc2": "Seconds between metric snapshots",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Label (optional)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Add",
|
||||
"visibleWidgets": "Visible Widgets",
|
||||
"widgetsMovedToHostMetrics": "Cards are now added, arranged, and resized directly in the Host Metrics tab. Open Host Metrics for this host and use Customize to choose which cards are shown.",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrics History Retention",
|
||||
"metricsHistoryRetentionRange": "1 to 90 days",
|
||||
"days": "days"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "No data available",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range",
|
||||
"host": "Host",
|
||||
"loading": "Loading...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Hostname",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memory",
|
||||
"metricNetwork": "Network",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processes",
|
||||
"metricProcessesRunning": "running",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Uptime",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"noHostSelected": "No host selected",
|
||||
"selectHost": "Select a host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connected"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Mètriques de l'amfitrió"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memòria",
|
||||
"disk": "Disc",
|
||||
"network": "Xarxa",
|
||||
"uptime": "Temps de funcionament",
|
||||
"processes": "Processos",
|
||||
"available": "Disponible",
|
||||
"free": "Gratuït",
|
||||
"connecting": "Connectant...",
|
||||
"connectionFailed": "No s'ha pogut connectar al servidor",
|
||||
"naCpus": "CPU(s) N/A",
|
||||
"cpuCores_one": "{{count}} Nucli",
|
||||
"cpuCores_other": "{{count}} Nuclis",
|
||||
"cpuUsage": "Ús de la CPU",
|
||||
"memoryUsage": "Ús de memòria",
|
||||
"diskUsage": "Ús del disc",
|
||||
"selectFilesystem": "Selecciona el sistema de fitxers",
|
||||
"temperature": "Temperatura",
|
||||
"highestTemperature": "Temperatura més alta",
|
||||
"primaryTemperatureSensor": "Sensor principal",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No s'ha detectat cap GPU NVIDIA. Instal·leu el controlador NVIDIA (proporciona nvidia-smi) per veure les mètriques de la GPU.",
|
||||
"driver": "Conductor {{version}}",
|
||||
"utilization": "Utilització",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temperatura",
|
||||
"power": "Poder",
|
||||
"fan": "Ventilador",
|
||||
"processes": "Processos de la GPU",
|
||||
"noProcesses": "Cap procés utilitza la GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "No s'ha pogut obtenir la configuració de l'amfitrió",
|
||||
"serverOffline": "Servidor fora de línia",
|
||||
"cannotFetchMetrics": "No es poden obtenir mètriques del servidor fora de línia",
|
||||
"totpFailed": "La verificació TOTP ha fallat",
|
||||
"noneAuthNotSupported": "Les mètriques de l'amfitrió no admeten el tipus d'autenticació \"cap\".",
|
||||
"noHostSelected": "No s'ha seleccionat cap amfitrió",
|
||||
"load": "Carrega",
|
||||
"systemInfo": "Informació del sistema",
|
||||
"hostname": "Nom d'amfitrió",
|
||||
"operatingSystem": "Sistema operatiu",
|
||||
"kernel": "Nucli",
|
||||
"seconds": "segons",
|
||||
"networkInterfaces": "Interfícies de xarxa",
|
||||
"noInterfacesFound": "No s'han trobat interfícies de xarxa",
|
||||
"noProcessesFound": "No s'han trobat processos",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "corrent",
|
||||
"loginStats": "Estadístiques d'inici de sessió SSH",
|
||||
"noRecentLoginData": "Sense dades d'inici de sessió recents",
|
||||
"ports": {
|
||||
"title": "Ports d'escolta",
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"address": "Adreça",
|
||||
"process": "Procés",
|
||||
"search": "Cerca ports...",
|
||||
"allProtocols": "Tot",
|
||||
"noData": "No hi ha dades de ports d'escolta"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Tallafocs",
|
||||
"inactive": "Inactiu",
|
||||
"policy": "Política",
|
||||
"rules": "regles",
|
||||
"noRules": "No hi ha regles en aquesta cadena",
|
||||
"noData": "No hi ha dades de tallafocs disponibles",
|
||||
"action": "Acció",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Font",
|
||||
"anywhere": "A qualsevol lloc",
|
||||
"chains": "cadenes"
|
||||
},
|
||||
"loadAvg": "Mitjana de càrrega",
|
||||
"swap": "Intercanvi",
|
||||
"architecture": "Arquitectura",
|
||||
"refresh": "Actualitza",
|
||||
"retry": "Torna-ho a intentar",
|
||||
"customize": "Personalitza el disseny",
|
||||
"reset": "Restablir",
|
||||
"tabLive": "En directe",
|
||||
"editModeInstructions": "Arrossega les cartes per reorganitzar-les, arrossega la vora inferior per canviar-ne la mida i utilitza el botó d'amplada per canviar l'extensió d'una carta. Afegeix o elimina cartes a continuació.",
|
||||
"managers": {
|
||||
"services": "Serveis",
|
||||
"processInspector": "Inspector de processos",
|
||||
"logViewer": "Visualitzador de registre",
|
||||
"cron": "Treballs cron",
|
||||
"packages": "Paquets",
|
||||
"ssl": "Certificats SSL",
|
||||
"firewall": "Tallafocs",
|
||||
"users": "Usuaris i permisos",
|
||||
"healthCheck": "Controls de salut",
|
||||
"diskBreakdown": "Avaria del disc",
|
||||
"systemdTimers": "Temporitzadors",
|
||||
"topMemory": "Top per memòria",
|
||||
"noData": "Sense dades",
|
||||
"sudoHint": "Establiu una contrasenya sudo per a aquest host a l'editor d'hosts per habilitar accions privilegiades.",
|
||||
"filter": "Filtra...",
|
||||
"start": "Inici",
|
||||
"stop": "Atura",
|
||||
"restart": "Reinicia",
|
||||
"actionDone": "{{name}} actualitzat",
|
||||
"actionFailed": "L'acció ha fallat",
|
||||
"signalSent": "Senyal enviat al PID {{pid}}",
|
||||
"killHint": "Clic: finalitzar (SIGTERM). Clic amb el botó dret: forçar la mort (SIGKILL).",
|
||||
"working": "Treballant...",
|
||||
"update": "Actualització",
|
||||
"upgradeAll": "Actualitza-ho tot",
|
||||
"allUpToDate": "Tot està actualitzat",
|
||||
"save": "Desa",
|
||||
"command": "Comandament",
|
||||
"enabled": "Activat",
|
||||
"cronSaved": "Crontab actualitzat",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Sessió en sec",
|
||||
"renew": "Renovar",
|
||||
"noAcmeClient": "No s'ha trobat cap client ACME (certbot o acme.sh) en aquest amfitrió.",
|
||||
"addInputRule": "Afegeix una regla INPUT",
|
||||
"firewallWarning": "Els canvis només són en temps d'execució fins que persisteixen. Aneu amb compte de no bloquejar-vos l'accés.",
|
||||
"ruleApplied": "Regla aplicada",
|
||||
"invalidPort": "Introduïu un port vàlid (1-65535)",
|
||||
"newUsername": "Nou nom d'usuari",
|
||||
"addUser": "Afegeix",
|
||||
"deleteUser": "Suprimeix l'usuari",
|
||||
"noHealthChecks": "Encara no s'ha configurat cap comprovació d'estat.",
|
||||
"follow": "Segueix",
|
||||
"noLogData": "No hi ha sortida de registre.",
|
||||
"tree": "Arbre",
|
||||
"enableDisable": "Activa / desactiva a l'arrencada",
|
||||
"grantSudo": "Concedir sudo",
|
||||
"revokeSudo": "Revoca sudo",
|
||||
"sslIssueCert": "Emetre certificat",
|
||||
"sslExpired": "Caducat",
|
||||
"sslInDays": "en {{days}}d",
|
||||
"sslNeedDomain": "Introduïu com a mínim un domini",
|
||||
"sslIssued": "Certificat emès",
|
||||
"sslDomainsPlaceholder": "exemple.com, www.exemple.com",
|
||||
"sslHttpStandalone": "HTTP (autònom)",
|
||||
"sslHttpWebroot": "HTTP (arrel web)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "Proveïdor de DNS (per exemple, Cloudflare)",
|
||||
"sslIssueHint": "Les credencials del proveïdor DNS ja han d'estar configurades a l'amfitrió.",
|
||||
"sslRevoke": "Revoca el certificat",
|
||||
"sslRevoked": "Certificat revocat",
|
||||
"sslRevokeConfirm": "Voleu revocar i eliminar el certificat \"{{name}}\"? Això no es pot desfer.",
|
||||
"healthRun": "Corre",
|
||||
"healthName": "Nom",
|
||||
"healthTarget": "Host / adreça",
|
||||
"healthAddCheck": "Afegeix un xec",
|
||||
"healthSaved": "S'han desat les comprovacions d'estat",
|
||||
"healthMissingFields": "Cada comprovació necessita un nom i un objectiu",
|
||||
"logFile": "Fitxer",
|
||||
"logUnit": "Unitat",
|
||||
"logCustomPath": "Ruta personalitzada a /var/log (opcional)",
|
||||
"logGrep": "Línies de filtre...",
|
||||
"firewallPersist": "Regles de persistència",
|
||||
"firewallPersisted": "Les regles del tallafocs van persistir",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard no està instal·lat en aquest host.",
|
||||
"wgNoInterfaces": "No hi ha cap interfície de WireGuard configurada",
|
||||
"wgInterfaceUp": "Porta a col·lació",
|
||||
"wgInterfaceDown": "Baixar",
|
||||
"wgBringingUp": "S'està mostrant {{name}}...",
|
||||
"wgBringingDown": "Baixant {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} està activat",
|
||||
"wgInterfaceDownDone": "{{name}} està inactiu",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Clau pública",
|
||||
"wgEndpoint": "Punt final",
|
||||
"wgAllowedIPs": "IP permeses",
|
||||
"wgLastHandshake": "Última encaixada de mans",
|
||||
"wgHandshakeNever": "Mai",
|
||||
"wgTransfer": "Transferència",
|
||||
"wgNoPeers": "Sense iguals"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Historial de mètriques",
|
||||
"historySuffix": "Història",
|
||||
"cpuMemoryDisk": "CPU / Memòria / Disc",
|
||||
"network": "Xarxa",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "No hi ha dades històriques disponibles per a aquest interval de temps.",
|
||||
"custom": "Personalitzat",
|
||||
"to": "a",
|
||||
"apply": "Aplica",
|
||||
"viewHistory": "Veure l'historial"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Mètriques de l'amfitrió",
|
||||
"metricsCollectionLabel": "Col·lecció de mètriques",
|
||||
"enableMetricsLabel": "Activa les mètriques",
|
||||
"enableMetricsDesc": "Recopila mètriques de CPU, RAM, disc i altres d'aquest amfitrió",
|
||||
"useGlobalMetrics": "Utilitza l'interval global",
|
||||
"useGlobalMetricsDesc": "Substitueix amb l'interval de mètriques de tot el servidor",
|
||||
"metricsIntervalS": "Interval de mètriques (s)",
|
||||
"metricsIntervalDesc2": "Segons entre instantànies de mètriques",
|
||||
"excludedMountsLabel": "Muntatges exclosos",
|
||||
"monitoredMountsLabel": "Rutes monitoritzades personalitzades",
|
||||
"monitoredMountsDesc": "Afegiu camins que no estiguin exposats com a muntatges separats, inclosos els camins de l'Assistent d'inici muntats per vinculació, com ara /config.",
|
||||
"monitoredMountPathPlaceholder": "Camí (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Etiqueta (opcional)",
|
||||
"addMonitoredMount": "Afegeix un camí",
|
||||
"excludedMountsDesc": "Amaga punts de muntatge o tipus de sistemes de fitxers específics (per exemple, nfs, cifs) de l'ús del disc i de les alertes per a aquest amfitrió.",
|
||||
"excludedMountsPlaceholder": "Ruta de muntatge (/mnt/nas) o tipus de sistema de fitxers (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No s'han exclòs muntatges. Es mostren tots els sistemes de fitxers detectats.",
|
||||
"addExcludedMount": "Afegeix",
|
||||
"visibleWidgets": "Widgets visibles",
|
||||
"widgetsMovedToHostMetrics": "Ara les targetes s'afegeixen, s'organitzen i es canvien de mida directament a la pestanya Mètriques de l'amfitrió. Obriu Mètriques de l'amfitrió per a aquest amfitrió i utilitzeu Personalitza per triar quines targetes es mostren.",
|
||||
"hostNotFound": "No s'ha trobat l'amfitrió",
|
||||
"loadingHost": "S'està carregant l'amfitrió..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Mètriques i alertes",
|
||||
"feature_metrics_desc": "Fes un seguiment de la CPU, la memòria i el disc al llarg del temps i rep alertes quan es facin malbé."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Retenció de l'historial de mètriques",
|
||||
"metricsHistoryRetentionRange": "D'1 a 90 dies",
|
||||
"days": "dies"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "No hi ha dades disponibles",
|
||||
"showCurrentValue": "Mostra el valor actual",
|
||||
"chartMetric": "Mètrica",
|
||||
"metricRange": "Rang",
|
||||
"host": "Amfitrió",
|
||||
"loading": "S'està carregant...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disc",
|
||||
"metricHostname": "Nom d'amfitrió",
|
||||
"metricKernel": "Nucli",
|
||||
"metricMemory": "Memòria",
|
||||
"metricNetwork": "Xarxa",
|
||||
"metricOs": "Sistema operatiu",
|
||||
"metricProcesses": "Processos",
|
||||
"metricProcessesRunning": "corrent",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Temps de funcionament",
|
||||
"metricsNotAvailable": "Mètriques no disponibles",
|
||||
"noHostSelected": "No s'ha seleccionat cap amfitrió",
|
||||
"selectHost": "Seleccioneu un amfitrió..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Estadístiques en directe no disponibles"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disc"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connectat"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Utilitza les mètriques de l'amfitrió",
|
||||
"description": "Visualitza les mètriques de l'amfitrió i utilitza els gestors d'amfitrió (serveis, paquets, tallafocs i més) als amfitrions als quals pots accedir."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Interval de mètriques (segons)",
|
||||
"description": "Amb quina freqüència es mostreja un amfitrió que es visualitza, tret que l'amfitrió estableixi el seu propi interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Retenció de l'historial (dies)",
|
||||
"description": "Quant de temps es conserven les mostres de mètriques per als gràfics d'historial."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Mètriques activades per a nous amfitrions",
|
||||
"description": "Si els nous amfitrions comencen amb la recopilació de mètriques activada."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Unitat de temperatura",
|
||||
"description": "Com es mostren les temperatures a les mètriques de l'amfitrió.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Recopilar mètriques"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Interval de mètriques (segons)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Widgets activats"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Muntatges exclosos"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Camins monitoritzats"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Metriky hostitele"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "Procesor",
|
||||
"memory": "Paměť",
|
||||
"disk": "Disk",
|
||||
"network": "Síť",
|
||||
"uptime": "Doba provozuschopnosti",
|
||||
"processes": "Procesy",
|
||||
"available": "K dispozici",
|
||||
"free": "Uvolnit",
|
||||
"connecting": "Spojovací...",
|
||||
"connectionFailed": "Nepodařilo se připojit k serveru",
|
||||
"naCpus": "CPU (procesory)",
|
||||
"cpuCores_one": "{{count}} Jádro",
|
||||
"cpuCores_other": "{{count}} Jádra",
|
||||
"cpuUsage": "Využití CPU",
|
||||
"memoryUsage": "Využití paměti",
|
||||
"diskUsage": "Využití disku",
|
||||
"selectFilesystem": "Vyberte souborový systém",
|
||||
"temperature": "Teplota",
|
||||
"highestTemperature": "Nejvyšší teplota",
|
||||
"primaryTemperatureSensor": "Primární senzor",
|
||||
"gpu": {
|
||||
"title": "Grafický procesor",
|
||||
"noDevice": "Nebyla detekována žádná grafická karta NVIDIA. Nainstalujte ovladač NVIDIA (obsahuje nvidia-smi) pro zobrazení metrik grafické karty.",
|
||||
"driver": "Řidič {{version}}",
|
||||
"utilization": "Využití",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Dočasné",
|
||||
"power": "Moc",
|
||||
"fan": "Větrák",
|
||||
"processes": "Procesy GPU",
|
||||
"noProcesses": "Žádné procesy nepoužívají GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Nepodařilo se načíst konfiguraci hostitele",
|
||||
"serverOffline": "Server offline",
|
||||
"cannotFetchMetrics": "Nelze načíst metriky z offline serveru",
|
||||
"totpFailed": "Ověření TOTP se nezdařilo",
|
||||
"noneAuthNotSupported": "Metriky hostitele nepodporují typ ověřování „žádné“.",
|
||||
"noHostSelected": "Nebyl vybrán žádný hostitel",
|
||||
"load": "Zatížení",
|
||||
"systemInfo": "Informace o systému",
|
||||
"hostname": "Název hostitele",
|
||||
"operatingSystem": "Operační systém",
|
||||
"kernel": "Jádro",
|
||||
"seconds": "sekundy",
|
||||
"networkInterfaces": "Síťová rozhraní",
|
||||
"noInterfacesFound": "Nenalezena žádná síťová rozhraní",
|
||||
"noProcessesFound": "Nenalezeny žádné procesy",
|
||||
"processesTotal": "celkový",
|
||||
"processesRunning": "běh",
|
||||
"loginStats": "Statistiky přihlášení SSH",
|
||||
"noRecentLoginData": "Žádné nedávné přihlašovací údaje",
|
||||
"ports": {
|
||||
"title": "Naslouchající porty",
|
||||
"protocol": "Protokol",
|
||||
"port": "Port",
|
||||
"address": "Adresa",
|
||||
"process": "Proces",
|
||||
"search": "Hledat porty...",
|
||||
"allProtocols": "Vše",
|
||||
"noData": "Žádná data naslouchajících portů"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Neaktivní",
|
||||
"policy": "Politika",
|
||||
"rules": "pravidla",
|
||||
"noRules": "V tomto řetězci nejsou žádná pravidla",
|
||||
"noData": "Nejsou k dispozici žádná data firewallu",
|
||||
"action": "Akce",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Zdroj",
|
||||
"anywhere": "Kdekoli",
|
||||
"chains": "řetězy"
|
||||
},
|
||||
"loadAvg": "Průměrné zatížení",
|
||||
"swap": "Výměna",
|
||||
"architecture": "Architektura",
|
||||
"refresh": "Obnovit",
|
||||
"retry": "Zkusit znovu",
|
||||
"customize": "Přizpůsobit rozvržení",
|
||||
"reset": "Obnovit",
|
||||
"tabLive": "Žít",
|
||||
"editModeInstructions": "Přetažením karet změníte jejich uspořádání, přetažením spodního okraje změníte jejich velikost a pomocí tlačítka šířky změníte jejich rozpětí. Karty můžete přidávat nebo odebírat níže.",
|
||||
"managers": {
|
||||
"services": "Služby",
|
||||
"processInspector": "Inspektor procesů",
|
||||
"logViewer": "Prohlížeč protokolů",
|
||||
"cron": "Cron úlohy",
|
||||
"packages": "Balíčky",
|
||||
"ssl": "SSL certifikáty",
|
||||
"firewall": "Firewall",
|
||||
"users": "Uživatelé a oprávnění",
|
||||
"healthCheck": "Kontroly stavu",
|
||||
"diskBreakdown": "Rozpad disku",
|
||||
"systemdTimers": "Časovače",
|
||||
"topMemory": "Nejlépe podle paměti",
|
||||
"noData": "Žádná data",
|
||||
"sudoHint": "V editoru hostitelů nastavte pro tento hostitel heslo sudo, abyste povolili privilegované akce.",
|
||||
"filter": "Filtr...",
|
||||
"start": "Start",
|
||||
"stop": "Zastávka",
|
||||
"restart": "Restartovat",
|
||||
"actionDone": "{{name}} aktualizováno",
|
||||
"actionFailed": "Akce se nezdařila",
|
||||
"signalSent": "Signál odeslaný do PID {{pid}}",
|
||||
"killHint": "Kliknutí: ukončení (SIGTERM). Kliknutí pravým tlačítkem: vynucené ukončení (SIGKILL).",
|
||||
"working": "Pracovní...",
|
||||
"update": "Aktualizovat",
|
||||
"upgradeAll": "Upgradovat vše",
|
||||
"allUpToDate": "Všechno je aktuální",
|
||||
"save": "Uložit",
|
||||
"command": "Příkaz",
|
||||
"enabled": "Povoleno",
|
||||
"cronSaved": "Crontab aktualizován",
|
||||
"clients": "Klienti",
|
||||
"dryRun": "Zkušební provoz",
|
||||
"renew": "Obnovit",
|
||||
"noAcmeClient": "Na tomto hostiteli nebyl nalezen žádný klient ACME (certbot ani acme.sh).",
|
||||
"addInputRule": "Přidat pravidlo INPUT",
|
||||
"firewallWarning": "Změny jsou platné pouze za běhu, dokud nejsou trvalé. Dávejte pozor, abyste se k nim nezablokovali.",
|
||||
"ruleApplied": "Pravidlo bylo použito",
|
||||
"invalidPort": "Zadejte platný port (1-65535)",
|
||||
"newUsername": "Nové uživatelské jméno",
|
||||
"addUser": "Přidat",
|
||||
"deleteUser": "Smazat uživatele",
|
||||
"noHealthChecks": "Zatím nejsou nakonfigurovány žádné kontroly stavu.",
|
||||
"follow": "Následovat",
|
||||
"noLogData": "Žádný výstup protokolu.",
|
||||
"tree": "Strom",
|
||||
"enableDisable": "Povolit/zakázat při spuštění",
|
||||
"grantSudo": "Udělit sudo",
|
||||
"revokeSudo": "Zrušit sudo",
|
||||
"sslIssueCert": "Vydat certifikát",
|
||||
"sslExpired": "Platnost vypršela",
|
||||
"sslInDays": "v {{days}}d",
|
||||
"sslNeedDomain": "Zadejte alespoň jednu doménu",
|
||||
"sslIssued": "Vydaný certifikát",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (samostatný)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "Poskytovatel DNS (např. cloudflare)",
|
||||
"sslIssueHint": "Přihlašovací údaje poskytovatele DNS musí být již na hostiteli nakonfigurovány.",
|
||||
"sslRevoke": "Zrušit certifikát",
|
||||
"sslRevoked": "Certifikát zrušen",
|
||||
"sslRevokeConfirm": "Zrušit a odstranit certifikát „{{name}}“? Tuto akci nelze vrátit zpět.",
|
||||
"healthRun": "Běh",
|
||||
"healthName": "Jméno",
|
||||
"healthTarget": "Hostitel / adresa",
|
||||
"healthAddCheck": "Přidat šek",
|
||||
"healthSaved": "Kontroly stavu uloženy",
|
||||
"healthMissingFields": "Každá kontrola potřebuje název a cíl.",
|
||||
"logFile": "Soubor",
|
||||
"logUnit": "Jednotka",
|
||||
"logCustomPath": "Vlastní cesta v /var/log (volitelné)",
|
||||
"logGrep": "Filtrační potrubí...",
|
||||
"firewallPersist": "Pravidla pro trvalé uložení",
|
||||
"firewallPersisted": "Pravidla firewallu přetrvávala",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard není na tomto hostiteli nainstalován",
|
||||
"wgNoInterfaces": "Žádná nakonfigurovaná rozhraní WireGuard",
|
||||
"wgInterfaceUp": "Vychovat",
|
||||
"wgInterfaceDown": "Snížit",
|
||||
"wgBringingUp": "Vyvolávám {{name}}...",
|
||||
"wgBringingDown": "Snižování {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} je aktivní",
|
||||
"wgInterfaceDownDone": "{{name}} je nefunkční",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Veřejný klíč",
|
||||
"wgEndpoint": "Koncový bod",
|
||||
"wgAllowedIPs": "Povolené IP adresy",
|
||||
"wgLastHandshake": "Poslední podání ruky",
|
||||
"wgHandshakeNever": "Nikdy",
|
||||
"wgTransfer": "Převod",
|
||||
"wgNoPeers": "Žádní vrstevníci"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Historie metrik",
|
||||
"historySuffix": "Dějiny",
|
||||
"cpuMemoryDisk": "CPU / Paměť / Disk",
|
||||
"network": "Síť",
|
||||
"download": "RX",
|
||||
"upload": "Texas",
|
||||
"noData": "Pro toto časové období nejsou k dispozici žádná historická data.",
|
||||
"custom": "Zvyk",
|
||||
"to": "na",
|
||||
"apply": "Použít",
|
||||
"viewHistory": "Zobrazit historii"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Metriky hostitele",
|
||||
"metricsCollectionLabel": "Sbírka metrik",
|
||||
"enableMetricsLabel": "Povolit metriky",
|
||||
"enableMetricsDesc": "Shromažďujte metriky CPU, RAM, disku a další z tohoto hostitele",
|
||||
"useGlobalMetrics": "Použít globální interval",
|
||||
"useGlobalMetricsDesc": "Přepsat intervalem metrik na úrovni celého serveru",
|
||||
"metricsIntervalS": "Interval metrik (y)",
|
||||
"metricsIntervalDesc2": "Sekundy mezi snímky metrik",
|
||||
"excludedMountsLabel": "Vyloučené držáky",
|
||||
"monitoredMountsLabel": "Vlastní monitorované cesty",
|
||||
"monitoredMountsDesc": "Přidejte cesty, které nejsou zobrazeny jako samostatné připojení, včetně cest připojených k Home Assistant, jako je /config.",
|
||||
"monitoredMountPathPlaceholder": "Cesta (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Štítek (volitelné)",
|
||||
"addMonitoredMount": "Přidat cestu",
|
||||
"excludedMountsDesc": "Skrýt konkrétní body připojení nebo typy souborových systémů (např. nfs, cifs) před využitím disku a upozorněními pro tento hostitel.",
|
||||
"excludedMountsPlaceholder": "Cesta připojení (/mnt/nas) nebo typ souborového systému (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Žádné připojení nejsou vyloučena. Zobrazí se všechny detekované souborové systémy.",
|
||||
"addExcludedMount": "Přidat",
|
||||
"visibleWidgets": "Viditelné widgety",
|
||||
"widgetsMovedToHostMetrics": "Karty se nyní přidávají, uspořádávají a mění jejich velikost přímo na kartě Metriky hostitele. Otevřete Metriky hostitele pro tohoto hostitele a pomocí možnosti Přizpůsobit vyberte, které karty se zobrazí.",
|
||||
"hostNotFound": "Hostitel nenalezen",
|
||||
"loadingHost": "Načítání hostitele..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metriky a upozornění",
|
||||
"feature_metrics_desc": "Sledujte CPU, paměť a disk v průběhu času a nechte se upozornit, když se pokazí."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Uchovávání historie metrik",
|
||||
"metricsHistoryRetentionRange": "1 až 90 dní",
|
||||
"days": "dny"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Nejsou k dispozici žádná data",
|
||||
"showCurrentValue": "Zobrazit aktuální hodnotu",
|
||||
"chartMetric": "Metrický",
|
||||
"metricRange": "Rozsah",
|
||||
"host": "Hostitel",
|
||||
"loading": "Načítání...",
|
||||
"metricCpu": "Procesor",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Název hostitele",
|
||||
"metricKernel": "Jádro",
|
||||
"metricMemory": "Paměť",
|
||||
"metricNetwork": "Síť",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Procesy",
|
||||
"metricProcessesRunning": "běh",
|
||||
"metricProcessesTotal": "celkový",
|
||||
"metricUptime": "Doba provozuschopnosti",
|
||||
"metricsNotAvailable": "Metriky nejsou k dispozici",
|
||||
"noHostSelected": "Nebyl vybrán žádný hostitel",
|
||||
"selectHost": "Vyberte hostitele..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Živé statistiky nejsou k dispozici"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "Procesor",
|
||||
"ram": "BERAN"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Připojeno"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Používejte metriky hostitele",
|
||||
"description": "Zobrazte si metriky hostitelů a používejte správce hostitelů (služby, balíčky, firewall a další) na hostitelích, ke kterým máte přístup."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Interval metrik (sekundy)",
|
||||
"description": "Jak často je vzorkován zobrazený hostitel, pokud si hostitel nenastaví vlastní interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Uchování historie (dny)",
|
||||
"description": "Jak dlouho se uchovávají vzorky metrik pro historické grafy."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metriky pro nové hostitele",
|
||||
"description": "Zda noví hostitelé začínají se zapnutým sběrem metrik."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Jednotka teploty",
|
||||
"description": "Jak se teploty zobrazují v metrikách hostitele.",
|
||||
"celsius": "Celsia",
|
||||
"fahrenheit": "Fahrenheita"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Shromažďování metrik"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Interval metrik (sekundy)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Povolené widgety"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Vyloučené držáky"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Sledované cesty"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Værtsmålinger"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Hukommelse",
|
||||
"disk": "Disk",
|
||||
"network": "Netværk",
|
||||
"uptime": "Oppetid",
|
||||
"processes": "Processer",
|
||||
"available": "Tilgængelig",
|
||||
"free": "Gratis",
|
||||
"connecting": "Forbinder...",
|
||||
"connectionFailed": "Kunne ikke oprette forbindelse til serveren",
|
||||
"naCpus": "Ikke tilgængelig CPU(er)",
|
||||
"cpuCores_one": "{{count}} Kerne",
|
||||
"cpuCores_other": "{{count}} Kerner",
|
||||
"cpuUsage": "CPU-forbrug",
|
||||
"memoryUsage": "Hukommelsesforbrug",
|
||||
"diskUsage": "Diskforbrug",
|
||||
"selectFilesystem": "Vælg filsystem",
|
||||
"temperature": "Temperatur",
|
||||
"highestTemperature": "Højeste temperatur",
|
||||
"primaryTemperatureSensor": "Primær sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "Ingen NVIDIA GPU fundet. Installer NVIDIA-driveren (den leverer nvidia-smi) for at se GPU-målinger.",
|
||||
"driver": "Chauffør {{version}}",
|
||||
"utilization": "Udnyttelse",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp.",
|
||||
"power": "Magt",
|
||||
"fan": "Ventilator",
|
||||
"processes": "GPU-processer",
|
||||
"noProcesses": "Ingen processer bruger GPU'en"
|
||||
},
|
||||
"failedToFetchHostConfig": "Kunne ikke hente værtkonfigurationen",
|
||||
"serverOffline": "Server offline",
|
||||
"cannotFetchMetrics": "Kan ikke hente målinger fra offlineserveren",
|
||||
"totpFailed": "TOTP-bekræftelse mislykkedes",
|
||||
"noneAuthNotSupported": "Host Metrics understøtter ikke godkendelsestypen 'ingen'.",
|
||||
"noHostSelected": "Ingen vært valgt",
|
||||
"load": "Indlæs",
|
||||
"systemInfo": "Systemoplysninger",
|
||||
"hostname": "Værtsnavn",
|
||||
"operatingSystem": "Operativsystem",
|
||||
"kernel": "Kernen",
|
||||
"seconds": "sekunder",
|
||||
"networkInterfaces": "Netværksgrænseflader",
|
||||
"noInterfacesFound": "Ingen netværksgrænseflader fundet",
|
||||
"noProcessesFound": "Ingen processer fundet",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "løb",
|
||||
"loginStats": "SSH-loginstatistik",
|
||||
"noRecentLoginData": "Ingen nylige logindata",
|
||||
"ports": {
|
||||
"title": "Lytteporte",
|
||||
"protocol": "Protokol",
|
||||
"port": "Havn",
|
||||
"address": "Adresse",
|
||||
"process": "Behandle",
|
||||
"search": "Søg efter porte...",
|
||||
"allProtocols": "Alle",
|
||||
"noData": "Ingen data om lytteporte"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inaktiv",
|
||||
"policy": "Politik",
|
||||
"rules": "regler",
|
||||
"noRules": "Ingen regler i denne kæde",
|
||||
"noData": "Ingen firewalldata tilgængelige",
|
||||
"action": "Handling",
|
||||
"protocol": "Proto",
|
||||
"port": "Havn",
|
||||
"source": "Kilde",
|
||||
"anywhere": "Hvor som helst",
|
||||
"chains": "kæder"
|
||||
},
|
||||
"loadAvg": "Indlæs gennemsnit",
|
||||
"swap": "Byt",
|
||||
"architecture": "Arkitektur",
|
||||
"refresh": "Opfriske",
|
||||
"retry": "Prøv igen",
|
||||
"customize": "Tilpas layout",
|
||||
"reset": "Nulstil",
|
||||
"tabLive": "Levende",
|
||||
"editModeInstructions": "Træk kort for at omarrangere, træk i den nederste kant for at ændre størrelsen, og brug breddeknappen til at ændre et korts spændvidde. Tilføj eller fjern kort nedenfor.",
|
||||
"managers": {
|
||||
"services": "Tjenester",
|
||||
"processInspector": "Procesinspektør",
|
||||
"logViewer": "Logfremviser",
|
||||
"cron": "Cron-job",
|
||||
"packages": "Pakker",
|
||||
"ssl": "SSL-certifikater",
|
||||
"firewall": "Firewall",
|
||||
"users": "Brugere og tilladelser",
|
||||
"healthCheck": "Sundhedstjek",
|
||||
"diskBreakdown": "Disknedbrydning",
|
||||
"systemdTimers": "Timere",
|
||||
"topMemory": "Top efter hukommelse",
|
||||
"noData": "Ingen data",
|
||||
"sudoHint": "Angiv en sudo-adgangskode til denne vært i værtseditoren for at aktivere privilegerede handlinger.",
|
||||
"filter": "Filter...",
|
||||
"start": "Starte",
|
||||
"stop": "Stop",
|
||||
"restart": "Genstart",
|
||||
"actionDone": "{{name}} opdateret",
|
||||
"actionFailed": "Handlingen mislykkedes",
|
||||
"signalSent": "Signal sendt til PID {{pid}}",
|
||||
"killHint": "Klik: afslut (SIGTERM). Højreklik: tvungen afbrydelse (SIGKILL).",
|
||||
"working": "Arbejder...",
|
||||
"update": "Opdatering",
|
||||
"upgradeAll": "Opgrader alle",
|
||||
"allUpToDate": "Alt er opdateret",
|
||||
"save": "Spare",
|
||||
"command": "Kommando",
|
||||
"enabled": "Aktiveret",
|
||||
"cronSaved": "Crontab opdateret",
|
||||
"clients": "Klienter",
|
||||
"dryRun": "Tørløb",
|
||||
"renew": "Forny",
|
||||
"noAcmeClient": "Ingen ACME-klient (certbot eller acme.sh) fundet på denne vært.",
|
||||
"addInputRule": "Tilføj INPUT-regel",
|
||||
"firewallWarning": "Ændringer gælder kun for kørsel, indtil de bevares. Pas på ikke at låse dig selv ude.",
|
||||
"ruleApplied": "Regel anvendt",
|
||||
"invalidPort": "Indtast en gyldig port (1-65535)",
|
||||
"newUsername": "Nyt brugernavn",
|
||||
"addUser": "Tilføje",
|
||||
"deleteUser": "Slet bruger",
|
||||
"noHealthChecks": "Ingen sundhedstjek konfigureret endnu.",
|
||||
"follow": "Følge",
|
||||
"noLogData": "Ingen log-output.",
|
||||
"tree": "Træ",
|
||||
"enableDisable": "Aktivér/deaktiver ved opstart",
|
||||
"grantSudo": "Giv sudo",
|
||||
"revokeSudo": "Tilbagekald sudo",
|
||||
"sslIssueCert": "Udstedelsescertifikat",
|
||||
"sslExpired": "Udløbet",
|
||||
"sslInDays": "i {{days}}d",
|
||||
"sslNeedDomain": "Angiv mindst ét domæne",
|
||||
"sslIssued": "Certifikat udstedt",
|
||||
"sslDomainsPlaceholder": "eksempel.com, www.eksempel.com",
|
||||
"sslHttpStandalone": "HTTP (selvstændig)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS-udbyder (f.eks. Cloudflare)",
|
||||
"sslIssueHint": "DNS-udbyderens legitimationsoplysninger skal allerede være konfigureret på værten.",
|
||||
"sslRevoke": "Tilbagekald certifikat",
|
||||
"sslRevoked": "Certifikat tilbagekaldt",
|
||||
"sslRevokeConfirm": "Tilbagekald og fjern certifikatet \"{{name}}\"? Dette kan ikke fortrydes.",
|
||||
"healthRun": "Løbe",
|
||||
"healthName": "Navn",
|
||||
"healthTarget": "Vært / adresse",
|
||||
"healthAddCheck": "Tilføj check",
|
||||
"healthSaved": "Sundhedstjek gemt",
|
||||
"healthMissingFields": "Hver check skal have et navn og et mål",
|
||||
"logFile": "Fil",
|
||||
"logUnit": "Enhed",
|
||||
"logCustomPath": "Brugerdefineret sti under /var/log (valgfrit)",
|
||||
"logGrep": "Filtrer linjer...",
|
||||
"firewallPersist": "Vedvarende regler",
|
||||
"firewallPersisted": "Firewallregler bevares",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard er ikke installeret på denne vært",
|
||||
"wgNoInterfaces": "Ingen WireGuard-grænseflader konfigureret",
|
||||
"wgInterfaceUp": "Bring op",
|
||||
"wgInterfaceDown": "Bring ned",
|
||||
"wgBringingUp": "Bringer {{name}} op...",
|
||||
"wgBringingDown": "Bringer {{name}} ned...",
|
||||
"wgInterfaceUpDone": "{{name}} er oppe",
|
||||
"wgInterfaceDownDone": "{{name}} er nede",
|
||||
"wgListenPort": "Havn",
|
||||
"wgPublicKey": "Offentlig nøgle",
|
||||
"wgEndpoint": "Endepunkt",
|
||||
"wgAllowedIPs": "Tilladte IP-adresser",
|
||||
"wgLastHandshake": "Sidste håndtryk",
|
||||
"wgHandshakeNever": "Aldrig",
|
||||
"wgTransfer": "Overførsel",
|
||||
"wgNoPeers": "Ingen ligemænd"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrikhistorik",
|
||||
"historySuffix": "Historie",
|
||||
"cpuMemoryDisk": "CPU / Hukommelse / Disk",
|
||||
"network": "Netværk",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Der er ingen historiske data tilgængelige for dette tidsinterval.",
|
||||
"custom": "Skik",
|
||||
"to": "til",
|
||||
"apply": "Anvende",
|
||||
"viewHistory": "Vis historik"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Værtsmålinger",
|
||||
"metricsCollectionLabel": "Metriksamling",
|
||||
"enableMetricsLabel": "Aktivér metrikker",
|
||||
"enableMetricsDesc": "Indsaml CPU-, RAM-, disk- og andre målinger fra denne vært",
|
||||
"useGlobalMetrics": "Brug globalt interval",
|
||||
"useGlobalMetricsDesc": "Tilsidesæt med det serveromfattende metrikinterval",
|
||||
"metricsIntervalS": "Målinger Interval (s)",
|
||||
"metricsIntervalDesc2": "Sekunder mellem metriske snapshots",
|
||||
"excludedMountsLabel": "Ekskluderede monteringer",
|
||||
"monitoredMountsLabel": "Brugerdefinerede overvågede stier",
|
||||
"monitoredMountsDesc": "Tilføj stier, der ikke er eksponeret som separate monteringer, herunder bind-monterede Home Assistant-stier såsom /config.",
|
||||
"monitoredMountPathPlaceholder": "Sti (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Etiket (valgfrit)",
|
||||
"addMonitoredMount": "Tilføj sti",
|
||||
"excludedMountsDesc": "Skjul specifikke monteringspunkter eller filsystemtyper (f.eks. nfs, cifs) fra diskbrug og advarsler for denne vært.",
|
||||
"excludedMountsPlaceholder": "Monteringssti (/mnt/nas) eller filsystemtype (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Ingen monteringer er udelukket. Alle detekterede filsystemer vises.",
|
||||
"addExcludedMount": "Tilføje",
|
||||
"visibleWidgets": "Synlige widgets",
|
||||
"widgetsMovedToHostMetrics": "Kort tilføjes, arrangeres og skaleres nu direkte i fanen Værtsmål. Åbn Værtsmål for denne vært, og brug Tilpas til at vælge, hvilke kort der vises.",
|
||||
"hostNotFound": "Værten blev ikke fundet",
|
||||
"loadingHost": "Indlæser vært..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Målinger og advarsler",
|
||||
"feature_metrics_desc": "Spor CPU, hukommelse og disk over tid, og få en advarsel, når de bliver dårlige."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Opbevaring af metrikhistorik",
|
||||
"metricsHistoryRetentionRange": "1 til 90 dage",
|
||||
"days": "dage"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Ingen data tilgængelige",
|
||||
"showCurrentValue": "Vis aktuel værdi",
|
||||
"chartMetric": "Metrisk",
|
||||
"metricRange": "Rækkevidde",
|
||||
"host": "Vært",
|
||||
"loading": "Indlæser...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Værtsnavn",
|
||||
"metricKernel": "Kernen",
|
||||
"metricMemory": "Hukommelse",
|
||||
"metricNetwork": "Netværk",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processer",
|
||||
"metricProcessesRunning": "løb",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Oppetid",
|
||||
"metricsNotAvailable": "Målinger er ikke tilgængelige",
|
||||
"noHostSelected": "Ingen vært valgt",
|
||||
"selectHost": "Vælg en vært..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Livestatistik ikke tilgængelig"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "VÆDDER"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Forbundet"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Brug værtsmålinger",
|
||||
"description": "Se værtsmålinger og brug værtsadministratorerne (tjenester, pakker, firewall og mere) på værter, du kan nå."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Måleinterval (sekunder)",
|
||||
"description": "Hvor ofte en vært, der vises, samples, medmindre værten angiver sit eget interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Historikopbevaring (dage)",
|
||||
"description": "Hvor længe metrikker gemmes til historikdiagrammerne."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Målinger aktiveret for nye værter",
|
||||
"description": "Om nye værter starter med indsamling af metrics aktiveret."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperaturenhed",
|
||||
"description": "Hvordan temperaturer vises i Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Indsaml målinger"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Måleinterval (sekunder)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Aktiverede widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Ekskluderede monteringer"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Overvågede stier"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Host-Metriken"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Arbeitsspeicher",
|
||||
"disk": "Festplatte",
|
||||
"network": "Netzwerk",
|
||||
"uptime": "Betriebszeit",
|
||||
"processes": "Prozesse",
|
||||
"available": "Verfügbar",
|
||||
"free": "Frei",
|
||||
"connecting": "Verbinden...",
|
||||
"connectionFailed": "Verbindung zum Server fehlgeschlagen",
|
||||
"naCpus": "N/A CPU(s)",
|
||||
"cpuCores_one": "{{count}} Kern",
|
||||
"cpuCores_other": "{{count}} Kerne",
|
||||
"cpuUsage": "CPU-Auslastung",
|
||||
"memoryUsage": "Arbeitsspeicherauslastung",
|
||||
"diskUsage": "Festplattenauslastung",
|
||||
"selectFilesystem": "Dateisystem auswählen",
|
||||
"temperature": "Temperatur",
|
||||
"highestTemperature": "Höchste Temperatur",
|
||||
"primaryTemperatureSensor": "Primärsensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "Es wurde keine NVIDIA-GPU erkannt. Installieren Sie den NVIDIA-Treiber (dieser stellt nvidia-smi bereit), um die GPU-Metriken anzuzeigen.",
|
||||
"driver": "Fahrer {{version}}",
|
||||
"utilization": "Verwendung",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Leistung",
|
||||
"fan": "Lüfter",
|
||||
"processes": "GPU-Prozesse",
|
||||
"noProcesses": "Es werden keine Prozesse die GPU nutzen."
|
||||
},
|
||||
"failedToFetchHostConfig": "Fehler beim Abrufen der Host-Konfiguration",
|
||||
"serverOffline": "Server offline",
|
||||
"cannotFetchMetrics": "Metriken können nicht vom Offline-Server abgerufen werden",
|
||||
"totpFailed": "TOTP-Überprüfung fehlgeschlagen",
|
||||
"noneAuthNotSupported": "Host Metrics unterstützt den Authentifizierungstyp 'none' nicht.",
|
||||
"noHostSelected": "Kein Host ausgewählt",
|
||||
"load": "Auslastung",
|
||||
"systemInfo": "Systeminformationen",
|
||||
"hostname": "Hostname",
|
||||
"operatingSystem": "Betriebssystem",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "Sekunden",
|
||||
"networkInterfaces": "Netzwerkschnittstellen",
|
||||
"noInterfacesFound": "Keine Netzwerkschnittstellen gefunden",
|
||||
"noProcessesFound": "Keine Prozesse gefunden",
|
||||
"processesTotal": "gesamt",
|
||||
"processesRunning": "laufend",
|
||||
"loginStats": "SSH-Anmeldestatistiken",
|
||||
"noRecentLoginData": "Keine aktuellen Anmeldedaten",
|
||||
"ports": {
|
||||
"title": "Lauschende Ports",
|
||||
"protocol": "Protokoll",
|
||||
"port": "Port",
|
||||
"address": "Adresse",
|
||||
"process": "Prozess",
|
||||
"search": "Ports durchsuchen...",
|
||||
"allProtocols": "Alle",
|
||||
"noData": "Keine Daten zu lauschenden Ports"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inaktiv",
|
||||
"policy": "Richtlinie",
|
||||
"rules": "Regeln",
|
||||
"noRules": "Keine Regeln in dieser Kette",
|
||||
"noData": "Keine Firewall-Daten verfügbar",
|
||||
"action": "Aktion",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Quelle",
|
||||
"anywhere": "Überall",
|
||||
"chains": "Ketten"
|
||||
},
|
||||
"loadAvg": "Lastdurchschnitt",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architektur",
|
||||
"refresh": "Aktualisieren",
|
||||
"retry": "Wiederholen",
|
||||
"customize": "Layout anpassen",
|
||||
"reset": "Zurücksetzen",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Ziehen Sie Karten, um sie neu anzuordnen, ziehen Sie die untere Kante, um die Größe zu ändern, und verwenden Sie die Breitenschaltfläche, um die Breite einer Karte anzupassen. Fügen Sie Karten unten hinzu oder entfernen Sie sie.",
|
||||
"managers": {
|
||||
"services": "Dienste",
|
||||
"processInspector": "Prozessinspektor",
|
||||
"logViewer": "Protokollbetrachter",
|
||||
"cron": "Cron-Jobs",
|
||||
"packages": "Pakete",
|
||||
"ssl": "SSL-Zertifikate",
|
||||
"firewall": "Firewall",
|
||||
"users": "Benutzer & Berechtigungen",
|
||||
"healthCheck": "Gesundheitsprüfungen",
|
||||
"diskBreakdown": "Datenträgeraufschlüsselung",
|
||||
"systemdTimers": "Timer",
|
||||
"topMemory": "Top nach Speicher",
|
||||
"noData": "Keine Daten",
|
||||
"sudoHint": "Legen Sie im Host-Editor ein sudo-Passwort für diesen Host fest, um privilegierte Aktionen zu ermöglichen.",
|
||||
"filter": "Filtern...",
|
||||
"start": "Starten",
|
||||
"stop": "Stoppen",
|
||||
"restart": "Neustarten",
|
||||
"actionDone": "{{name}} aktualisiert",
|
||||
"actionFailed": "Aktion fehlgeschlagen",
|
||||
"signalSent": "Signal an PID {{pid}} gesendet",
|
||||
"killHint": "Klicken: Beenden (SIGTERM). Rechtsklick: Erzwungenes Beenden (SIGKILL).",
|
||||
"working": "In Bearbeitung...",
|
||||
"update": "Aktualisieren",
|
||||
"upgradeAll": "Alle upgraden",
|
||||
"allUpToDate": "Alles ist auf dem neuesten Stand",
|
||||
"save": "Speichern",
|
||||
"command": "Befehl",
|
||||
"enabled": "Aktiviert",
|
||||
"cronSaved": "Crontab aktualisiert",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Trockenlauf",
|
||||
"renew": "Erneuern",
|
||||
"noAcmeClient": "Kein ACME-Client (certbot oder acme.sh) auf diesem Host gefunden.",
|
||||
"addInputRule": "INPUT-Regel hinzufügen",
|
||||
"firewallWarning": "Änderungen sind nur zur Laufzeit wirksam, bis sie gespeichert werden. Achten Sie darauf, sich nicht auszusperren.",
|
||||
"ruleApplied": "Regel angewendet",
|
||||
"invalidPort": "Gültigen Port eingeben (1–65535)",
|
||||
"newUsername": "Neuer Benutzername",
|
||||
"addUser": "Hinzufügen",
|
||||
"deleteUser": "Benutzer löschen",
|
||||
"noHealthChecks": "Noch keine Zustandsprüfungen konfiguriert.",
|
||||
"follow": "Folgen",
|
||||
"noLogData": "Keine Protokollausgabe.",
|
||||
"tree": "Baum",
|
||||
"enableDisable": "Beim Start aktivieren / deaktivieren",
|
||||
"grantSudo": "sudo gewähren",
|
||||
"revokeSudo": "sudo entziehen",
|
||||
"sslIssueCert": "Zertifikat ausstellen",
|
||||
"sslExpired": "Abgelaufen",
|
||||
"sslInDays": "in {{days}} T",
|
||||
"sslNeedDomain": "Geben Sie mindestens eine Domain ein",
|
||||
"sslIssued": "Zertifikat ausgestellt",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (Standalone)",
|
||||
"sslHttpWebroot": "HTTP (Webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS-Anbieter (z. B. cloudflare)",
|
||||
"sslIssueHint": "Anmeldedaten des DNS-Anbieters müssen bereits auf dem Host konfiguriert sein.",
|
||||
"sslRevoke": "Zertifikat widerrufen",
|
||||
"sslRevoked": "Zertifikat wurde widerrufen",
|
||||
"sslRevokeConfirm": "Zertifikat \"{{name}}\" widerrufen und entfernen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"healthRun": "Ausführen",
|
||||
"healthName": "Name",
|
||||
"healthTarget": "Host / Adresse",
|
||||
"healthAddCheck": "Prüfung hinzufügen",
|
||||
"healthSaved": "Statusprüfungen gespeichert",
|
||||
"healthMissingFields": "Jede Prüfung benötigt einen Namen und ein Ziel",
|
||||
"logFile": "Datei",
|
||||
"logUnit": "Unit",
|
||||
"logCustomPath": "Benutzerdefinierter Pfad unter /var/log (optional)",
|
||||
"logGrep": "Zeilen filtern...",
|
||||
"firewallPersist": "Regeln dauerhaft speichern",
|
||||
"firewallPersisted": "Firewall-Regeln dauerhaft gespeichert",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard ist auf diesem Host nicht installiert",
|
||||
"wgNoInterfaces": "Keine WireGuard-Schnittstellen konfiguriert",
|
||||
"wgInterfaceUp": "Aktivieren",
|
||||
"wgInterfaceDown": "Deaktivieren",
|
||||
"wgBringingUp": "{{name}} wird aktiviert...",
|
||||
"wgBringingDown": "{{name}} wird deaktiviert...",
|
||||
"wgInterfaceUpDone": "{{name}} ist aktiv",
|
||||
"wgInterfaceDownDone": "{{name}} ist deaktiviert",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Öffentlicher Schlüssel",
|
||||
"wgEndpoint": "Endpunkt",
|
||||
"wgAllowedIPs": "Erlaubte IPs",
|
||||
"wgLastHandshake": "Letzter Handshake",
|
||||
"wgHandshakeNever": "Nie",
|
||||
"wgTransfer": "Übertragung",
|
||||
"wgNoPeers": "Keine Konkurrenten"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrikenverlauf",
|
||||
"historySuffix": "Verlauf",
|
||||
"cpuMemoryDisk": "CPU / RAM / Festplatte",
|
||||
"network": "Netzwerk",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Keine Verlaufsdaten für diesen Zeitraum verfügbar.",
|
||||
"custom": "Benutzerdefiniert",
|
||||
"to": "bis",
|
||||
"apply": "Anwenden",
|
||||
"viewHistory": "Verlauf anzeigen"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Host-Metriken",
|
||||
"metricsCollectionLabel": "Metrikerfassung",
|
||||
"enableMetricsLabel": "Metriken aktivieren",
|
||||
"enableMetricsDesc": "CPU, RAM, Festplatten und andere Metriken von diesem Host erfassen",
|
||||
"useGlobalMetrics": "Globales Intervall verwenden",
|
||||
"useGlobalMetricsDesc": "Mit dem serverweiten Metrikintervall überschreiben",
|
||||
"metricsIntervalS": "Metrikintervall (s)",
|
||||
"metricsIntervalDesc2": "Sekunden zwischen Metrik-Momentaufnahmen",
|
||||
"excludedMountsLabel": "Ausgeschlossene Halterungen",
|
||||
"monitoredMountsLabel": "Benutzerdefinierte überwachte Pfade",
|
||||
"monitoredMountsDesc": "Fügen Sie Pfade hinzu, die nicht als separate Mounts verfügbar sind, einschließlich bind-gebundener Home Assistant-Pfade wie /config.",
|
||||
"monitoredMountPathPlaceholder": "Pfad (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Bezeichnung (optional)",
|
||||
"addMonitoredMount": "Pfad hinzufügen",
|
||||
"excludedMountsDesc": "Bestimmte Mountpunkte oder Dateisystemtypen (z. B. nfs, cifs) können von der Festplattennutzung und Warnmeldungen für diesen Host ausgeblendet werden.",
|
||||
"excludedMountsPlaceholder": "Mount-Pfad (/mnt/nas) oder Dateisystemtyp (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Keine Mounts ausgeschlossen. Alle erkannten Dateisysteme werden angezeigt.",
|
||||
"addExcludedMount": "Hinzufügen",
|
||||
"visibleWidgets": "Sichtbare Widgets",
|
||||
"widgetsMovedToHostMetrics": "Karten werden jetzt direkt im Tab „Host-Metriken“ hinzugefügt, angeordnet und in der Größe verändert. Öffnen Sie die Host-Metriken für diesen Host und verwenden Sie „Anpassen“, um auszuwählen, welche Karten angezeigt werden.",
|
||||
"hostNotFound": "Host nicht gefunden",
|
||||
"loadingHost": "Host wird geladen..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Kennzahlen und Warnmeldungen",
|
||||
"feature_metrics_desc": "Verfolgen Sie CPU, Arbeitsspeicher und Festplatte im Zeitverlauf und lassen Sie sich benachrichtigen, wenn diese Werte sinken."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Aufbewahrungsdauer der Metriken",
|
||||
"metricsHistoryRetentionRange": "1 bis 90 Tage",
|
||||
"days": "Tage"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Keine Daten verfügbar",
|
||||
"showCurrentValue": "Aktuellen Wert anzeigen",
|
||||
"chartMetric": "Metrik",
|
||||
"metricRange": "Bereich",
|
||||
"host": "Host",
|
||||
"loading": "Wird geladen...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Festplatte",
|
||||
"metricHostname": "Hostname",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Arbeitsspeicher",
|
||||
"metricNetwork": "Netzwerk",
|
||||
"metricOs": "Betriebssystem",
|
||||
"metricProcesses": "Prozesse",
|
||||
"metricProcessesRunning": "laufend",
|
||||
"metricProcessesTotal": "gesamt",
|
||||
"metricUptime": "Betriebszeit",
|
||||
"metricsNotAvailable": "Metriken nicht verfügbar",
|
||||
"noHostSelected": "Kein Host ausgewählt",
|
||||
"selectHost": "Host auswählen..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live-Statistiken nicht verfügbar"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Datenträger"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Verbunden"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Hostmetriken verwenden",
|
||||
"description": "Sehen Sie sich Host-Metriken an und nutzen Sie die Host-Manager (Dienste, Pakete, Firewall und mehr) auf Hosts, die Sie erreichen können."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrikintervall (Sekunden)",
|
||||
"description": "Wie oft ein angezeigter Host abgetastet wird, es sei denn, der Host legt sein eigenes Intervall fest."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Aufbewahrungsdauer (Tage)",
|
||||
"description": "Wie lange die Messwertbeispiele für die Verlaufsdiagramme aufbewahrt werden."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metriken für neue Hosts aktiviert",
|
||||
"description": "Ob neue Hosts mit aktivierter Metrikenerfassung starten."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperatureinheit",
|
||||
"description": "Wie Temperaturen in Host Metrics angezeigt werden.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Kennzahlen erfassen"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrikintervall (Sekunden)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Aktivierte Widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Ausgeschlossene Halterungen"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Überwachte Pfade"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Μετρήσεις κεντρικού υπολογιστή"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "ΚΜΕ",
|
||||
"memory": "Μνήμη",
|
||||
"disk": "Δίσκος",
|
||||
"network": "Δίκτυο",
|
||||
"uptime": "Χρόνος λειτουργίας",
|
||||
"processes": "Διαδικασίες",
|
||||
"available": "Διαθέσιμος",
|
||||
"free": "Δωρεάν",
|
||||
"connecting": "Συνδετικός...",
|
||||
"connectionFailed": "Αποτυχία σύνδεσης με τον διακομιστή",
|
||||
"naCpus": "Δ/Υ CPU(s)",
|
||||
"cpuCores_one": "{{count}} Πυρήνας",
|
||||
"cpuCores_other": "{{count}} Πυρήνες",
|
||||
"cpuUsage": "Χρήση CPU",
|
||||
"memoryUsage": "Χρήση μνήμης",
|
||||
"diskUsage": "Χρήση δίσκου",
|
||||
"selectFilesystem": "Επιλογή συστήματος αρχείων",
|
||||
"temperature": "Θερμοκρασία",
|
||||
"highestTemperature": "Υψηλότερη θερμοκρασία",
|
||||
"primaryTemperatureSensor": "Κύριος αισθητήρας",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "Δεν εντοπίστηκε GPU NVIDIA. Εγκαταστήστε το πρόγραμμα οδήγησης NVIDIA (παρέχει nvidia-smi) για να δείτε τις μετρήσεις GPU.",
|
||||
"driver": "Οδηγός {{version}}",
|
||||
"utilization": "Χρησιμοποίηση",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Θερμοκρασία",
|
||||
"power": "Εξουσία",
|
||||
"fan": "Ανεμιστήρας",
|
||||
"processes": "Διαδικασίες GPU",
|
||||
"noProcesses": "Δεν υπάρχουν διεργασίες που να χρησιμοποιούν την GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Αποτυχία ανάκτησης διαμόρφωσης κεντρικού υπολογιστή",
|
||||
"serverOffline": "Διακομιστής εκτός σύνδεσης",
|
||||
"cannotFetchMetrics": "Δεν είναι δυνατή η ανάκτηση μετρήσεων από διακομιστή εκτός σύνδεσης",
|
||||
"totpFailed": "Η επαλήθευση TOTP απέτυχε.",
|
||||
"noneAuthNotSupported": "Το Host Metrics δεν υποστηρίζει τον τύπο ελέγχου ταυτότητας 'κανένας'.",
|
||||
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
|
||||
"load": "Φορτίο",
|
||||
"systemInfo": "Πληροφορίες συστήματος",
|
||||
"hostname": "Όνομα κεντρικού υπολογιστή",
|
||||
"operatingSystem": "Λειτουργικό σύστημα",
|
||||
"kernel": "Πυρήνας",
|
||||
"seconds": "δευτερόλεπτα",
|
||||
"networkInterfaces": "Διεπαφές δικτύου",
|
||||
"noInterfacesFound": "Δεν βρέθηκαν διεπαφές δικτύου",
|
||||
"noProcessesFound": "Δεν βρέθηκαν διεργασίες",
|
||||
"processesTotal": "σύνολο",
|
||||
"processesRunning": "τρέξιμο",
|
||||
"loginStats": "Στατιστικά στοιχεία σύνδεσης SSH",
|
||||
"noRecentLoginData": "Δεν υπάρχουν πρόσφατα δεδομένα σύνδεσης",
|
||||
"ports": {
|
||||
"title": "Θύρες ακρόασης",
|
||||
"protocol": "Πρωτόκολλο",
|
||||
"port": "Λιμάνι",
|
||||
"address": "Διεύθυνση",
|
||||
"process": "Διαδικασία",
|
||||
"search": "Αναζήτηση θυρών...",
|
||||
"allProtocols": "Ολοι",
|
||||
"noData": "Δεν υπάρχουν δεδομένα θυρών ακρόασης"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Τείχος προστασίας",
|
||||
"inactive": "Αδρανής",
|
||||
"policy": "Πολιτική",
|
||||
"rules": "κανόνες",
|
||||
"noRules": "Δεν υπάρχουν κανόνες σε αυτήν την αλυσίδα",
|
||||
"noData": "Δεν υπάρχουν διαθέσιμα δεδομένα τείχους προστασίας",
|
||||
"action": "Δράση",
|
||||
"protocol": "Πρωτό",
|
||||
"port": "Λιμάνι",
|
||||
"source": "Πηγή",
|
||||
"anywhere": "Οπουδήποτε",
|
||||
"chains": "αλυσίδες"
|
||||
},
|
||||
"loadAvg": "Μέσος όρος φορτίου",
|
||||
"swap": "Ανταλαγή",
|
||||
"architecture": "Αρχιτεκτονική",
|
||||
"refresh": "Φρεσκάρω",
|
||||
"retry": "Δοκιμάζω πάλι",
|
||||
"customize": "Προσαρμογή διάταξης",
|
||||
"reset": "Επαναφορά",
|
||||
"tabLive": "Ζω",
|
||||
"editModeInstructions": "Σύρετε τις κάρτες για αναδιάταξη, σύρετε την κάτω άκρη για αλλαγή μεγέθους και χρησιμοποιήστε το κουμπί πλάτους για να αλλάξετε το εύρος μιας κάρτας. Προσθέστε ή αφαιρέστε κάρτες παρακάτω.",
|
||||
"managers": {
|
||||
"services": "Υπηρεσίες",
|
||||
"processInspector": "Επιθεωρητής Διαδικασίας",
|
||||
"logViewer": "Πρόγραμμα προβολής αρχείων καταγραφής",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Πακέτα",
|
||||
"ssl": "Πιστοποιητικά SSL",
|
||||
"firewall": "Τείχος προστασίας",
|
||||
"users": "Χρήστες και Δικαιώματα",
|
||||
"healthCheck": "Έλεγχοι υγείας",
|
||||
"diskBreakdown": "Ανάλυση δίσκου",
|
||||
"systemdTimers": "Χρονόμετρα",
|
||||
"topMemory": "Κορυφαία κατά Μνήμη",
|
||||
"noData": "Δεν υπάρχουν δεδομένα",
|
||||
"sudoHint": "Ορίστε έναν κωδικό πρόσβασης sudo για αυτόν τον κεντρικό υπολογιστή στον επεξεργαστή κεντρικού υπολογιστή για να ενεργοποιήσετε τις προνομιακές ενέργειες.",
|
||||
"filter": "Φίλτρο...",
|
||||
"start": "Αρχή",
|
||||
"stop": "Στάση",
|
||||
"restart": "Επανεκκίνηση",
|
||||
"actionDone": "{{name}} ενημερώθηκε",
|
||||
"actionFailed": "Η ενέργεια απέτυχε",
|
||||
"signalSent": "Σήμα που αποστέλλεται στο PID {{pid}}",
|
||||
"killHint": "Κάντε κλικ: τερματισμός (SIGTERM). Δεξί κλικ: αναγκαστική τερματισμός (SIGKILL).",
|
||||
"working": "Εργαζόμενος...",
|
||||
"update": "Εκσυγχρονίζω",
|
||||
"upgradeAll": "Αναβάθμιση όλων",
|
||||
"allUpToDate": "Όλα είναι ενημερωμένα",
|
||||
"save": "Εκτός",
|
||||
"command": "Εντολή",
|
||||
"enabled": "Ενεργοποιημένο",
|
||||
"cronSaved": "Το Crontab ενημερώθηκε",
|
||||
"clients": "Πελάτες",
|
||||
"dryRun": "Ξηρή λειτουργία",
|
||||
"renew": "Ανανεώνω",
|
||||
"noAcmeClient": "Δεν βρέθηκε πρόγραμμα-πελάτης ACME (certbot ή acme.sh) σε αυτόν τον κεντρικό υπολογιστή.",
|
||||
"addInputRule": "Προσθήκη κανόνα INPUT",
|
||||
"firewallWarning": "Οι αλλαγές ισχύουν μόνο κατά τον χρόνο εκτέλεσης μέχρι να διατηρηθούν. Προσέξτε να μην κλειδωθείτε έξω.",
|
||||
"ruleApplied": "Εφαρμόστηκε κανόνας",
|
||||
"invalidPort": "Εισαγάγετε μια έγκυρη θύρα (1-65535)",
|
||||
"newUsername": "Νέο όνομα χρήστη",
|
||||
"addUser": "Προσθέτω",
|
||||
"deleteUser": "Διαγραφή χρήστη",
|
||||
"noHealthChecks": "Δεν έχουν ρυθμιστεί ακόμη έλεγχοι εύρυθμης λειτουργίας.",
|
||||
"follow": "Ακολουθώ",
|
||||
"noLogData": "Δεν υπάρχει έξοδος αρχείου καταγραφής.",
|
||||
"tree": "Δέντρο",
|
||||
"enableDisable": "Ενεργοποίηση/απενεργοποίηση κατά την εκκίνηση",
|
||||
"grantSudo": "Grant sudo",
|
||||
"revokeSudo": "Ανάκληση sudo",
|
||||
"sslIssueCert": "Έκδοση πιστοποιητικού",
|
||||
"sslExpired": "Λήξη",
|
||||
"sslInDays": "σε {{days}}δ",
|
||||
"sslNeedDomain": "Εισαγάγετε τουλάχιστον έναν τομέα",
|
||||
"sslIssued": "Πιστοποιητικό που εκδόθηκε",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (αυτόνομο)",
|
||||
"sslHttpWebroot": "HTTP (weberroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "Πάροχος DNS (π.χ. cloudflare)",
|
||||
"sslIssueHint": "Τα διαπιστευτήρια του παρόχου DNS πρέπει να έχουν ήδη διαμορφωθεί στον κεντρικό υπολογιστή.",
|
||||
"sslRevoke": "Ανάκληση πιστοποιητικού",
|
||||
"sslRevoked": "Το πιστοποιητικό ανακλήθηκε",
|
||||
"sslRevokeConfirm": "Ανάκληση και κατάργηση του πιστοποιητικού \"{{name}}\"; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
|
||||
"healthRun": "Τρέξιμο",
|
||||
"healthName": "Ονομα",
|
||||
"healthTarget": "Υποδοχή / διεύθυνση",
|
||||
"healthAddCheck": "Προσθήκη επιταγής",
|
||||
"healthSaved": "Οι έλεγχοι υγείας αποθηκεύτηκαν",
|
||||
"healthMissingFields": "Κάθε έλεγχος χρειάζεται ένα όνομα και έναν στόχο",
|
||||
"logFile": "Αρχείο",
|
||||
"logUnit": "Μονάδα",
|
||||
"logCustomPath": "Προσαρμοσμένη διαδρομή στο /var/log (προαιρετικό)",
|
||||
"logGrep": "Γραμμές φίλτρου...",
|
||||
"firewallPersist": "Επιμονή στους κανόνες",
|
||||
"firewallPersisted": "Οι κανόνες του τείχους προστασίας παρέμειναν",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "Το WireGuard δεν είναι εγκατεστημένο σε αυτόν τον κεντρικό υπολογιστή",
|
||||
"wgNoInterfaces": "Δεν έχουν ρυθμιστεί διεπαφές WireGuard",
|
||||
"wgInterfaceUp": "Ανατρέφω",
|
||||
"wgInterfaceDown": "Ρίχνω",
|
||||
"wgBringingUp": "Εμφάνιση του {{name}}...",
|
||||
"wgBringingDown": "Κατεβάζοντας το {{name}}...",
|
||||
"wgInterfaceUpDone": "Το {{name}} είναι ενεργό",
|
||||
"wgInterfaceDownDone": "Το {{name}} δεν λειτουργεί",
|
||||
"wgListenPort": "Λιμάνι",
|
||||
"wgPublicKey": "Δημόσιο κλειδί",
|
||||
"wgEndpoint": "Τελικό σημείο",
|
||||
"wgAllowedIPs": "Επιτρεπόμενες IP",
|
||||
"wgLastHandshake": "Τελευταία χειραψία",
|
||||
"wgHandshakeNever": "Ποτέ",
|
||||
"wgTransfer": "Μεταφορά",
|
||||
"wgNoPeers": "Δεν υπάρχουν ομότιμοι"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Ιστορικό μετρήσεων",
|
||||
"historySuffix": "Ιστορία",
|
||||
"cpuMemoryDisk": "CPU / Μνήμη / Δίσκος",
|
||||
"network": "Δίκτυο",
|
||||
"download": "Συνάντηση",
|
||||
"upload": "Τέξας",
|
||||
"noData": "Δεν υπάρχουν διαθέσιμα δεδομένα ιστορικού για αυτό το χρονικό εύρος.",
|
||||
"custom": "Εθιμο",
|
||||
"to": "να",
|
||||
"apply": "Εφαρμόζω",
|
||||
"viewHistory": "Προβολή ιστορικού"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Μετρήσεις κεντρικού υπολογιστή",
|
||||
"metricsCollectionLabel": "Συλλογή μετρήσεων",
|
||||
"enableMetricsLabel": "Ενεργοποίηση μετρήσεων",
|
||||
"enableMetricsDesc": "Συλλογή μετρήσεων CPU, RAM, δίσκου και άλλων από αυτόν τον κεντρικό υπολογιστή",
|
||||
"useGlobalMetrics": "Χρήση καθολικού διαστήματος",
|
||||
"useGlobalMetricsDesc": "Παράκαμψη με το διάστημα μετρήσεων σε ολόκληρο τον διακομιστή",
|
||||
"metricsIntervalS": "Διάστημα μετρήσεων (s)",
|
||||
"metricsIntervalDesc2": "Δευτερόλεπτα μεταξύ στιγμιότυπων μετρήσεων",
|
||||
"excludedMountsLabel": "Εξαιρούμενες βάσεις",
|
||||
"monitoredMountsLabel": "Προσαρμοσμένες παρακολουθούμενες διαδρομές",
|
||||
"monitoredMountsDesc": "Προσθέστε διαδρομές που δεν εκτίθενται ως ξεχωριστές προσαρτήσεις, συμπεριλαμβανομένων των διαδρομών του Home Assistant που είναι προσαρτημένες στο bind, όπως το /config.",
|
||||
"monitoredMountPathPlaceholder": "Διαδρομή (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Ετικέτα (προαιρετικά)",
|
||||
"addMonitoredMount": "Προσθήκη διαδρομής",
|
||||
"excludedMountsDesc": "Απόκρυψη συγκεκριμένων σημείων προσάρτησης ή τύπων συστημάτων αρχείων (π.χ. nfs, cifs) από τη χρήση δίσκου και τις ειδοποιήσεις για αυτόν τον κεντρικό υπολογιστή.",
|
||||
"excludedMountsPlaceholder": "Διαδρομή προσάρτησης (/mnt/nas) ή τύπος συστήματος αρχείων (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Δεν εξαιρούνται προσαρτήσεις. Εμφανίζονται όλα τα εντοπισμένα συστήματα αρχείων.",
|
||||
"addExcludedMount": "Προσθέτω",
|
||||
"visibleWidgets": "Ορατά γραφικά στοιχεία",
|
||||
"widgetsMovedToHostMetrics": "Οι κάρτες προστίθενται, ταξινομούνται και αλλάζουν μέγεθος απευθείας στην καρτέλα Μετρήσεις κεντρικού υπολογιστή. Ανοίξτε τις Μετρήσεις κεντρικού υπολογιστή για αυτόν τον κεντρικό υπολογιστή και χρησιμοποιήστε την Προσαρμογή για να επιλέξετε ποιες κάρτες θα εμφανίζονται.",
|
||||
"hostNotFound": "Δεν βρέθηκε κεντρικός υπολογιστής",
|
||||
"loadingHost": "Φόρτωση κεντρικού υπολογιστή..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Μετρήσεις και ειδοποιήσεις",
|
||||
"feature_metrics_desc": "Παρακολουθήστε την CPU, τη μνήμη και τον δίσκο με την πάροδο του χρόνου και λάβετε ειδοποίηση όταν παρουσιάσουν προβλήματα."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Διατήρηση ιστορικού μετρήσεων",
|
||||
"metricsHistoryRetentionRange": "1 έως 90 ημέρες",
|
||||
"days": "ημέρες"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Δεν υπάρχουν διαθέσιμα δεδομένα",
|
||||
"showCurrentValue": "Εμφάνιση τρέχουσας τιμής",
|
||||
"chartMetric": "Μετρικός",
|
||||
"metricRange": "Σειρά",
|
||||
"host": "Πλήθος",
|
||||
"loading": "Φόρτωση...",
|
||||
"metricCpu": "ΚΜΕ",
|
||||
"metricDisk": "Δίσκος",
|
||||
"metricHostname": "Όνομα κεντρικού υπολογιστή",
|
||||
"metricKernel": "Πυρήνας",
|
||||
"metricMemory": "Μνήμη",
|
||||
"metricNetwork": "Δίκτυο",
|
||||
"metricOs": "Λειτουργικό σύστημα",
|
||||
"metricProcesses": "Διαδικασίες",
|
||||
"metricProcessesRunning": "τρέξιμο",
|
||||
"metricProcessesTotal": "σύνολο",
|
||||
"metricUptime": "Χρόνος λειτουργίας",
|
||||
"metricsNotAvailable": "Οι μετρήσεις δεν είναι διαθέσιμες",
|
||||
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
|
||||
"selectHost": "Επιλέξτε έναν οικοδεσπότη..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Δεν είναι διαθέσιμα ζωντανά στατιστικά στοιχεία"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "ΚΜΕ",
|
||||
"ram": "ΕΜΒΟΛΟ"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Δίσκος"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Συνδεδεμένος"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Χρήση μετρήσεων κεντρικού υπολογιστή",
|
||||
"description": "Δείτε μετρήσεις κεντρικού υπολογιστή και χρησιμοποιήστε τους διαχειριστές κεντρικού υπολογιστή (υπηρεσίες, πακέτα, τείχος προστασίας και άλλα) σε κεντρικούς υπολογιστές που μπορείτε να προσεγγίσετε."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Διάστημα μετρήσεων (δευτερόλεπτα)",
|
||||
"description": "Πόσο συχνά δειγματοληπτείται ένας κεντρικός υπολογιστής που προβάλλεται, εκτός εάν ο κεντρικός υπολογιστής ορίσει το δικό του διάστημα."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Διατήρηση ιστορικού (ημέρες)",
|
||||
"description": "Για πόσο χρονικό διάστημα διατηρούνται τα δείγματα μετρήσεων για τα γραφήματα ιστορικού."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Ενεργοποιημένες μετρήσεις για νέους οικοδεσπότες",
|
||||
"description": "Εάν οι νέοι οικοδεσπότες ξεκινούν με ενεργοποιημένη τη συλλογή μετρήσεων."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Μονάδα θερμοκρασίας",
|
||||
"description": "Πώς εμφανίζονται οι θερμοκρασίες στις Μετρήσεις Host.",
|
||||
"celsius": "Κελσίου",
|
||||
"fahrenheit": "θερμόμετρο Φαρενάιτ"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Συλλογή μετρήσεων"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Διάστημα μετρήσεων (δευτερόλεπτα)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Ενεργοποιημένα γραφικά στοιχεία"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Εξαιρούμενες βάσεις"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Παρακολουθούμενες διαδρομές"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Métricas del host"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memoria",
|
||||
"disk": "Disco",
|
||||
"network": "Red",
|
||||
"uptime": "Tiempo de actividad",
|
||||
"processes": "Procesos",
|
||||
"available": "Disponible",
|
||||
"free": "Libre",
|
||||
"connecting": "Conectando...",
|
||||
"connectionFailed": "Error al conectar con el servidor",
|
||||
"naCpus": "CPU(s) N/D",
|
||||
"cpuCores_one": "{{count}} núcleo",
|
||||
"cpuCores_other": "{{count}} núcleos",
|
||||
"cpuUsage": "Uso de CPU",
|
||||
"memoryUsage": "Uso de memoria",
|
||||
"diskUsage": "Uso del disco",
|
||||
"selectFilesystem": "Seleccionar sistema de archivos",
|
||||
"temperature": "Temperatura",
|
||||
"highestTemperature": "Temperatura más alta",
|
||||
"primaryTemperatureSensor": "Sensor primario",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No se ha detectado ninguna GPU NVIDIA. Instale el controlador NVIDIA (que incluye nvidia-smi) para ver las métricas de la GPU.",
|
||||
"driver": "Conductor {{version}}",
|
||||
"utilization": "Utilización",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temperatura",
|
||||
"power": "Fuerza",
|
||||
"fan": "Admirador",
|
||||
"processes": "procesos de GPU",
|
||||
"noProcesses": "Ningún proceso está utilizando la GPU."
|
||||
},
|
||||
"failedToFetchHostConfig": "Error al obtener la configuración del host",
|
||||
"serverOffline": "Servidor fuera de línea",
|
||||
"cannotFetchMetrics": "No se pueden obtener métricas de un servidor fuera de línea",
|
||||
"totpFailed": "Error en la verificación TOTP",
|
||||
"noneAuthNotSupported": "Host Metrics no admite el tipo de autenticación 'none'.",
|
||||
"noHostSelected": "Ningún host seleccionado",
|
||||
"load": "Carga",
|
||||
"systemInfo": "Información del sistema",
|
||||
"hostname": "Nombre de host",
|
||||
"operatingSystem": "Sistema operativo",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "segundos",
|
||||
"networkInterfaces": "Interfaces de red",
|
||||
"noInterfacesFound": "No se encontraron interfaces de red",
|
||||
"noProcessesFound": "No se encontraron procesos",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "en ejecución",
|
||||
"loginStats": "Estadísticas de inicio de sesión SSH",
|
||||
"noRecentLoginData": "No hay datos de inicio de sesión recientes",
|
||||
"ports": {
|
||||
"title": "Puertos en escucha",
|
||||
"protocol": "Protocolo",
|
||||
"port": "Puerto",
|
||||
"address": "Dirección",
|
||||
"process": "Proceso",
|
||||
"search": "Buscar puertos...",
|
||||
"allProtocols": "Todos",
|
||||
"noData": "No hay datos de puertos en escucha"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Cortafuegos",
|
||||
"inactive": "Inactivo",
|
||||
"policy": "Política",
|
||||
"rules": "reglas",
|
||||
"noRules": "No hay reglas en esta cadena",
|
||||
"noData": "No hay datos del cortafuegos disponibles",
|
||||
"action": "Acción",
|
||||
"protocol": "Proto",
|
||||
"port": "Puerto",
|
||||
"source": "Origen",
|
||||
"anywhere": "Cualquier origen",
|
||||
"chains": "cadenas"
|
||||
},
|
||||
"loadAvg": "Carga Promedio",
|
||||
"swap": "Swap",
|
||||
"architecture": "Arquitectura",
|
||||
"refresh": "Refrescar",
|
||||
"retry": "Reintentar",
|
||||
"customize": "Personalizar diseño",
|
||||
"reset": "Restablecer",
|
||||
"tabLive": "En vivo",
|
||||
"editModeInstructions": "Arrastre las tarjetas para reorganizarlas, arrastre el borde inferior para redimensionarlas, y use el botón de ancho para cambiar el ancho de una tarjeta. Añada o elimine tarjetas a continuación.",
|
||||
"managers": {
|
||||
"services": "Servicios",
|
||||
"processInspector": "Inspector de procesos",
|
||||
"logViewer": "Visor de registros",
|
||||
"cron": "Tareas Cron",
|
||||
"packages": "Paquetes",
|
||||
"ssl": "Certificados SSL",
|
||||
"firewall": "Firewall",
|
||||
"users": "Usuarios y permisos",
|
||||
"healthCheck": "Comprobaciones de estado",
|
||||
"diskBreakdown": "Desglose de disco",
|
||||
"systemdTimers": "Temporizadores",
|
||||
"topMemory": "Top por memoria",
|
||||
"noData": "Sin datos",
|
||||
"sudoHint": "Establezca una contraseña de sudo para este host en el editor del host para habilitar acciones privilegiadas.",
|
||||
"filter": "Filtrar...",
|
||||
"start": "Iniciar",
|
||||
"stop": "Detener",
|
||||
"restart": "Reiniciar",
|
||||
"actionDone": "{{name}} actualizado",
|
||||
"actionFailed": "Acción fallida",
|
||||
"signalSent": "Señal enviada a PID {{pid}}",
|
||||
"killHint": "Clic: terminar (SIGTERM). Clic derecho: forzar cierre (SIGKILL).",
|
||||
"working": "Trabajando...",
|
||||
"update": "Actualizar",
|
||||
"upgradeAll": "Actualizar todo",
|
||||
"allUpToDate": "Todo está actualizado",
|
||||
"save": "Guardar",
|
||||
"command": "Comando",
|
||||
"enabled": "Activado",
|
||||
"cronSaved": "Crontab actualizado",
|
||||
"clients": "Clientes",
|
||||
"dryRun": "Simulación",
|
||||
"renew": "Renovar",
|
||||
"noAcmeClient": "No se encontró ningún cliente ACME (certbot o acme.sh) en este host.",
|
||||
"addInputRule": "Añadir regla INPUT",
|
||||
"firewallWarning": "Los cambios son solo en tiempo de ejecución hasta que se persisten. Tenga cuidado de no quedarse bloqueado fuera.",
|
||||
"ruleApplied": "Regla aplicada",
|
||||
"invalidPort": "Introduzca un puerto válido (1-65535)",
|
||||
"newUsername": "Nuevo nombre de usuario",
|
||||
"addUser": "Añadir",
|
||||
"deleteUser": "Eliminar usuario",
|
||||
"noHealthChecks": "Aún no se han configurado comprobaciones de estado.",
|
||||
"follow": "Seguir",
|
||||
"noLogData": "Sin salida de registro.",
|
||||
"tree": "Árbol",
|
||||
"enableDisable": "Activar / desactivar al arrancar",
|
||||
"grantSudo": "Conceder sudo",
|
||||
"revokeSudo": "Revocar sudo",
|
||||
"sslIssueCert": "Emitir certificado",
|
||||
"sslExpired": "Caducado",
|
||||
"sslInDays": "en {{days}}d",
|
||||
"sslNeedDomain": "Introduzca al menos un dominio",
|
||||
"sslIssued": "Certificado emitido",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (standalone)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "Proveedor DNS (p. ej. cloudflare)",
|
||||
"sslIssueHint": "Las credenciales del proveedor DNS ya deben estar configuradas en el host.",
|
||||
"sslRevoke": "Revocar certificado",
|
||||
"sslRevoked": "Certificado revocado",
|
||||
"sslRevokeConfirm": "¿Revocar y eliminar el certificado \"{{name}}\"? Esta acción no se puede deshacer.",
|
||||
"healthRun": "Ejecutar",
|
||||
"healthName": "Nombre",
|
||||
"healthTarget": "Host / dirección",
|
||||
"healthAddCheck": "Añadir verificación",
|
||||
"healthSaved": "Verificaciones de estado guardadas",
|
||||
"healthMissingFields": "Cada verificación necesita un nombre y un destino",
|
||||
"logFile": "Archivo",
|
||||
"logUnit": "Unidad",
|
||||
"logCustomPath": "Ruta personalizada bajo /var/log (opcional)",
|
||||
"logGrep": "Filtrar líneas...",
|
||||
"firewallPersist": "Persistir reglas",
|
||||
"firewallPersisted": "Reglas del firewall persistidas",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard no está instalado en este host",
|
||||
"wgNoInterfaces": "No hay interfaces WireGuard configuradas",
|
||||
"wgInterfaceUp": "Activar",
|
||||
"wgInterfaceDown": "Desactivar",
|
||||
"wgBringingUp": "Activando {{name}}...",
|
||||
"wgBringingDown": "Desactivando {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} está activado",
|
||||
"wgInterfaceDownDone": "{{name}} está desactivado",
|
||||
"wgListenPort": "Puerto",
|
||||
"wgPublicKey": "Clave pública",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "IPs permitidas",
|
||||
"wgLastHandshake": "Último handshake",
|
||||
"wgHandshakeNever": "Nunca",
|
||||
"wgTransfer": "Transferencia",
|
||||
"wgNoPeers": "Sin pares"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Historial de Métricas",
|
||||
"historySuffix": "Historial",
|
||||
"cpuMemoryDisk": "CPU / Memoria / Disco",
|
||||
"network": "Red",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "No hay datos de historial disponibles para este rango de tiempo.",
|
||||
"custom": "Personalizado",
|
||||
"to": "a",
|
||||
"apply": "Aplicar",
|
||||
"viewHistory": "Ver Historial"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Métricas del host",
|
||||
"metricsCollectionLabel": "Recopilación de métricas",
|
||||
"enableMetricsLabel": "Habilitar métricas",
|
||||
"enableMetricsDesc": "Recopilar CPU, RAM, disco y otras métricas de este host.",
|
||||
"useGlobalMetrics": "Usar intervalo global",
|
||||
"useGlobalMetricsDesc": "Sobrescribir con el intervalo de métricas global del servidor.",
|
||||
"metricsIntervalS": "Intervalo de métricas (s)",
|
||||
"metricsIntervalDesc2": "Segundos entre instantáneas de métricas.",
|
||||
"excludedMountsLabel": "Monturas excluidas",
|
||||
"monitoredMountsLabel": "Rutas personalizadas monitorizadas",
|
||||
"monitoredMountsDesc": "Agregue rutas que no estén expuestas como montajes separados, incluidas las rutas de Home Assistant montadas mediante enlace, como /config.",
|
||||
"monitoredMountPathPlaceholder": "Ruta (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Etiqueta (opcional)",
|
||||
"addMonitoredMount": "Agregar ruta",
|
||||
"excludedMountsDesc": "Oculta puntos de montaje o tipos de sistema de archivos específicos (por ejemplo, nfs, cifs) del uso del disco y las alertas para este host.",
|
||||
"excludedMountsPlaceholder": "Ruta de montaje (/mnt/nas) o tipo de sistema de archivos (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No se excluye ningún punto de montaje. Se muestran todos los sistemas de archivos detectados.",
|
||||
"addExcludedMount": "Añadir",
|
||||
"visibleWidgets": "Widgets visibles",
|
||||
"widgetsMovedToHostMetrics": "Ahora las tarjetas se añaden, organizan y redimensionan directamente en la pestaña Métricas del host. Abre Métricas del host para este host y usa Personalizar para elegir qué tarjetas se muestran.",
|
||||
"hostNotFound": "Host no encontrado",
|
||||
"loadingHost": "Cargando host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Métricas y alertas",
|
||||
"feature_metrics_desc": "Realiza un seguimiento del rendimiento de la CPU, la memoria y el disco duro a lo largo del tiempo, y recibe alertas cuando alguno falle."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Retención del historial de métricas",
|
||||
"metricsHistoryRetentionRange": "De 1 a 90 días",
|
||||
"days": "días"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Sin datos disponibles",
|
||||
"showCurrentValue": "Mostrar valor actual",
|
||||
"chartMetric": "Métrica",
|
||||
"metricRange": "Rango",
|
||||
"host": "Host",
|
||||
"loading": "Cargando...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disco",
|
||||
"metricHostname": "Nombre de host",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memoria",
|
||||
"metricNetwork": "Red",
|
||||
"metricOs": "SO",
|
||||
"metricProcesses": "Procesos",
|
||||
"metricProcessesRunning": "en ejecución",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Tiempo de actividad",
|
||||
"metricsNotAvailable": "Métricas no disponibles",
|
||||
"noHostSelected": "Ningún host seleccionado",
|
||||
"selectHost": "Seleccionar un host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Estadísticas en directo no disponibles"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disco"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Conectado"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Utilizar métricas de host",
|
||||
"description": "Consulta las métricas del host y utiliza los administradores de host (servicios, paquetes, cortafuegos y más) en los hosts a los que puedas acceder."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Intervalo de métricas (segundos)",
|
||||
"description": "Con qué frecuencia se muestrea el host que se está visualizando, a menos que el host establezca su propio intervalo."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Retención del historial (días)",
|
||||
"description": "¿Cuánto tiempo se conservan las muestras de métricas para los gráficos históricos?"
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Métricas activadas para nuevos anfitriones",
|
||||
"description": "Si los nuevos hosts comienzan con la recopilación de métricas activada."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Unidad de temperatura",
|
||||
"description": "Cómo se muestran las temperaturas en las métricas del host.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Recopilar métricas"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Intervalo de métricas (segundos)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Widgets habilitados"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Monturas excluidas"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Senderos vigilados"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Isännän mittarit"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "Suoritin",
|
||||
"memory": "Muisti",
|
||||
"disk": "Levy",
|
||||
"network": "Verkko",
|
||||
"uptime": "Käyttöaika",
|
||||
"processes": "Prosessit",
|
||||
"available": "Saatavilla",
|
||||
"free": "Ilmainen",
|
||||
"connecting": "Yhdistetään...",
|
||||
"connectionFailed": "Palvelimeen ei saatu yhteyttä",
|
||||
"naCpus": "Ei saatavilla olevaa suoritinta",
|
||||
"cpuCores_one": "{{count}} Ydin",
|
||||
"cpuCores_other": "{{count}} Ytimet",
|
||||
"cpuUsage": "Suorittimen käyttö",
|
||||
"memoryUsage": "Muistin käyttö",
|
||||
"diskUsage": "Levyn käyttö",
|
||||
"selectFilesystem": "Valitse tiedostojärjestelmä",
|
||||
"temperature": "Lämpötila",
|
||||
"highestTemperature": "Korkein lämpötila",
|
||||
"primaryTemperatureSensor": "Ensisijainen anturi",
|
||||
"gpu": {
|
||||
"title": "Näytönohjain",
|
||||
"noDevice": "NVIDIA-näytönohjainta ei havaittu. Asenna NVIDIA-ajuri (se tarjoaa nvidia-smi-ajurin) nähdäksesi näytönohjaimen tiedot.",
|
||||
"driver": "Kuljettaja {{version}}",
|
||||
"utilization": "Käyttö",
|
||||
"memory": "VRAM-muisti",
|
||||
"temperature": "Lämpötila",
|
||||
"power": "Voima",
|
||||
"fan": "Tuuletin",
|
||||
"processes": "GPU-prosessit",
|
||||
"noProcesses": "Mikään prosessi ei käytä grafiikkasuoritinta"
|
||||
},
|
||||
"failedToFetchHostConfig": "Isännän määritysten nouto epäonnistui",
|
||||
"serverOffline": "Palvelin offline-tilassa",
|
||||
"cannotFetchMetrics": "Mittarien nouto offline-palvelimelta ei onnistu",
|
||||
"totpFailed": "TOTP-vahvistus epäonnistui",
|
||||
"noneAuthNotSupported": "Isäntämittarit eivät tue 'none'-todennustyyppiä.",
|
||||
"noHostSelected": "Ei valittua isäntää",
|
||||
"load": "Ladata",
|
||||
"systemInfo": "Järjestelmätiedot",
|
||||
"hostname": "Isäntänimi",
|
||||
"operatingSystem": "Käyttöjärjestelmä",
|
||||
"kernel": "Ydin",
|
||||
"seconds": "sekuntia",
|
||||
"networkInterfaces": "Verkkoliitännät",
|
||||
"noInterfacesFound": "Verkkoliitäntöjä ei löytynyt",
|
||||
"noProcessesFound": "Ei prosesseja",
|
||||
"processesTotal": "kokonais-",
|
||||
"processesRunning": "juokseminen",
|
||||
"loginStats": "SSH-kirjautumistilastot",
|
||||
"noRecentLoginData": "Ei viimeaikaisia kirjautumistietoja",
|
||||
"ports": {
|
||||
"title": "Kuunteluportit",
|
||||
"protocol": "Protokolla",
|
||||
"port": "Portti",
|
||||
"address": "Osoite",
|
||||
"process": "Käsitellä",
|
||||
"search": "Hakusatamat...",
|
||||
"allProtocols": "Kaikki",
|
||||
"noData": "Ei kuunteluporttien tietoja"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Palomuuri",
|
||||
"inactive": "Passiivinen",
|
||||
"policy": "Käytäntö",
|
||||
"rules": "säännöt",
|
||||
"noRules": "Ei sääntöjä tässä ketjussa",
|
||||
"noData": "Palomuuritietoja ei ole saatavilla",
|
||||
"action": "Toiminta",
|
||||
"protocol": "Proto",
|
||||
"port": "Portti",
|
||||
"source": "Lähde",
|
||||
"anywhere": "Missä tahansa",
|
||||
"chains": "ketjut"
|
||||
},
|
||||
"loadAvg": "Keskimääräinen lataus",
|
||||
"swap": "Vaihtaa",
|
||||
"architecture": "Arkkitehtuuri",
|
||||
"refresh": "Päivitä",
|
||||
"retry": "Yritä uudelleen",
|
||||
"customize": "Mukauta asettelua",
|
||||
"reset": "Nollaa",
|
||||
"tabLive": "Elää",
|
||||
"editModeInstructions": "Vedä kortteja järjestelläksesi niitä uudelleen, vedä alareunaa muuttaaksesi kokoa ja käytä leveyspainiketta muuttaaksesi kortin väliä. Lisää tai poista kortteja alla.",
|
||||
"managers": {
|
||||
"services": "Palvelut",
|
||||
"processInspector": "Prosessitarkastaja",
|
||||
"logViewer": "Lokikatseluohjelma",
|
||||
"cron": "Cron-työt",
|
||||
"packages": "Paketit",
|
||||
"ssl": "SSL-varmenteet",
|
||||
"firewall": "Palomuuri",
|
||||
"users": "Käyttäjät ja käyttöoikeudet",
|
||||
"healthCheck": "Terveystarkastukset",
|
||||
"diskBreakdown": "Levyn erittely",
|
||||
"systemdTimers": "Ajastimet",
|
||||
"topMemory": "Muistin mukaan huippu",
|
||||
"noData": "Ei tietoja",
|
||||
"sudoHint": "Aseta tälle isännälle sudo-salasana isäntäeditorissa ottaaksesi käyttöön etuoikeutetut toiminnot.",
|
||||
"filter": "Suodattaa...",
|
||||
"start": "Aloita",
|
||||
"stop": "Stop",
|
||||
"restart": "Käynnistä uudelleen",
|
||||
"actionDone": "{{name}} päivitetty",
|
||||
"actionFailed": "Toiminto epäonnistui",
|
||||
"signalSent": "Signaali lähetetään PID:lle {{pid}}",
|
||||
"killHint": "Napsauta: lopeta (SIGTERM). Napsauta hiiren kakkospainikkeella: pakota lopettaminen (SIGKILL).",
|
||||
"working": "Työskentely...",
|
||||
"update": "Päivittää",
|
||||
"upgradeAll": "Päivitä kaikki",
|
||||
"allUpToDate": "Kaikki on ajan tasalla",
|
||||
"save": "Tallentaa",
|
||||
"command": "Komento",
|
||||
"enabled": "Käytössä",
|
||||
"cronSaved": "Crontab päivitetty",
|
||||
"clients": "Asiakkaat",
|
||||
"dryRun": "Kuiva ajo",
|
||||
"renew": "Uudista",
|
||||
"noAcmeClient": "Tältä isännältä ei löytynyt ACME-asiakasohjelmaa (certbot tai acme.sh).",
|
||||
"addInputRule": "Lisää SYÖTTÖSääntö",
|
||||
"firewallWarning": "Muutokset ovat vain ajonaikaisia, kunnes ne pysyvät voimassa. Varo lukitsemasta itseäsi ulos.",
|
||||
"ruleApplied": "Sääntö sovellettu",
|
||||
"invalidPort": "Anna kelvollinen portti (1-65535)",
|
||||
"newUsername": "Uusi käyttäjätunnus",
|
||||
"addUser": "Lisätä",
|
||||
"deleteUser": "Poista käyttäjä",
|
||||
"noHealthChecks": "Ei vielä määritettyjä kuntotarkastuksia.",
|
||||
"follow": "Seuraa",
|
||||
"noLogData": "Ei lokitulostetta.",
|
||||
"tree": "Puu",
|
||||
"enableDisable": "Ota käyttöön / poista käytöstä käynnistyksen yhteydessä",
|
||||
"grantSudo": "Myönnä sudo",
|
||||
"revokeSudo": "Peruuta sudo",
|
||||
"sslIssueCert": "Myöntötodistus",
|
||||
"sslExpired": "Vanhentunut",
|
||||
"sslInDays": "{{days}}d:ssä",
|
||||
"sslNeedDomain": "Anna vähintään yksi verkkotunnus",
|
||||
"sslIssued": "Todistus myönnetty",
|
||||
"sslDomainsPlaceholder": "esimerkki.com, www.esimerkki.com",
|
||||
"sslHttpStandalone": "HTTP (erillinen)",
|
||||
"sslHttpWebroot": "HTTP (verkkojuuri)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS-palveluntarjoaja (esim. cloudflare)",
|
||||
"sslIssueHint": "DNS-palveluntarjoajan tunnistetietojen on oltava jo määritettyinä isännälle.",
|
||||
"sslRevoke": "Peruuta varmenne",
|
||||
"sslRevoked": "Varmenne peruutettu",
|
||||
"sslRevokeConfirm": "Peruuttaa ja poistaa varmenne \"{{name}}\"? Tätä ei voi perua.",
|
||||
"healthRun": "Juokse",
|
||||
"healthName": "Nimi",
|
||||
"healthTarget": "Isäntä / osoite",
|
||||
"healthAddCheck": "Lisää shekki",
|
||||
"healthSaved": "Terveystarkastukset tallennettu",
|
||||
"healthMissingFields": "Jokaisella tarkastuksella on oltava nimi ja kohde",
|
||||
"logFile": "Tiedosto",
|
||||
"logUnit": "Yksikkö",
|
||||
"logCustomPath": "Mukautettu polku /var/log-hakemistossa (valinnainen)",
|
||||
"logGrep": "Suodata viivat...",
|
||||
"firewallPersist": "Säilytä säännöt",
|
||||
"firewallPersisted": "Palomuurisäännöt säilyivät",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuardia ei ole asennettu tälle koneelle",
|
||||
"wgNoInterfaces": "Ei WireGuard-liitäntöjä määritettynä",
|
||||
"wgInterfaceUp": "Tuo esiin",
|
||||
"wgInterfaceDown": "Alentaa",
|
||||
"wgBringingUp": "Tuodaan {{name}} esiin...",
|
||||
"wgBringingDown": "Lasketaan {{name}} alas...",
|
||||
"wgInterfaceUpDone": "{{name}} on ylhäällä",
|
||||
"wgInterfaceDownDone": "{{name}} on alhaalla",
|
||||
"wgListenPort": "Portti",
|
||||
"wgPublicKey": "Julkinen avain",
|
||||
"wgEndpoint": "Päätepiste",
|
||||
"wgAllowedIPs": "Sallitut IP-osoitteet",
|
||||
"wgLastHandshake": "Viimeinen kädenpuristus",
|
||||
"wgHandshakeNever": "Ei koskaan",
|
||||
"wgTransfer": "Siirtää",
|
||||
"wgNoPeers": "Ei vertaisia"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Mittarihistoria",
|
||||
"historySuffix": "Historia",
|
||||
"cpuMemoryDisk": "CPU / Muisti / Levy",
|
||||
"network": "Verkko",
|
||||
"download": "Vastaanottaja",
|
||||
"upload": "Teksas",
|
||||
"noData": "Tältä aikaväliltä ei ole saatavilla historiatietoja.",
|
||||
"custom": "Mukautettu",
|
||||
"to": "että",
|
||||
"apply": "Käytä",
|
||||
"viewHistory": "Näytä historia"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Isännän mittarit",
|
||||
"metricsCollectionLabel": "Mittarikokoelma",
|
||||
"enableMetricsLabel": "Ota mittarit käyttöön",
|
||||
"enableMetricsDesc": "Kerää prosessorin, RAMin, levyn ja muita mittareita tältä isännältä",
|
||||
"useGlobalMetrics": "Käytä globaalia intervallia",
|
||||
"useGlobalMetricsDesc": "Ohita palvelimen laajuisella mittarivälillä",
|
||||
"metricsIntervalS": "Mittariväli (s)",
|
||||
"metricsIntervalDesc2": "Sekunteja mittareiden tilannevedosten välillä",
|
||||
"excludedMountsLabel": "Poissuljetut kiinnikkeet",
|
||||
"monitoredMountsLabel": "Mukautetut valvotut polut",
|
||||
"monitoredMountsDesc": "Lisää polut, joita ei näytetä erillisinä liitoskohtina, mukaan lukien sidotut Home Assistant -polut, kuten /config.",
|
||||
"monitoredMountPathPlaceholder": "Polku (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Tarra (valinnainen)",
|
||||
"addMonitoredMount": "Lisää polku",
|
||||
"excludedMountsDesc": "Piilota tietyt liitoskohdat tai tiedostojärjestelmätyypit (esim. nfs, cifs) levyn käytöstä ja hälytyksistä tälle isännälle.",
|
||||
"excludedMountsPlaceholder": "Liitospolku (/mnt/nas) tai tiedostojärjestelmän tyyppi (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Ei poissuljettuja liitäntöjä. Kaikki havaitut tiedostojärjestelmät näytetään.",
|
||||
"addExcludedMount": "Lisätä",
|
||||
"visibleWidgets": "Näkyvät widgetit",
|
||||
"widgetsMovedToHostMetrics": "Kortteja lisätään, järjestetään ja niiden kokoa muutetaan nyt suoraan Isännän mittarit -välilehdellä. Avaa tämän isännän Isännän mittarit ja valitse näytettävät kortit Mukauta-toiminnolla.",
|
||||
"hostNotFound": "Isäntää ei löydy",
|
||||
"loadingHost": "Ladataan isäntää..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Mittarit ja hälytykset",
|
||||
"feature_metrics_desc": "Seuraa suorittimen, muistin ja levyn toimintaa ajan kuluessa ja saat ilmoituksen, kun ne vikaantuvat."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Mittarihistorian säilytys",
|
||||
"metricsHistoryRetentionRange": "1–90 päivää",
|
||||
"days": "päivää"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Ei tietoja saatavilla",
|
||||
"showCurrentValue": "Näytä nykyinen arvo",
|
||||
"chartMetric": "Metrinen",
|
||||
"metricRange": "Alue",
|
||||
"host": "Isäntä",
|
||||
"loading": "Ladataan...",
|
||||
"metricCpu": "Suoritin",
|
||||
"metricDisk": "Levy",
|
||||
"metricHostname": "Isäntänimi",
|
||||
"metricKernel": "Ydin",
|
||||
"metricMemory": "Muisti",
|
||||
"metricNetwork": "Verkko",
|
||||
"metricOs": "Käyttöjärjestelmä",
|
||||
"metricProcesses": "Prosessit",
|
||||
"metricProcessesRunning": "juokseminen",
|
||||
"metricProcessesTotal": "kokonais-",
|
||||
"metricUptime": "Käyttöaika",
|
||||
"metricsNotAvailable": "Mittareita ei ole saatavilla",
|
||||
"noHostSelected": "Ei valittua isäntää",
|
||||
"selectHost": "Valitse isäntä..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live-tilastot eivät ole saatavilla"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "Suoritin",
|
||||
"ram": "RAM-muistia"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Levy"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Yhdistetty"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Käytä isäntämittareita",
|
||||
"description": "Tarkastele isäntätietokoneen mittareita ja käytä isäntätietokoneiden hallintaohjelmia (palvelut, paketit, palomuuri ja paljon muuta) tavoitettavilla isännillä."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Mittariväli (sekunteina)",
|
||||
"description": "Kuinka usein tarkasteltavasta isännästä otetaan näytteitä, ellei isäntä aseta omaa aikaväliä."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Historian säilytys (päivää)",
|
||||
"description": "Kuinka kauan historiakaavioiden mittareiden näytteitä säilytetään."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Mittarit käytössä uusille isännille",
|
||||
"description": "Aloittavatko uudet isännät mittaustietojen kerääminen käytössä."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Lämpötilayksikkö",
|
||||
"description": "Miten lämpötilat näytetään isäntämetriikoissa.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Kerää mittareita"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Mittariväli (sekunteina)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Käytössä olevat widgetit"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Poissuljetut kiinnikkeet"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Valvotut polut"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Métriques de l'hôte"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Mémoire",
|
||||
"disk": "Disque",
|
||||
"network": "Réseau",
|
||||
"uptime": "Uptime",
|
||||
"processes": "Processus",
|
||||
"available": "Disponible",
|
||||
"free": "Libre",
|
||||
"connecting": "Connexion en cours…",
|
||||
"connectionFailed": "Échec de la connexion au serveur",
|
||||
"naCpus": "CPU(s) N/A",
|
||||
"cpuCores_one": "{{count}} cœur",
|
||||
"cpuCores_other": "{{count}} cœurs",
|
||||
"cpuUsage": "Utilisation CPU",
|
||||
"memoryUsage": "Utilisation mémoire",
|
||||
"diskUsage": "Utilisation disque",
|
||||
"selectFilesystem": "Sélectionner le système de fichiers",
|
||||
"temperature": "Température",
|
||||
"highestTemperature": "Température maximale",
|
||||
"primaryTemperatureSensor": "Capteur principal",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "Aucune carte graphique NVIDIA n'a été détectée. Installez le pilote NVIDIA (qui fournit nvidia-smi) pour consulter les métriques du GPU.",
|
||||
"driver": "Conducteur {{version}}",
|
||||
"utilization": "Utilisation",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Pouvoir",
|
||||
"fan": "Ventilateur",
|
||||
"processes": "Processus GPU",
|
||||
"noProcesses": "Aucun processus n'utilise le GPU."
|
||||
},
|
||||
"failedToFetchHostConfig": "Échec de la récupération de la configuration de l'hôte",
|
||||
"serverOffline": "Serveur hors ligne",
|
||||
"cannotFetchMetrics": "Impossible de récupérer les métriques du serveur hors ligne",
|
||||
"totpFailed": "Échec de la vérification TOTP",
|
||||
"noneAuthNotSupported": "Les métriques de l'hôte ne prennent pas en charge le type d'authentification 'none'.",
|
||||
"noHostSelected": "Aucun hôte sélectionné",
|
||||
"load": "Charge",
|
||||
"systemInfo": "Informations système",
|
||||
"hostname": "Nom d'hôte",
|
||||
"operatingSystem": "Système d'exploitation",
|
||||
"kernel": "Noyau",
|
||||
"seconds": "secondes",
|
||||
"networkInterfaces": "Interfaces réseau",
|
||||
"noInterfacesFound": "Aucune interface réseau trouvée",
|
||||
"noProcessesFound": "Aucun processus trouvé",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "en cours",
|
||||
"loginStats": "Statistiques de connexion SSH",
|
||||
"noRecentLoginData": "Aucune donnée de connexion récente",
|
||||
"ports": {
|
||||
"title": "Ports en écoute",
|
||||
"protocol": "Protocole",
|
||||
"port": "Port",
|
||||
"address": "Adresse",
|
||||
"process": "Processus",
|
||||
"search": "Rechercher des ports...",
|
||||
"allProtocols": "Tous",
|
||||
"noData": "Aucune donnée de ports en écoute"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Pare-feu",
|
||||
"inactive": "Inactif",
|
||||
"policy": "Politique",
|
||||
"rules": "règles",
|
||||
"noRules": "Aucune règle dans cette chaîne",
|
||||
"noData": "Aucune donnée de pare-feu disponible",
|
||||
"action": "Action",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Source",
|
||||
"anywhere": "Partout",
|
||||
"chains": "chaînes"
|
||||
},
|
||||
"loadAvg": "Charge moyenne",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architecture",
|
||||
"refresh": "Actualiser",
|
||||
"retry": "Réessayer",
|
||||
"customize": "Personnaliser la disposition",
|
||||
"reset": "Réinitialiser",
|
||||
"tabLive": "En direct",
|
||||
"editModeInstructions": "Faites glisser les cartes pour les réorganiser, faites glisser le bord inférieur pour les redimensionner, et utilisez le bouton de largeur pour modifier l'étendue d'une carte. Ajoutez ou retirez des cartes ci-dessous.",
|
||||
"managers": {
|
||||
"services": "Services",
|
||||
"processInspector": "Inspecteur de processus",
|
||||
"logViewer": "Visualiseur de logs",
|
||||
"cron": "Tâches Cron",
|
||||
"packages": "Paquets",
|
||||
"ssl": "Certificats SSL",
|
||||
"firewall": "Pare-feu",
|
||||
"users": "Utilisateurs et permissions",
|
||||
"healthCheck": "Contrôles de santé",
|
||||
"diskBreakdown": "Ventilation du disque",
|
||||
"systemdTimers": "Timers",
|
||||
"topMemory": "Top mémoire",
|
||||
"noData": "Aucune donnée",
|
||||
"sudoHint": "Définissez un mot de passe sudo pour cet hôte dans l'éditeur d'hôte pour activer les actions privilégiées.",
|
||||
"filter": "Filtrer...",
|
||||
"start": "Démarrer",
|
||||
"stop": "Arrêter",
|
||||
"restart": "Redémarrer",
|
||||
"actionDone": "{{name}} mis à jour",
|
||||
"actionFailed": "Action échouée",
|
||||
"signalSent": "Signal envoyé au PID {{pid}}",
|
||||
"killHint": "Clic : terminer (SIGTERM). Clic droit : forcer l'arrêt (SIGKILL).",
|
||||
"working": "En cours...",
|
||||
"update": "Mettre à jour",
|
||||
"upgradeAll": "Tout mettre à niveau",
|
||||
"allUpToDate": "Tout est à jour",
|
||||
"save": "Enregistrer",
|
||||
"command": "Commande",
|
||||
"enabled": "Activé",
|
||||
"cronSaved": "Crontab mis à jour",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Simulation",
|
||||
"renew": "Renouveler",
|
||||
"noAcmeClient": "Aucun client ACME (certbot ou acme.sh) trouvé sur cet hôte.",
|
||||
"addInputRule": "Ajouter une règle INPUT",
|
||||
"firewallWarning": "Les modifications sont appliquées uniquement en mémoire jusqu'à leur persistance. Veillez à ne pas vous bloquer l'accès.",
|
||||
"ruleApplied": "Règle appliquée",
|
||||
"invalidPort": "Saisissez un port valide (1-65535)",
|
||||
"newUsername": "Nouveau nom d'utilisateur",
|
||||
"addUser": "Ajouter",
|
||||
"deleteUser": "Supprimer l'utilisateur",
|
||||
"noHealthChecks": "Aucune vérification d'état configurée pour le moment.",
|
||||
"follow": "Suivre",
|
||||
"noLogData": "Aucun journal disponible.",
|
||||
"tree": "Arborescence",
|
||||
"enableDisable": "Activer / désactiver au démarrage",
|
||||
"grantSudo": "Accorder sudo",
|
||||
"revokeSudo": "Révoquer sudo",
|
||||
"sslIssueCert": "Émettre le certificat",
|
||||
"sslExpired": "Expiré",
|
||||
"sslInDays": "dans {{days}} j",
|
||||
"sslNeedDomain": "Saisissez au moins un domaine",
|
||||
"sslIssued": "Certificat émis",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (autonome)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "Fournisseur DNS (ex. cloudflare)",
|
||||
"sslIssueHint": "Les identifiants du fournisseur DNS doivent déjà être configurés sur l’hôte.",
|
||||
"sslRevoke": "Révoquer le certificat",
|
||||
"sslRevoked": "Certificat révoqué",
|
||||
"sslRevokeConfirm": "Révoquer et supprimer le certificat « {{name}} » ? Cette action est irréversible.",
|
||||
"healthRun": "Exécuter",
|
||||
"healthName": "Nom",
|
||||
"healthTarget": "Hôte / adresse",
|
||||
"healthAddCheck": "Ajouter une vérification",
|
||||
"healthSaved": "Vérifications de santé enregistrées",
|
||||
"healthMissingFields": "Chaque vérification nécessite un nom et une cible",
|
||||
"logFile": "Fichier",
|
||||
"logUnit": "Unité",
|
||||
"logCustomPath": "Chemin personnalisé sous /var/log (optionnel)",
|
||||
"logGrep": "Filtrer les lignes...",
|
||||
"firewallPersist": "Persister les règles",
|
||||
"firewallPersisted": "Règles du pare-feu rendues persistantes",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard n’est pas installé sur cet hôte",
|
||||
"wgNoInterfaces": "Aucune interface WireGuard configurée",
|
||||
"wgInterfaceUp": "Activer",
|
||||
"wgInterfaceDown": "Désactiver",
|
||||
"wgBringingUp": "Activation de {{name}}...",
|
||||
"wgBringingDown": "Désactivation de {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} est activé",
|
||||
"wgInterfaceDownDone": "{{name}} est désactivé",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Clé publique",
|
||||
"wgEndpoint": "Point de terminaison",
|
||||
"wgAllowedIPs": "IP autorisées",
|
||||
"wgLastHandshake": "Dernière poignée de main",
|
||||
"wgHandshakeNever": "Jamais",
|
||||
"wgTransfer": "Transfert",
|
||||
"wgNoPeers": "Aucun pair"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Historique des métriques",
|
||||
"historySuffix": "Historique",
|
||||
"cpuMemoryDisk": "CPU / Mémoire / Disque",
|
||||
"network": "Réseau",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Aucune donnée d'historique disponible pour cette plage de temps.",
|
||||
"custom": "Personnalisé",
|
||||
"to": "à",
|
||||
"apply": "Appliquer",
|
||||
"viewHistory": "Voir l'historique"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Métriques de l'hôte",
|
||||
"metricsCollectionLabel": "Collecte de métriques",
|
||||
"enableMetricsLabel": "Activer les métriques",
|
||||
"enableMetricsDesc": "Collecter les métriques CPU, RAM, disque et autres de cet hôte",
|
||||
"useGlobalMetrics": "Utiliser l'intervalle global",
|
||||
"useGlobalMetricsDesc": "Remplacer par l'intervalle de métriques défini à l'échelle du serveur",
|
||||
"metricsIntervalS": "Intervalle de métriques (s)",
|
||||
"metricsIntervalDesc2": "Secondes entre les instantanés de métriques",
|
||||
"excludedMountsLabel": "Supports exclus",
|
||||
"monitoredMountsLabel": "Chemins surveillés personnalisés",
|
||||
"monitoredMountsDesc": "Ajoutez les chemins qui ne sont pas exposés en tant que points de montage séparés, y compris les chemins Home Assistant montés par liaison tels que /config.",
|
||||
"monitoredMountPathPlaceholder": "Chemin (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Libellé (facultatif)",
|
||||
"addMonitoredMount": "Ajouter un chemin",
|
||||
"excludedMountsDesc": "Masquer certains points de montage ou types de systèmes de fichiers (par exemple nfs, cifs) des données d'utilisation du disque et des alertes pour cet hôte.",
|
||||
"excludedMountsPlaceholder": "Chemin de montage (/mnt/nas) ou type de système de fichiers (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Aucun point de montage n'est exclu. Tous les systèmes de fichiers détectés sont affichés.",
|
||||
"addExcludedMount": "Ajouter",
|
||||
"visibleWidgets": "Widgets visibles",
|
||||
"widgetsMovedToHostMetrics": "Les cartes sont désormais ajoutées, organisées et redimensionnées directement dans l'onglet Métriques de l'hôte. Ouvrez les métriques de cet hôte et utilisez Personnaliser pour choisir les cartes à afficher.",
|
||||
"hostNotFound": "Hôte introuvable",
|
||||
"loadingHost": "Chargement de l'hôte..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Métriques et alertes",
|
||||
"feature_metrics_desc": "Surveillez l'évolution du processeur, de la mémoire et du disque au fil du temps et recevez une alerte en cas de défaillance."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Rétention de l'historique des métriques",
|
||||
"metricsHistoryRetentionRange": "1 à 90 jours",
|
||||
"days": "jours"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Aucune donnée disponible",
|
||||
"showCurrentValue": "Afficher la valeur actuelle",
|
||||
"chartMetric": "Métrique",
|
||||
"metricRange": "Plage",
|
||||
"host": "Hôte",
|
||||
"loading": "Chargement…",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disque",
|
||||
"metricHostname": "Nom d’hôte",
|
||||
"metricKernel": "Noyau",
|
||||
"metricMemory": "Mémoire",
|
||||
"metricNetwork": "Réseau",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processus",
|
||||
"metricProcessesRunning": "en cours",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Uptime",
|
||||
"metricsNotAvailable": "Métriques non disponibles",
|
||||
"noHostSelected": "Aucun hôte sélectionné",
|
||||
"selectHost": "Sélectionner un hôte…"
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Statistiques en direct indisponibles"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disque"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connecté"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Utiliser les métriques de l'hôte",
|
||||
"description": "Consultez les indicateurs de performance des hôtes et utilisez les gestionnaires d'hôtes (services, paquets, pare-feu, etc.) sur les hôtes auxquels vous pouvez accéder."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Intervalle des métriques (secondes)",
|
||||
"description": "La fréquence d'échantillonnage d'un hôte visualisé est déterminée, sauf si l'hôte définit son propre intervalle."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Conservation de l'historique (jours)",
|
||||
"description": "Durée de conservation des échantillons de métriques pour les graphiques d'historique."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Indicateurs activés pour les nouveaux hôtes",
|
||||
"description": "Indique si les nouveaux hôtes démarrent avec la collecte de métriques activée."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Unité de température",
|
||||
"description": "Affichage des températures dans Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collecte de données"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Intervalle des métriques (secondes)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Widgets activés"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Supports exclus"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Chemins surveillés"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "מדדי מארח"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "מעבד",
|
||||
"memory": "זֵכֶר",
|
||||
"disk": "דִיסק",
|
||||
"network": "רֶשֶׁת",
|
||||
"uptime": "זמן פעולה",
|
||||
"processes": "תהליכים",
|
||||
"available": "זָמִין",
|
||||
"free": "לְשַׁחְרֵר",
|
||||
"connecting": "מְקַשֵׁר...",
|
||||
"connectionFailed": "נכשלה ההתחברות לשרת",
|
||||
"naCpus": "מעבדים לא רלוונטיים",
|
||||
"cpuCores_one": "{{count}} ליבה",
|
||||
"cpuCores_other": "{{count}} ליבות",
|
||||
"cpuUsage": "שימוש במעבד",
|
||||
"memoryUsage": "שימוש בזיכרון",
|
||||
"diskUsage": "שימוש בדיסק",
|
||||
"selectFilesystem": "בחירת מערכת קבצים",
|
||||
"temperature": "טֶמפֶּרָטוּרָה",
|
||||
"highestTemperature": "הטמפרטורה הגבוהה ביותר",
|
||||
"primaryTemperatureSensor": "חיישן ראשי",
|
||||
"gpu": {
|
||||
"title": "כרטיס מסך",
|
||||
"noDevice": "לא זוהה כרטיס מסך של NVIDIA. התקן את מנהל ההתקן של NVIDIA (הוא מספק nvidia-smi) כדי לראות את מדדי הכרטיס המסך.",
|
||||
"driver": "נהג {{version}}",
|
||||
"utilization": "ניצול",
|
||||
"memory": "זיכרון RAM",
|
||||
"temperature": "טמפ'",
|
||||
"power": "כּוֹחַ",
|
||||
"fan": "מְנִיפָה",
|
||||
"processes": "תהליכי GPU",
|
||||
"noProcesses": "אף תהליכים לא משתמשים ב-GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "נכשל באחזור תצורת המארח",
|
||||
"serverOffline": "שרת לא מקוון",
|
||||
"cannotFetchMetrics": "לא ניתן לאחזר מדדים משרת לא מקוון",
|
||||
"totpFailed": "אימות TOTP נכשל",
|
||||
"noneAuthNotSupported": "Host Metrics אינו תומך בסוג אימות 'ללא'.",
|
||||
"noHostSelected": "לא נבחר מארח",
|
||||
"load": "לִטעוֹן",
|
||||
"systemInfo": "מידע מערכת",
|
||||
"hostname": "שם מארח",
|
||||
"operatingSystem": "מַעֲרֶכֶת הַפעָלָה",
|
||||
"kernel": "גַרעִין",
|
||||
"seconds": "שניות",
|
||||
"networkInterfaces": "ממשקי רשת",
|
||||
"noInterfacesFound": "לא נמצאו ממשקי רשת",
|
||||
"noProcessesFound": "לא נמצאו תהליכים",
|
||||
"processesTotal": "סַך הַכֹּל",
|
||||
"processesRunning": "רִיצָה",
|
||||
"loginStats": "סטטיסטיקות כניסה ל-SSH",
|
||||
"noRecentLoginData": "אין נתוני התחברות אחרונים",
|
||||
"ports": {
|
||||
"title": "יציאות האזנה",
|
||||
"protocol": "פּרוֹטוֹקוֹל",
|
||||
"port": "נָמָל",
|
||||
"address": "כְּתוֹבֶת",
|
||||
"process": "תַהֲלִיך",
|
||||
"search": "חיפוש פורטים...",
|
||||
"allProtocols": "כֹּל",
|
||||
"noData": "אין נתוני יציאות האזנה"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "חומת אש",
|
||||
"inactive": "לֹא פָּעִיל",
|
||||
"policy": "מְדִינִיוּת",
|
||||
"rules": "כללים",
|
||||
"noRules": "אין חוקים בשרשרת הזו",
|
||||
"noData": "אין נתוני חומת אש זמינים",
|
||||
"action": "פְּעוּלָה",
|
||||
"protocol": "פרוטו",
|
||||
"port": "נָמָל",
|
||||
"source": "מָקוֹר",
|
||||
"anywhere": "בְּכָל מָקוֹם",
|
||||
"chains": "שרשראות"
|
||||
},
|
||||
"loadAvg": "טען ממוצע",
|
||||
"swap": "לְהַחלִיף",
|
||||
"architecture": "אַדְרִיכָלוּת",
|
||||
"refresh": "לְרַעֲנֵן",
|
||||
"retry": "נסה שוב",
|
||||
"customize": "התאמה אישית של הפריסה",
|
||||
"reset": "אִתחוּל",
|
||||
"tabLive": "לִחיוֹת",
|
||||
"editModeInstructions": "גרור כרטיסים כדי לסדר מחדש, גרור את הקצה התחתון כדי לשנות את הגודל, והשתמש בלחצן הרוחב כדי לשנות את טווח הכרטיס. הוסף או הסר כרטיסים למטה.",
|
||||
"managers": {
|
||||
"services": "שירותים",
|
||||
"processInspector": "מפקח תהליכים",
|
||||
"logViewer": "מציג יומנים",
|
||||
"cron": "עבודות קרון",
|
||||
"packages": "חבילות",
|
||||
"ssl": "תעודות SSL",
|
||||
"firewall": "חומת אש",
|
||||
"users": "משתמשים והרשאות",
|
||||
"healthCheck": "בדיקות בריאות",
|
||||
"diskBreakdown": "פירוט דיסק",
|
||||
"systemdTimers": "טיימרים",
|
||||
"topMemory": "מובילים לפי זיכרון",
|
||||
"noData": "אין נתונים",
|
||||
"sudoHint": "הגדר סיסמת sudo עבור מארח זה בעורך המארחים כדי לאפשר פעולות הרשאות.",
|
||||
"filter": "לְסַנֵן...",
|
||||
"start": "הַתחָלָה",
|
||||
"stop": "לְהַפְסִיק",
|
||||
"restart": "הפעלה מחדש",
|
||||
"actionDone": "{{name}} updated",
|
||||
"actionFailed": "הפעולה נכשלה",
|
||||
"signalSent": "אות שנשלח ל-PID {{pid}}",
|
||||
"killHint": "לחיצה: סיום (SIGTERM). לחיצה ימנית: כיבוי בכוח (SIGKILL).",
|
||||
"working": "עובד...",
|
||||
"update": "לְעַדְכֵּן",
|
||||
"upgradeAll": "שדרגו הכל",
|
||||
"allUpToDate": "הכל מעודכן",
|
||||
"save": "לְהַצִיל",
|
||||
"command": "פְּקוּדָה",
|
||||
"enabled": "מופעל",
|
||||
"cronSaved": "קרונטאב עודכן",
|
||||
"clients": "לקוחות",
|
||||
"dryRun": "ריצה יבשה",
|
||||
"renew": "לְחַדֵשׁ",
|
||||
"noAcmeClient": "לא נמצא לקוח ACME (certbot או acme.sh) בשרת זה.",
|
||||
"addInputRule": "הוסף כלל INPUT",
|
||||
"firewallWarning": "שינויים הם בזמן ריצה בלבד עד שהם מתמשכים. היזהרו לא לנעול את עצמכם מחוץ למערכת.",
|
||||
"ruleApplied": "כלל שהוחל",
|
||||
"invalidPort": "הזן פורט תקין (1-65535)",
|
||||
"newUsername": "שם משתמש חדש",
|
||||
"addUser": "לְהוֹסִיף",
|
||||
"deleteUser": "מחיקת משתמש",
|
||||
"noHealthChecks": "עדיין לא הוגדרו בדיקות תקינות.",
|
||||
"follow": "לַעֲקוֹב",
|
||||
"noLogData": "אין פלט יומן.",
|
||||
"tree": "עֵץ",
|
||||
"enableDisable": "הפעלה/השבתה בעת האתחול",
|
||||
"grantSudo": "גרנט סודו",
|
||||
"revokeSudo": "בטל את הסודו",
|
||||
"sslIssueCert": "הנפקת תעודה",
|
||||
"sslExpired": "פג תוקף",
|
||||
"sslInDays": "ב- {{days}}ד'",
|
||||
"sslNeedDomain": "הזן לפחות דומיין אחד",
|
||||
"sslIssued": "תעודה שהונפקה",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (עצמאי)",
|
||||
"sslHttpWebroot": "HTTP (שורש רשת)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "ספק DNS (למשל cloudflare)",
|
||||
"sslIssueHint": "יש להגדיר את פרטי הגישה של ספק ה-DNS כבר במארח.",
|
||||
"sslRevoke": "בטל את האישור",
|
||||
"sslRevoked": "התעודה בוטלה",
|
||||
"sslRevokeConfirm": "לבטל ולהסיר את האישור \"{{name}}\"? לא ניתן לבטל פעולה זו.",
|
||||
"healthRun": "לָרוּץ",
|
||||
"healthName": "שֵׁם",
|
||||
"healthTarget": "מארח / כתובת",
|
||||
"healthAddCheck": "הוסף צ'ק",
|
||||
"healthSaved": "בדיקות הבריאות נשמרו",
|
||||
"healthMissingFields": "כל צ'ק צריך שם ומטרה",
|
||||
"logFile": "קוֹבֶץ",
|
||||
"logUnit": "יְחִידָה",
|
||||
"logCustomPath": "נתיב מותאם אישית תחת /var/log (אופציונלי)",
|
||||
"logGrep": "קווי סינון...",
|
||||
"firewallPersist": "כללים קבועים",
|
||||
"firewallPersisted": "כללי חומת האש נשמרו",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard אינו מותקן על מחשב זה",
|
||||
"wgNoInterfaces": "לא הוגדרו ממשקי WireGuard",
|
||||
"wgInterfaceUp": "לְהַעֲלוֹת",
|
||||
"wgInterfaceDown": "לְהַפִּיל",
|
||||
"wgBringingUp": "מעלה {{name}}...",
|
||||
"wgBringingDown": "מוריד את {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} פעיל",
|
||||
"wgInterfaceDownDone": "{{name}} מושבת",
|
||||
"wgListenPort": "נָמָל",
|
||||
"wgPublicKey": "מפתח ציבורי",
|
||||
"wgEndpoint": "נקודת קצה",
|
||||
"wgAllowedIPs": "כתובות IP מותרות",
|
||||
"wgLastHandshake": "לחיצת יד אחרונה",
|
||||
"wgHandshakeNever": "לְעוֹלָם לֹא",
|
||||
"wgTransfer": "לְהַעֲבִיר",
|
||||
"wgNoPeers": "אין עמיתים"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "היסטוריית מדדים",
|
||||
"historySuffix": "הִיסטוֹרִיָה",
|
||||
"cpuMemoryDisk": "מעבד / זיכרון / דיסק",
|
||||
"network": "רֶשֶׁת",
|
||||
"download": "מרשם",
|
||||
"upload": "טקסס",
|
||||
"noData": "אין נתוני היסטוריה זמינים עבור טווח זמן זה.",
|
||||
"custom": "מִנְהָג",
|
||||
"to": "אֶל",
|
||||
"apply": "לִפְנוֹת",
|
||||
"viewHistory": "הצג היסטוריה"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "מדדי מארח",
|
||||
"metricsCollectionLabel": "אוסף מדדים",
|
||||
"enableMetricsLabel": "הפעל מדדים",
|
||||
"enableMetricsDesc": "איסוף מדדים של מעבד, זיכרון RAM, דיסק ומדדים אחרים ממארח זה",
|
||||
"useGlobalMetrics": "השתמש במרווח גלובלי",
|
||||
"useGlobalMetricsDesc": "עקיפה עם מרווח המדדים הכולל של השרת",
|
||||
"metricsIntervalS": "מרווח מדדים (ים)",
|
||||
"metricsIntervalDesc2": "שניות בין תמונות מצב של מדדים",
|
||||
"excludedMountsLabel": "תושבות לא כלולות",
|
||||
"monitoredMountsLabel": "נתיבים מנוטרים בהתאמה אישית",
|
||||
"monitoredMountsDesc": "הוסף נתיבים שאינם חשופים כחיבורים נפרדים, כולל נתיבי Home Assistant המותקנים באמצעות binding כגון /config.",
|
||||
"monitoredMountPathPlaceholder": "נתיב (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "תווית (אופציונלי)",
|
||||
"addMonitoredMount": "הוסף נתיב",
|
||||
"excludedMountsDesc": "הסתר נקודות הרכבה או סוגי מערכת קבצים ספציפיים (למשל nfs, cifs) משימוש בדיסק והתראות עבור מארח זה.",
|
||||
"excludedMountsPlaceholder": "נתיב הרכבה (/mnt/nas) או סוג מערכת קבצים (nfs, cifs)",
|
||||
"excludedMountsEmpty": "לא נכללו רכיבי טעינה. כל מערכות הקבצים שזוהו מוצגות.",
|
||||
"addExcludedMount": "לְהוֹסִיף",
|
||||
"visibleWidgets": "ווידג'טים גלויים",
|
||||
"widgetsMovedToHostMetrics": "כעת ניתן להוסיף, לסידור ולשנות את גודל הכרטיסים ישירות בכרטיסייה 'מדדי מארח'. פתחו את 'מדדי מארח' עבור מארח זה והשתמשו באפשרות 'התאמה אישית' כדי לבחור אילו כרטיסים יוצגו.",
|
||||
"hostNotFound": "המארח לא נמצא",
|
||||
"loadingHost": "טוען מארח..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "מדדים והתראות",
|
||||
"feature_metrics_desc": "עקוב אחר המעבד, הזיכרון והדיסק לאורך זמן, וקבל התראות כאשר הם מתקלקלים."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "שמירת היסטוריית מדדים",
|
||||
"metricsHistoryRetentionRange": "יום אחד עד 90 ימים",
|
||||
"days": "ימים"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "אין נתונים זמינים",
|
||||
"showCurrentValue": "הצג ערך נוכחי",
|
||||
"chartMetric": "מֶטרִי",
|
||||
"metricRange": "לָנוּעַ",
|
||||
"host": "מְאָרֵחַ",
|
||||
"loading": "טְעִינָה...",
|
||||
"metricCpu": "מעבד",
|
||||
"metricDisk": "דִיסק",
|
||||
"metricHostname": "שם מארח",
|
||||
"metricKernel": "גַרעִין",
|
||||
"metricMemory": "זֵכֶר",
|
||||
"metricNetwork": "רֶשֶׁת",
|
||||
"metricOs": "מערכת הפעלה",
|
||||
"metricProcesses": "תהליכים",
|
||||
"metricProcessesRunning": "רִיצָה",
|
||||
"metricProcessesTotal": "סַך הַכֹּל",
|
||||
"metricUptime": "זמן פעולה",
|
||||
"metricsNotAvailable": "מדדים אינם זמינים",
|
||||
"noHostSelected": "לא נבחר מארח",
|
||||
"selectHost": "בחר מארח..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "סטטיסטיקות חיות לא זמינות"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "מעבד",
|
||||
"ram": "אַיִל"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "דִיסק"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "מְחוּבָּר"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "השתמש במדדי מארח",
|
||||
"description": "צפה במדדי אירוח והשתמש במנהלי אירוח (שירותים, חבילות, חומת אש ועוד) עבור אירוחים שאתה יכול להגיע אליהם."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "מרווח מדדים (שניות)",
|
||||
"description": "באיזו תדירות נדגם מארח שנצפה, אלא אם כן המארח קובע מרווח זמן משלו."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "שמירת היסטוריה (ימים)",
|
||||
"description": "כמה זמן נשמרות דגימות מדדים עבור תרשימי ההיסטוריה."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "מדדים מופעלים עבור מארחים חדשים",
|
||||
"description": "האם מארחים חדשים מתחילים עם איסוף מדדים מופעל."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "יחידת טמפרטורה",
|
||||
"description": "כיצד מוצגות טמפרטורות ב- Host Metrics.",
|
||||
"celsius": "צֶלסִיוּס",
|
||||
"fahrenheit": "פרנהייט"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "איסוף מדדים"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "מרווח מדדים (שניות)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "ווידג'טים מופעלים"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "הרכבות לא כלולות"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "נתיבים מנוטרים"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "होस्ट मेट्रिक्स"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "मेमोरी",
|
||||
"disk": "डिस्क",
|
||||
"network": "नेटवर्क",
|
||||
"uptime": "अपटाइम",
|
||||
"processes": "प्रोसेसेस",
|
||||
"available": "उपलब्ध",
|
||||
"free": "फ्री",
|
||||
"connecting": "कनेक्ट हो रहा है...",
|
||||
"connectionFailed": "सर्वर से कनेक्ट करने में विफल",
|
||||
"naCpus": "N/A CPU(s)",
|
||||
"cpuCores_one": "{{count}} कोर",
|
||||
"cpuCores_other": "{{count}} कोर",
|
||||
"cpuUsage": "CPU उपयोग",
|
||||
"memoryUsage": "मेमोरी उपयोग",
|
||||
"diskUsage": "डिस्क उपयोग",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "तापमान",
|
||||
"highestTemperature": "उच्चतम तापमान",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "होस्ट कॉन्फ़िगरेशन प्राप्त करने में विफल",
|
||||
"serverOffline": "सर्वर ऑफ़लाइन",
|
||||
"cannotFetchMetrics": "ऑफ़लाइन सर्वर से मीट्रिक्स प्राप्त नहीं कर सकते",
|
||||
"totpFailed": "TOTP सत्यापन विफल",
|
||||
"noneAuthNotSupported": "होस्ट मेट्रिक्स 'none' प्रमाणीकरण प्रकार का समर्थन नहीं करता।",
|
||||
"noHostSelected": "कोई होस्ट चयनित नहीं",
|
||||
"load": "लोड",
|
||||
"systemInfo": "सिस्टम जानकारी",
|
||||
"hostname": "होस्टनाम",
|
||||
"operatingSystem": "ऑपरेटिंग सिस्टम",
|
||||
"kernel": "कर्नेल",
|
||||
"seconds": "सेकंड",
|
||||
"networkInterfaces": "नेटवर्क इंटरफ़ेस",
|
||||
"noInterfacesFound": "कोई नेटवर्क इंटरफ़ेस नहीं मिला",
|
||||
"noProcessesFound": "कोई प्रोसेस नहीं मिला",
|
||||
"processesTotal": "कुल",
|
||||
"processesRunning": "चल रहे",
|
||||
"loginStats": "SSH लॉगिन आँकड़े",
|
||||
"noRecentLoginData": "कोई हालिया लॉगिन डेटा नहीं",
|
||||
"ports": {
|
||||
"title": "लिसनिंग पोर्ट",
|
||||
"protocol": "प्रोटोकॉल",
|
||||
"port": "पोर्ट",
|
||||
"address": "पता",
|
||||
"process": "प्रोसेस",
|
||||
"search": "पोर्ट खोजें...",
|
||||
"allProtocols": "सभी",
|
||||
"noData": "कोई लिसनिंग पोर्ट डेटा नहीं"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "फ़ायरवॉल",
|
||||
"inactive": "निष्क्रिय",
|
||||
"policy": "नीति",
|
||||
"rules": "नियम",
|
||||
"noRules": "इस चेन में कोई नियम नहीं",
|
||||
"noData": "कोई फ़ायरवॉल डेटा उपलब्ध नहीं",
|
||||
"action": "कार्रवाई",
|
||||
"protocol": "प्रोटो",
|
||||
"port": "पोर्ट",
|
||||
"source": "स्रोत",
|
||||
"anywhere": "कहीं भी",
|
||||
"chains": "चेन्स"
|
||||
},
|
||||
"loadAvg": "लोड औसत",
|
||||
"swap": "स्वैप",
|
||||
"architecture": "आर्किटेक्चर",
|
||||
"refresh": "रीफ़्रेश",
|
||||
"retry": "पुनः प्रयास करें",
|
||||
"customize": "लेआउट अनुकूलित करें",
|
||||
"reset": "रीसेट",
|
||||
"tabLive": "लाइव",
|
||||
"editModeInstructions": "कार्ड को पुनर्व्यवस्थित करने के लिए खींचें, आकार बदलने के लिए निचले किनारे को खींचें, और कार्ड की चौड़ाई बदलने के लिए चौड़ाई बटन का उपयोग करें। नीचे कार्ड जोड़ें या हटाएं।",
|
||||
"managers": {
|
||||
"services": "सेवाएँ",
|
||||
"processInspector": "प्रोसेस इंस्पेक्टर",
|
||||
"logViewer": "लॉग व्यूअर",
|
||||
"cron": "क्रॉन जॉब्स",
|
||||
"packages": "पैकेजेस",
|
||||
"ssl": "SSL प्रमाणपत्र",
|
||||
"firewall": "फ़ायरवॉल",
|
||||
"users": "उपयोगकर्ता और अनुमतियाँ",
|
||||
"healthCheck": "स्वास्थ्य जांच",
|
||||
"diskBreakdown": "डिस्क ब्रेकडाउन",
|
||||
"systemdTimers": "टाइमर",
|
||||
"topMemory": "मेमोरी के अनुसार शीर्ष",
|
||||
"noData": "कोई डेटा नहीं",
|
||||
"sudoHint": "विशेषाधिकार क्रियाओं को सक्षम करने के लिए होस्ट संपादक में इस होस्ट के लिए एक sudo पासवर्ड सेट करें।",
|
||||
"filter": "फ़िल्टर...",
|
||||
"start": "प्रारंभ",
|
||||
"stop": "रोकें",
|
||||
"restart": "पुनरारंभ",
|
||||
"actionDone": "{{name}} अद्यतित",
|
||||
"actionFailed": "कार्रवाई विफल",
|
||||
"signalSent": "PID {{pid}} को सिग्नल भेजा गया",
|
||||
"killHint": "क्लिक: समाप्त करें (SIGTERM)। राइट-क्लिक: बलपूर्वक समाप्त करें (SIGKILL)।",
|
||||
"working": "काम कर रहा है...",
|
||||
"update": "अपडेट करें",
|
||||
"upgradeAll": "सभी अपग्रेड करें",
|
||||
"allUpToDate": "सब कुछ अप टू डेट है",
|
||||
"save": "सहेजें",
|
||||
"command": "कमांड",
|
||||
"enabled": "सक्षम",
|
||||
"cronSaved": "क्रॉनटैब अपडेट किया गया",
|
||||
"clients": "क्लाइंट",
|
||||
"dryRun": "ड्राई रन",
|
||||
"renew": "रीन्यू करें",
|
||||
"noAcmeClient": "इस होस्ट पर कोई ACME क्लाइंट (certbot या acme.sh) नहीं मिला।",
|
||||
"addInputRule": "INPUT नियम जोड़ें",
|
||||
"firewallWarning": "परिवर्तन केवल रनटाइम के लिए हैं जब तक स्थायी न हों। स्वयं को लॉक आउट करने से सावधान रहें।",
|
||||
"ruleApplied": "नियम लागू किया गया",
|
||||
"invalidPort": "एक वैध पोर्ट दर्ज करें (1-65535)",
|
||||
"newUsername": "नया उपयोगकर्ता नाम",
|
||||
"addUser": "जोड़ें",
|
||||
"deleteUser": "उपयोगकर्ता हटाएं",
|
||||
"noHealthChecks": "अभी तक कोई हेल्थ चेक कॉन्फ़िगर नहीं किया गया है।",
|
||||
"follow": "अनुसरण करें",
|
||||
"noLogData": "कोई लॉग आउटपुट नहीं।",
|
||||
"tree": "ट्री",
|
||||
"enableDisable": "बूट पर सक्षम / अक्षम करें",
|
||||
"grantSudo": "sudo दें",
|
||||
"revokeSudo": "sudo वापस लें",
|
||||
"sslIssueCert": "प्रमाणपत्र जारी करें",
|
||||
"sslExpired": "समाप्त",
|
||||
"sslInDays": "{{days}} दिनों में",
|
||||
"sslNeedDomain": "कम से कम एक डोमेन दर्ज करें",
|
||||
"sslIssued": "प्रमाणपत्र जारी किया गया",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (स्टैंडअलोन)",
|
||||
"sslHttpWebroot": "HTTP (वेबरूट)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS प्रदाता (उदा. cloudflare)",
|
||||
"sslIssueHint": "DNS प्रदाता क्रेडेंशियल पहले से होस्ट पर कॉन्फ़िगर किए गए होने चाहिए।",
|
||||
"sslRevoke": "प्रमाणपत्र निरस्त करें",
|
||||
"sslRevoked": "प्रमाणपत्र निरस्त किया गया",
|
||||
"sslRevokeConfirm": "प्रमाणपत्र \"{{name}}\" निरस्त करें और हटाएं? यह पूर्ववत नहीं किया जा सकता।",
|
||||
"healthRun": "चलाएं",
|
||||
"healthName": "नाम",
|
||||
"healthTarget": "होस्ट / पता",
|
||||
"healthAddCheck": "जांच जोड़ें",
|
||||
"healthSaved": "स्वास्थ्य जांच सहेजी गईं",
|
||||
"healthMissingFields": "प्रत्येक जांच के लिए एक नाम और लक्ष्य आवश्यक है",
|
||||
"logFile": "फ़ाइल",
|
||||
"logUnit": "यूनिट",
|
||||
"logCustomPath": "/var/log के अंतर्गत कस्टम पथ (वैकल्पिक)",
|
||||
"logGrep": "लाइनें फ़िल्टर करें...",
|
||||
"firewallPersist": "नियम स्थायी करें",
|
||||
"firewallPersisted": "फ़ायरवॉल नियम स्थायी किए गए",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard इस होस्ट पर स्थापित नहीं है",
|
||||
"wgNoInterfaces": "कोई WireGuard इंटरफ़ेस कॉन्फ़िगर्ड नहीं हैं",
|
||||
"wgInterfaceUp": "चालू करें",
|
||||
"wgInterfaceDown": "बंद करें",
|
||||
"wgBringingUp": "{{name}} चालू किया जा रहा है...",
|
||||
"wgBringingDown": "{{name}} बंद किया जा रहा है...",
|
||||
"wgInterfaceUpDone": "{{name}} चालू है",
|
||||
"wgInterfaceDownDone": "{{name}} बंद है",
|
||||
"wgListenPort": "पोर्ट",
|
||||
"wgPublicKey": "सार्वजनिक कुंजी",
|
||||
"wgEndpoint": "एंडपॉइंट",
|
||||
"wgAllowedIPs": "अनुमत IPs",
|
||||
"wgLastHandshake": "अंतिम हैंडशेक",
|
||||
"wgHandshakeNever": "कभी नहीं",
|
||||
"wgTransfer": "ट्रांसफर",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "मेट्रिक्स इतिहास",
|
||||
"historySuffix": "इतिहास",
|
||||
"cpuMemoryDisk": "CPU / मेमोरी / डिस्क",
|
||||
"network": "नेटवर्क",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "इस समय सीमा के लिए कोई इतिहास डेटा उपलब्ध नहीं है।",
|
||||
"custom": "कस्टम",
|
||||
"to": "तक",
|
||||
"apply": "लागू करें",
|
||||
"viewHistory": "इतिहास देखें"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "होस्ट मेट्रिक्स",
|
||||
"metricsCollectionLabel": "मेट्रिक्स संग्रह",
|
||||
"enableMetricsLabel": "मेट्रिक्स सक्षम करें",
|
||||
"enableMetricsDesc": "इस होस्ट से CPU, RAM, डिस्क और अन्य मेट्रिक्स एकत्र करें",
|
||||
"useGlobalMetrics": "वैश्विक अंतराल का उपयोग करें",
|
||||
"useGlobalMetricsDesc": "सर्वर-व्यापी मेट्रिक्स अंतराल से ओवरराइड करें",
|
||||
"metricsIntervalS": "मेट्रिक्स अंतराल (सेकंड)",
|
||||
"metricsIntervalDesc2": "मेट्रिक स्नैपशॉट के बीच सेकंड",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "लेबल (वैकल्पिक)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "जोड़ें",
|
||||
"visibleWidgets": "दृश्यमान विजेट्स",
|
||||
"widgetsMovedToHostMetrics": "कार्ड अब सीधे होस्ट मेट्रिक्स टैब में जोड़े, व्यवस्थित और आकार बदले जा सकते हैं। इस होस्ट के लिए होस्ट मेट्रिक्स खोलें और यह चुनने के लिए कस्टमाइज़ का उपयोग करें कि कौन से कार्ड दिखाए जाएँ।",
|
||||
"hostNotFound": "होस्ट नहीं मिला",
|
||||
"loadingHost": "होस्ट लोड हो रहा है..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "मेट्रिक्स इतिहास प्रतिधारण",
|
||||
"metricsHistoryRetentionRange": "1 से 90 दिन",
|
||||
"days": "दिन"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "कोई डेटा उपलब्ध नहीं",
|
||||
"showCurrentValue": "वर्तमान मान दिखाएं",
|
||||
"chartMetric": "मीट्रिक",
|
||||
"metricRange": "सीमा",
|
||||
"host": "होस्ट",
|
||||
"loading": "लोड हो रहा है...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "डिस्क",
|
||||
"metricHostname": "होस्टनेम",
|
||||
"metricKernel": "कर्नेल",
|
||||
"metricMemory": "मेमोरी",
|
||||
"metricNetwork": "नेटवर्क",
|
||||
"metricOs": "ओएस",
|
||||
"metricProcesses": "प्रोसेस",
|
||||
"metricProcessesRunning": "चालू",
|
||||
"metricProcessesTotal": "कुल",
|
||||
"metricUptime": "अपटाइम",
|
||||
"metricsNotAvailable": "मेट्रिक्स उपलब्ध नहीं",
|
||||
"noHostSelected": "कोई होस्ट चयनित नहीं",
|
||||
"selectHost": "होस्ट चुनें..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "डिस्क"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "कनेक्टेड"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Gazdagép-metrikák"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memória",
|
||||
"disk": "Korong",
|
||||
"network": "Hálózat",
|
||||
"uptime": "Üzemidő",
|
||||
"processes": "Folyamatok",
|
||||
"available": "Elérhető",
|
||||
"free": "Ingyenes",
|
||||
"connecting": "Kapcsolódás...",
|
||||
"connectionFailed": "Nem sikerült csatlakozni a szerverhez",
|
||||
"naCpus": "Nincs CPU",
|
||||
"cpuCores_one": "{{count}} Mag",
|
||||
"cpuCores_other": "{{count}} Magok",
|
||||
"cpuUsage": "CPU-használat",
|
||||
"memoryUsage": "Memóriahasználat",
|
||||
"diskUsage": "Lemezhasználat",
|
||||
"selectFilesystem": "Fájlrendszer kiválasztása",
|
||||
"temperature": "Hőmérséklet",
|
||||
"highestTemperature": "Legmagasabb hőmérséklet",
|
||||
"primaryTemperatureSensor": "Elsődleges érzékelő",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "Nem észlelhető NVIDIA GPU. Telepítse az NVIDIA illesztőprogramot (ez biztosítja az nvidia-smi-t) a GPU-metrikák megtekintéséhez.",
|
||||
"driver": "Sofőr {{version}}",
|
||||
"utilization": "Hasznosítás",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Hőmérséklet",
|
||||
"power": "Hatalom",
|
||||
"fan": "Ventilátor",
|
||||
"processes": "GPU-folyamatok",
|
||||
"noProcesses": "Egyik folyamat sem használja a GPU-t"
|
||||
},
|
||||
"failedToFetchHostConfig": "Nem sikerült lekérni a gazdagép konfigurációját",
|
||||
"serverOffline": "Szerver offline állapotban",
|
||||
"cannotFetchMetrics": "Nem lehet metrikákat lekérni az offline szerverről",
|
||||
"totpFailed": "TOTP-ellenőrzés sikertelen",
|
||||
"noneAuthNotSupported": "A Host Metrics nem támogatja a „nincs” hitelesítési típust.",
|
||||
"noHostSelected": "Nincs kiválasztva gazdagép",
|
||||
"load": "Terhelés",
|
||||
"systemInfo": "Rendszerinformációk",
|
||||
"hostname": "Gazdagépnév",
|
||||
"operatingSystem": "Operációs rendszer",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "másodperc",
|
||||
"networkInterfaces": "Hálózati interfészek",
|
||||
"noInterfacesFound": "Nem találhatók hálózati interfészek",
|
||||
"noProcessesFound": "Nem találhatók folyamatok",
|
||||
"processesTotal": "teljes",
|
||||
"processesRunning": "futás",
|
||||
"loginStats": "SSH bejelentkezési statisztikák",
|
||||
"noRecentLoginData": "Nincsenek friss bejelentkezési adatok",
|
||||
"ports": {
|
||||
"title": "Figyelő portok",
|
||||
"protocol": "Jegyzőkönyv",
|
||||
"port": "Kikötő",
|
||||
"address": "Cím",
|
||||
"process": "Folyamat",
|
||||
"search": "Keresési portok...",
|
||||
"allProtocols": "Minden",
|
||||
"noData": "Nincsenek figyelőport-adatok"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Tűzfal",
|
||||
"inactive": "Inaktív",
|
||||
"policy": "Politika",
|
||||
"rules": "szabályok",
|
||||
"noRules": "Nincsenek szabályok ebben a láncban",
|
||||
"noData": "Nincsenek elérhető tűzfaladatok",
|
||||
"action": "Akció",
|
||||
"protocol": "Proto",
|
||||
"port": "Kikötő",
|
||||
"source": "Forrás",
|
||||
"anywhere": "Bárhol",
|
||||
"chains": "láncok"
|
||||
},
|
||||
"loadAvg": "Átlagos betöltés",
|
||||
"swap": "Csere",
|
||||
"architecture": "Építészet",
|
||||
"refresh": "Frissítés",
|
||||
"retry": "Újrapróbálkozás",
|
||||
"customize": "Elrendezés testreszabása",
|
||||
"reset": "Visszaállítás",
|
||||
"tabLive": "Élő",
|
||||
"editModeInstructions": "Húzd a kártyákat az átrendezéshez, húzd az alsó szélüket az átméretezéshez, és használd a szélesség gombot a kártya szélességének módosításához. Alább adj hozzá vagy távolíts el kártyákat.",
|
||||
"managers": {
|
||||
"services": "Szolgáltatások",
|
||||
"processInspector": "Folyamatfelügyelő",
|
||||
"logViewer": "Naplómegjelenítő",
|
||||
"cron": "Cron-feladatok",
|
||||
"packages": "Csomagok",
|
||||
"ssl": "SSL tanúsítványok",
|
||||
"firewall": "Tűzfal",
|
||||
"users": "Felhasználók és engedélyek",
|
||||
"healthCheck": "Egészségügyi ellenőrzések",
|
||||
"diskBreakdown": "Lemezbontás",
|
||||
"systemdTimers": "Időzítők",
|
||||
"topMemory": "Top by Memory",
|
||||
"noData": "Nincsenek adatok",
|
||||
"sudoHint": "Állítson be egy sudo jelszót ehhez a gazdagéphez a gazdagép-szerkesztőben a privilegizált műveletek engedélyezéséhez.",
|
||||
"filter": "Szűrő...",
|
||||
"start": "Indul",
|
||||
"stop": "Stop",
|
||||
"restart": "Újraindítás",
|
||||
"actionDone": "{{name}} frissítve",
|
||||
"actionFailed": "Sikertelen művelet",
|
||||
"signalSent": "PID {{pid}}-ra küldött jel",
|
||||
"killHint": "Kattintás: leállítás (SIGTERM). Jobb klikk: kényszerített leállítás (SIGKILL).",
|
||||
"working": "Dolgozó...",
|
||||
"update": "Frissítés",
|
||||
"upgradeAll": "Összes frissítése",
|
||||
"allUpToDate": "Minden naprakész",
|
||||
"save": "Megtakarítás",
|
||||
"command": "Parancs",
|
||||
"enabled": "Engedélyezve",
|
||||
"cronSaved": "Crontab frissítve",
|
||||
"clients": "Ügyfelek",
|
||||
"dryRun": "Száraz futás",
|
||||
"renew": "Megújítás",
|
||||
"noAcmeClient": "Nem található ACME kliens (certbot vagy acme.sh) ezen a gazdagépen.",
|
||||
"addInputRule": "INPUT szabály hozzáadása",
|
||||
"firewallWarning": "A változtatások csak futásidejűek, amíg meg nem őrizve nem lesznek. Ügyeljen arra, hogy ne zárja ki magát.",
|
||||
"ruleApplied": "Szabály alkalmazva",
|
||||
"invalidPort": "Adjon meg egy érvényes portszámot (1-65535)",
|
||||
"newUsername": "Új felhasználónév",
|
||||
"addUser": "Hozzáadás",
|
||||
"deleteUser": "Felhasználó törlése",
|
||||
"noHealthChecks": "Még nincsenek konfigurálva állapotfelmérések.",
|
||||
"follow": "Követés",
|
||||
"noLogData": "Nincs naplókimenet.",
|
||||
"tree": "Fa",
|
||||
"enableDisable": "Engedélyezés / letiltás rendszerindításkor",
|
||||
"grantSudo": "Sudo engedélyezése",
|
||||
"revokeSudo": "Sudo visszavonása",
|
||||
"sslIssueCert": "Kibocsátási tanúsítvány",
|
||||
"sslExpired": "Lejárt",
|
||||
"sslInDays": "{{days}}d-ben",
|
||||
"sslNeedDomain": "Adjon meg legalább egy domaint",
|
||||
"sslIssued": "Kiállított tanúsítvány",
|
||||
"sslDomainsPlaceholder": "példa.com, www.példa.com",
|
||||
"sslHttpStandalone": "HTTP (önálló)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS-szolgáltató (pl. cloudflare)",
|
||||
"sslIssueHint": "A DNS-szolgáltató hitelesítő adatait már konfigurálni kell a gazdagépen.",
|
||||
"sslRevoke": "Tanúsítvány visszavonása",
|
||||
"sslRevoked": "Tanúsítvány visszavonva",
|
||||
"sslRevokeConfirm": "Visszavonja és eltávolítja a(z) \"{{name}}\" tanúsítványt? Ez nem vonható vissza.",
|
||||
"healthRun": "Fut",
|
||||
"healthName": "Név",
|
||||
"healthTarget": "Gazdagép / cím",
|
||||
"healthAddCheck": "Csekk hozzáadása",
|
||||
"healthSaved": "Egészségügyi ellenőrzések mentve",
|
||||
"healthMissingFields": "Minden ellenőrzéshez kell egy név és egy cél.",
|
||||
"logFile": "Fájl",
|
||||
"logUnit": "Egység",
|
||||
"logCustomPath": "Egyéni elérési út a /var/log könyvtárban (opcionális)",
|
||||
"logGrep": "Szűrővonalak...",
|
||||
"firewallPersist": "Szabályok megőrzése",
|
||||
"firewallPersisted": "A tűzfalszabályok megmaradtak",
|
||||
"wireguard": "Drótvédő",
|
||||
"wgNotInstalled": "A WireGuard nincs telepítve erre a gépre",
|
||||
"wgNoInterfaces": "Nincsenek konfigurált WireGuard interfészek",
|
||||
"wgInterfaceUp": "Felnevel",
|
||||
"wgInterfaceDown": "Lehoz",
|
||||
"wgBringingUp": "{{name}} felhozása...",
|
||||
"wgBringingDown": "{{name}} lehozása...",
|
||||
"wgInterfaceUpDone": "{{name}} fent van",
|
||||
"wgInterfaceDownDone": "{{name}} nem működik",
|
||||
"wgListenPort": "Kikötő",
|
||||
"wgPublicKey": "Nyilvános kulcs",
|
||||
"wgEndpoint": "Végpont",
|
||||
"wgAllowedIPs": "Engedélyezett IP-címek",
|
||||
"wgLastHandshake": "Utolsó kézfogás",
|
||||
"wgHandshakeNever": "Soha",
|
||||
"wgTransfer": "Átruházás",
|
||||
"wgNoPeers": "Nincsenek társaik"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrikák előzményei",
|
||||
"historySuffix": "Történelem",
|
||||
"cpuMemoryDisk": "CPU / Memória / Lemez",
|
||||
"network": "Hálózat",
|
||||
"download": "Vétel",
|
||||
"upload": "Texas",
|
||||
"noData": "Ehhez az időtartományhoz nem állnak rendelkezésre előzményadatok.",
|
||||
"custom": "Szokás",
|
||||
"to": "hogy",
|
||||
"apply": "Jelentkezés",
|
||||
"viewHistory": "Előzmények megtekintése"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Gazdagép-metrikák",
|
||||
"metricsCollectionLabel": "Metrikagyűjtemény",
|
||||
"enableMetricsLabel": "Metrikák engedélyezése",
|
||||
"enableMetricsDesc": "CPU, RAM, lemez és egyéb mérőszámok gyűjtése erről a gazdagépről",
|
||||
"useGlobalMetrics": "Globális intervallum használata",
|
||||
"useGlobalMetricsDesc": "Felülbírálás a szerverszintű metrika intervallummal",
|
||||
"metricsIntervalS": "Mérési intervallum (s)",
|
||||
"metricsIntervalDesc2": "Metrika pillanatképek között eltelt másodpercek",
|
||||
"excludedMountsLabel": "Kizárt tartók",
|
||||
"monitoredMountsLabel": "Egyéni figyelt útvonalak",
|
||||
"monitoredMountsDesc": "Adjon hozzá olyan elérési utakat, amelyek nincsenek különálló csatolásként közzétéve, beleértve a kötésként csatolt Home Assistant elérési utakat, például a /config-ot.",
|
||||
"monitoredMountPathPlaceholder": "Útvonal (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Címke (opcionális)",
|
||||
"addMonitoredMount": "Útvonal hozzáadása",
|
||||
"excludedMountsDesc": "Rejtsd el az adott csatolási pontokat vagy fájlrendszertípusokat (pl. nfs, cifs) a lemezhasználat és a riasztások elől ennél a gazdagépnél.",
|
||||
"excludedMountsPlaceholder": "Csatlakoztatási útvonal (/mnt/nas) vagy fájlrendszer típusa (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Nincsenek kizárva csatolások. Minden észlelt fájlrendszer látható.",
|
||||
"addExcludedMount": "Hozzáadás",
|
||||
"visibleWidgets": "Látható widgetek",
|
||||
"widgetsMovedToHostMetrics": "A kártyák mostantól közvetlenül a Gazdagép metrikái lapon adhatók hozzá, rendezhetők el és méretezhetők át. Nyissa meg a Gazdagép metrikái oldalt ehhez a gazdagéphez, és a Testreszabás gombbal válassza ki, hogy mely kártyák jelenjenek meg.",
|
||||
"hostNotFound": "Nem található gazdagép",
|
||||
"loadingHost": "Gazdagép betöltése..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrikák és riasztások",
|
||||
"feature_metrics_desc": "Kövesse nyomon a CPU, a memória és a lemez teljesítményét az idő múlásával, és kapjon értesítést, ha meghibásodnak."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrikák előzményeinek megőrzése",
|
||||
"metricsHistoryRetentionRange": "1-90 nap",
|
||||
"days": "napok"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Nincsenek elérhető adatok",
|
||||
"showCurrentValue": "Aktuális érték megjelenítése",
|
||||
"chartMetric": "Metrika",
|
||||
"metricRange": "Hatótávolság",
|
||||
"host": "Házigazda",
|
||||
"loading": "Terhelés...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Korong",
|
||||
"metricHostname": "Gazdagépnév",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memória",
|
||||
"metricNetwork": "Hálózat",
|
||||
"metricOs": "operációs rendszer",
|
||||
"metricProcesses": "Folyamatok",
|
||||
"metricProcessesRunning": "futás",
|
||||
"metricProcessesTotal": "teljes",
|
||||
"metricUptime": "Üzemidő",
|
||||
"metricsNotAvailable": "Mutatók nem állnak rendelkezésre",
|
||||
"noHostSelected": "Nincs kiválasztva gazdagép",
|
||||
"selectHost": "Válasszon egy hosztot..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Élő statisztikák nem elérhetők"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Korong"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Csatlakoztatva"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Gazdagép-metrikák használata",
|
||||
"description": "Tekintse meg a hoszt metrikáit, és használja a hosztkezelőket (szolgáltatásokat, csomagokat, tűzfalat és egyebeket) az elérhető hosztokon."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrika intervallum (másodperc)",
|
||||
"description": "Azt adja meg, hogy egy megtekintett gazdagépből milyen gyakran történik mintavételezés, kivéve, ha a gazdagép beállítja a saját intervallumát."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Előzménymegőrzés (nap)",
|
||||
"description": "Mennyi ideig tárolják a metrika mintákat az előzménydiagramokhoz."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrikák bekapcsolva az új házigazdák számára",
|
||||
"description": "Az új hosztok bekapcsolt metrikák gyűjtésével indulnak-e."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Hőmérséklet mértékegysége",
|
||||
"description": "Hogyan jelennek meg a hőmérsékletek a Host Metrics-ben?",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Metrikák gyűjtése"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrika intervallum (másodperc)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Engedélyezett widgetek"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Kizárt tartók"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Megfigyelt útvonalak"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Host Metrics"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"network": "Network",
|
||||
"uptime": "Uptime",
|
||||
"processes": "Processes",
|
||||
"available": "Available",
|
||||
"free": "Free",
|
||||
"connecting": "Connecting...",
|
||||
"connectionFailed": "Failed to connect to server",
|
||||
"naCpus": "N/A CPU(s)",
|
||||
"cpuCores_one": "{{count}} Core",
|
||||
"cpuCores_other": "{{count}} Cores",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"diskUsage": "Disk Usage",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Temperature",
|
||||
"highestTemperature": "Highest temperature",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Failed to fetch host configuration",
|
||||
"serverOffline": "Server Offline",
|
||||
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||
"totpFailed": "TOTP verification failed",
|
||||
"noneAuthNotSupported": "Host Metrics does not support 'none' authentication type.",
|
||||
"noHostSelected": "No host selected",
|
||||
"load": "Load",
|
||||
"systemInfo": "System Information",
|
||||
"hostname": "Hostname",
|
||||
"operatingSystem": "Operating System",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "seconds",
|
||||
"networkInterfaces": "Network Interfaces",
|
||||
"noInterfacesFound": "No network interfaces found",
|
||||
"noProcessesFound": "No processes found",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "running",
|
||||
"loginStats": "SSH Login Statistics",
|
||||
"noRecentLoginData": "No recent login data",
|
||||
"ports": {
|
||||
"title": "Listening Ports",
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"address": "Address",
|
||||
"process": "Process",
|
||||
"search": "Search ports...",
|
||||
"allProtocols": "All",
|
||||
"noData": "No listening ports data"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inactive",
|
||||
"policy": "Policy",
|
||||
"rules": "rules",
|
||||
"noRules": "No rules in this chain",
|
||||
"noData": "No firewall data available",
|
||||
"action": "Action",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Source",
|
||||
"anywhere": "Anywhere",
|
||||
"chains": "chains"
|
||||
},
|
||||
"loadAvg": "Load Avg",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architecture",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"customize": "Customize layout",
|
||||
"reset": "Reset",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Drag cards to rearrange, drag the bottom edge to resize, and use the width button to change a card's span. Add or remove cards below.",
|
||||
"managers": {
|
||||
"services": "Services",
|
||||
"processInspector": "Process Inspector",
|
||||
"logViewer": "Log Viewer",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Packages",
|
||||
"ssl": "SSL Certificates",
|
||||
"firewall": "Firewall",
|
||||
"users": "Users & Permissions",
|
||||
"healthCheck": "Health Checks",
|
||||
"diskBreakdown": "Disk Breakdown",
|
||||
"systemdTimers": "Timers",
|
||||
"topMemory": "Top by Memory",
|
||||
"noData": "No data",
|
||||
"sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.",
|
||||
"filter": "Filter...",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"actionDone": "{{name}} updated",
|
||||
"actionFailed": "Action failed",
|
||||
"signalSent": "Signal sent to PID {{pid}}",
|
||||
"killHint": "Click: terminate (SIGTERM). Right-click: force kill (SIGKILL).",
|
||||
"working": "Working...",
|
||||
"update": "Update",
|
||||
"upgradeAll": "Upgrade all",
|
||||
"allUpToDate": "Everything is up to date",
|
||||
"save": "Save",
|
||||
"command": "Command",
|
||||
"enabled": "Enabled",
|
||||
"cronSaved": "Crontab updated",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Dry run",
|
||||
"renew": "Renew",
|
||||
"noAcmeClient": "No ACME client (certbot or acme.sh) found on this host.",
|
||||
"addInputRule": "Add INPUT rule",
|
||||
"firewallWarning": "Changes are runtime-only until persisted. Be careful not to lock yourself out.",
|
||||
"ruleApplied": "Rule applied",
|
||||
"invalidPort": "Enter a valid port (1-65535)",
|
||||
"newUsername": "New username",
|
||||
"addUser": "Add",
|
||||
"deleteUser": "Delete user",
|
||||
"noHealthChecks": "No health checks configured yet.",
|
||||
"follow": "Follow",
|
||||
"noLogData": "No log output.",
|
||||
"tree": "Tree",
|
||||
"enableDisable": "Enable / disable at boot",
|
||||
"grantSudo": "Grant sudo",
|
||||
"revokeSudo": "Revoke sudo",
|
||||
"sslIssueCert": "Issue certificate",
|
||||
"sslExpired": "Expired",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Enter at least one domain",
|
||||
"sslIssued": "Certificate issued",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (standalone)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS provider (e.g. cloudflare)",
|
||||
"sslIssueHint": "DNS provider credentials must already be configured on the host.",
|
||||
"sslRevoke": "Revoke certificate",
|
||||
"sslRevoked": "Certificate revoked",
|
||||
"sslRevokeConfirm": "Revoke and remove the certificate \"{{name}}\"? This cannot be undone.",
|
||||
"healthRun": "Run",
|
||||
"healthName": "Name",
|
||||
"healthTarget": "Host / address",
|
||||
"healthAddCheck": "Add check",
|
||||
"healthSaved": "Health checks saved",
|
||||
"healthMissingFields": "Each check needs a name and target",
|
||||
"logFile": "File",
|
||||
"logUnit": "Unit",
|
||||
"logCustomPath": "Custom path under /var/log (optional)",
|
||||
"logGrep": "Filter lines...",
|
||||
"firewallPersist": "Persist rules",
|
||||
"firewallPersisted": "Firewall rules persisted",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard is not installed on this host",
|
||||
"wgNoInterfaces": "No WireGuard interfaces configured",
|
||||
"wgInterfaceUp": "Bring up",
|
||||
"wgInterfaceDown": "Bring down",
|
||||
"wgBringingUp": "Bringing {{name}} up...",
|
||||
"wgBringingDown": "Bringing {{name}} down...",
|
||||
"wgInterfaceUpDone": "{{name}} is up",
|
||||
"wgInterfaceDownDone": "{{name}} is down",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Public key",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "Allowed IPs",
|
||||
"wgLastHandshake": "Last handshake",
|
||||
"wgHandshakeNever": "Never",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrics History",
|
||||
"historySuffix": "History",
|
||||
"cpuMemoryDisk": "CPU / Memory / Disk",
|
||||
"network": "Network",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "No history data available for this time range.",
|
||||
"custom": "Custom",
|
||||
"to": "to",
|
||||
"apply": "Apply",
|
||||
"viewHistory": "View History"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Host Metrics",
|
||||
"metricsCollectionLabel": "Metrics Collection",
|
||||
"enableMetricsLabel": "Enable Metrics",
|
||||
"enableMetricsDesc": "Collect CPU, RAM, disk, and other metrics from this host",
|
||||
"useGlobalMetrics": "Use Global Interval",
|
||||
"useGlobalMetricsDesc": "Override with the server-wide metrics interval",
|
||||
"metricsIntervalS": "Metrics Interval (s)",
|
||||
"metricsIntervalDesc2": "Seconds between metric snapshots",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Label (optional)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Add",
|
||||
"visibleWidgets": "Visible Widgets",
|
||||
"widgetsMovedToHostMetrics": "Cards are now added, arranged, and resized directly in the Host Metrics tab. Open Host Metrics for this host and use Customize to choose which cards are shown.",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrics History Retention",
|
||||
"metricsHistoryRetentionRange": "1 to 90 days",
|
||||
"days": "days"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "No data available",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range",
|
||||
"host": "Host",
|
||||
"loading": "Loading...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Hostname",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memory",
|
||||
"metricNetwork": "Network",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processes",
|
||||
"metricProcessesRunning": "running",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Uptime",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"noHostSelected": "No host selected",
|
||||
"selectHost": "Select a host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connected"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Metriche host"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memoria",
|
||||
"disk": "Disco",
|
||||
"network": "Rete",
|
||||
"uptime": "Tempo di attività",
|
||||
"processes": "Processi",
|
||||
"available": "Disponibile",
|
||||
"free": "Libero",
|
||||
"connecting": "Connessione in corso...",
|
||||
"connectionFailed": "Connessione al server non riuscita",
|
||||
"naCpus": "CPU N/D",
|
||||
"cpuCores_one": "{{count}} Core",
|
||||
"cpuCores_other": "{{count}} Core",
|
||||
"cpuUsage": "Utilizzo CPU",
|
||||
"memoryUsage": "Utilizzo memoria",
|
||||
"diskUsage": "Utilizzo disco",
|
||||
"selectFilesystem": "Seleziona il filesystem",
|
||||
"temperature": "Temperatura",
|
||||
"highestTemperature": "Temperatura massima",
|
||||
"primaryTemperatureSensor": "Sensore primario",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "Nessuna GPU NVIDIA rilevata. Installa il driver NVIDIA (che fornisce nvidia-smi) per visualizzare le metriche della GPU.",
|
||||
"driver": "Autista {{version}}",
|
||||
"utilization": "Utilizzo",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Energia",
|
||||
"fan": "Fan",
|
||||
"processes": "Elaborazione GPU",
|
||||
"noProcesses": "Nessun processo sta utilizzando la GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Recupero configurazione host non riuscito",
|
||||
"serverOffline": "Server offline",
|
||||
"cannotFetchMetrics": "Impossibile recuperare le metriche dal server offline",
|
||||
"totpFailed": "Verifica TOTP non riuscita",
|
||||
"noneAuthNotSupported": "Host Metrics non supporta il tipo di autenticazione 'none'.",
|
||||
"noHostSelected": "Nessun host selezionato",
|
||||
"load": "Carico",
|
||||
"systemInfo": "Informazioni di sistema",
|
||||
"hostname": "Nome host",
|
||||
"operatingSystem": "Sistema operativo",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "secondi",
|
||||
"networkInterfaces": "Interfacce di rete",
|
||||
"noInterfacesFound": "Nessuna interfaccia di rete trovata",
|
||||
"noProcessesFound": "Nessun processo trovato",
|
||||
"processesTotal": "totale",
|
||||
"processesRunning": "in esecuzione",
|
||||
"loginStats": "Statistiche di accesso SSH",
|
||||
"noRecentLoginData": "Nessun dato di accesso recente",
|
||||
"ports": {
|
||||
"title": "Porte in ascolto",
|
||||
"protocol": "Protocollo",
|
||||
"port": "Porta",
|
||||
"address": "Indirizzo",
|
||||
"process": "Processo",
|
||||
"search": "Cerca porte...",
|
||||
"allProtocols": "Tutti",
|
||||
"noData": "Nessun dato sulle porte in ascolto"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inattivo",
|
||||
"policy": "Politica",
|
||||
"rules": "regole",
|
||||
"noRules": "Nessuna regola in questa catena",
|
||||
"noData": "Nessun dato sul firewall disponibile",
|
||||
"action": "Azione",
|
||||
"protocol": "Proto",
|
||||
"port": "Porta",
|
||||
"source": "Sorgente",
|
||||
"anywhere": "Ovunque",
|
||||
"chains": "Catene"
|
||||
},
|
||||
"loadAvg": "Carico medio",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architettura",
|
||||
"refresh": "Aggiorna",
|
||||
"retry": "Riprova",
|
||||
"customize": "Personalizza layout",
|
||||
"reset": "Reimposta",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Trascina le schede per riordinarle, trascina il bordo inferiore per ridimensionarle e usa il pulsante della larghezza per modificare l'estensione della scheda. Aggiungi o rimuovi schede qui sotto.",
|
||||
"managers": {
|
||||
"services": "Servizi",
|
||||
"processInspector": "Ispettore dei processi",
|
||||
"logViewer": "Visualizzatore di log",
|
||||
"cron": "Cron",
|
||||
"packages": "Pacchetti",
|
||||
"ssl": "Certificati SSL",
|
||||
"firewall": "Firewall",
|
||||
"users": "Utenti e permessi",
|
||||
"healthCheck": "Controlli di integrità",
|
||||
"diskBreakdown": "Analisi del disco",
|
||||
"systemdTimers": "Timer",
|
||||
"topMemory": "Principali per memoria",
|
||||
"noData": "Nessun dato",
|
||||
"sudoHint": "Imposta una password sudo per questo host nell'editor host per abilitare azioni privilegiate.",
|
||||
"filter": "Filtra...",
|
||||
"start": "Avvia",
|
||||
"stop": "Ferma",
|
||||
"restart": "Riavvia",
|
||||
"actionDone": "{{name}} aggiornato",
|
||||
"actionFailed": "Azione non riuscita",
|
||||
"signalSent": "Segnale inviato al PID {{pid}}",
|
||||
"killHint": "Clic: termina (SIGTERM). Clic destro: terminazione forzata (SIGKILL).",
|
||||
"working": "In corso...",
|
||||
"update": "Aggiorna",
|
||||
"upgradeAll": "Aggiorna tutto",
|
||||
"allUpToDate": "Tutto aggiornato",
|
||||
"save": "Salva",
|
||||
"command": "Comando",
|
||||
"enabled": "Abilitato",
|
||||
"cronSaved": "Crontab aggiornato",
|
||||
"clients": "Client",
|
||||
"dryRun": "Prova",
|
||||
"renew": "Rinnova",
|
||||
"noAcmeClient": "Nessun client ACME (certbot o acme.sh) trovato su questo host.",
|
||||
"addInputRule": "Aggiungi regola INPUT",
|
||||
"firewallWarning": "Le modifiche sono solo runtime finché non vengono rese permanenti. Fai attenzione a non rimanere bloccato fuori.",
|
||||
"ruleApplied": "Regola applicata",
|
||||
"invalidPort": "Inserisci una porta valida (1-65535)",
|
||||
"newUsername": "Nuovo nome utente",
|
||||
"addUser": "Aggiungi",
|
||||
"deleteUser": "Elimina utente",
|
||||
"noHealthChecks": "Nessun health check configurato al momento.",
|
||||
"follow": "Segui",
|
||||
"noLogData": "Nessun output di log.",
|
||||
"tree": "Albero",
|
||||
"enableDisable": "Abilita / disabilita all'avvio",
|
||||
"grantSudo": "Concedi sudo",
|
||||
"revokeSudo": "Revoca sudo",
|
||||
"sslIssueCert": "Emetti certificato",
|
||||
"sslExpired": "Scaduto",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Inserisci almeno un dominio",
|
||||
"sslIssued": "Certificato emesso",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (standalone)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "Provider DNS (es. cloudflare)",
|
||||
"sslIssueHint": "Le credenziali del provider DNS devono essere già configurate sull'host.",
|
||||
"sslRevoke": "Revoca certificato",
|
||||
"sslRevoked": "Certificato revocato",
|
||||
"sslRevokeConfirm": "Revocare e rimuovere il certificato \"{{name}}\"? Questa azione non può essere annullata.",
|
||||
"healthRun": "Esegui",
|
||||
"healthName": "Nome",
|
||||
"healthTarget": "Host / indirizzo",
|
||||
"healthAddCheck": "Aggiungi controllo",
|
||||
"healthSaved": "Controlli di integrità salvati",
|
||||
"healthMissingFields": "Ogni controllo richiede un nome e un obiettivo",
|
||||
"logFile": "File",
|
||||
"logUnit": "Unità",
|
||||
"logCustomPath": "Percorso personalizzato in /var/log (opzionale)",
|
||||
"logGrep": "Filtra righe...",
|
||||
"firewallPersist": "Rendi permanenti le regole",
|
||||
"firewallPersisted": "Regole del firewall rese permanenti",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard non è installato su questo host",
|
||||
"wgNoInterfaces": "Nessuna interfaccia WireGuard configurata",
|
||||
"wgInterfaceUp": "Attiva",
|
||||
"wgInterfaceDown": "Disattiva",
|
||||
"wgBringingUp": "Attivazione di {{name}} in corso...",
|
||||
"wgBringingDown": "Disattivazione di {{name}} in corso...",
|
||||
"wgInterfaceUpDone": "{{name}} è attivo",
|
||||
"wgInterfaceDownDone": "{{name}} è disattivo",
|
||||
"wgListenPort": "Porta",
|
||||
"wgPublicKey": "Chiave pubblica",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "IP consentiti",
|
||||
"wgLastHandshake": "Ultimo handshake",
|
||||
"wgHandshakeNever": "Mai",
|
||||
"wgTransfer": "Trasferimento",
|
||||
"wgNoPeers": "Senza eguali"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Cronologia metriche",
|
||||
"historySuffix": "Cronologia",
|
||||
"cpuMemoryDisk": "CPU / Memoria / Disco",
|
||||
"network": "Rete",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Nessun dato storico disponibile per questo intervallo di tempo.",
|
||||
"custom": "Personalizzato",
|
||||
"to": "a",
|
||||
"apply": "Applica",
|
||||
"viewHistory": "Visualizza cronologia"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Metriche host",
|
||||
"metricsCollectionLabel": "Raccolta metriche",
|
||||
"enableMetricsLabel": "Abilita metriche",
|
||||
"enableMetricsDesc": "Raccogli CPU, RAM, disco e altre metriche da questo host",
|
||||
"useGlobalMetrics": "Usa intervallo globale",
|
||||
"useGlobalMetricsDesc": "Sovrascrivi con l'intervallo delle metriche del server",
|
||||
"metricsIntervalS": "Intervallo metriche (s)",
|
||||
"metricsIntervalDesc2": "Secondi tra uno snapshot delle metriche e l'altro",
|
||||
"excludedMountsLabel": "Montature escluse",
|
||||
"monitoredMountsLabel": "Percorsi monitorati personalizzati",
|
||||
"monitoredMountsDesc": "Aggiungi i percorsi che non sono esposti come mount separati, inclusi i percorsi di Home Assistant montati tramite bind, come /config.",
|
||||
"monitoredMountPathPlaceholder": "Percorso (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Etichetta (facoltativa)",
|
||||
"addMonitoredMount": "Aggiungi percorso",
|
||||
"excludedMountsDesc": "Nascondi specifici punti di montaggio o tipi di filesystem (ad esempio nfs, cifs) dall'utilizzo del disco e dagli avvisi per questo host.",
|
||||
"excludedMountsPlaceholder": "Percorso di montaggio (/mnt/nas) o tipo di filesystem (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Nessun mount escluso. Vengono mostrati tutti i filesystem rilevati.",
|
||||
"addExcludedMount": "Aggiungi",
|
||||
"visibleWidgets": "Widget visibili",
|
||||
"widgetsMovedToHostMetrics": "Le schede ora vengono aggiunte, disposte e ridimensionate direttamente nella scheda Metriche host. Apri Metriche host per questo host e usa Personalizza per scegliere quali schede mostrare.",
|
||||
"hostNotFound": "Host non trovato",
|
||||
"loadingHost": "Caricamento host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metriche e avvisi",
|
||||
"feature_metrics_desc": "Monitora l'utilizzo di CPU, memoria e disco nel tempo e ricevi avvisi in caso di malfunzionamenti."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Conservazione cronologia metriche",
|
||||
"metricsHistoryRetentionRange": "Da 1 a 90 giorni",
|
||||
"days": "giorni"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Nessun dato disponibile",
|
||||
"showCurrentValue": "Mostra valore corrente",
|
||||
"chartMetric": "Metrica",
|
||||
"metricRange": "Intervallo",
|
||||
"host": "Host",
|
||||
"loading": "Caricamento...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disco",
|
||||
"metricHostname": "Nome host",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memoria",
|
||||
"metricNetwork": "Rete",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processi",
|
||||
"metricProcessesRunning": "in esecuzione",
|
||||
"metricProcessesTotal": "totale",
|
||||
"metricUptime": "Uptime",
|
||||
"metricsNotAvailable": "Metriche non disponibili",
|
||||
"noHostSelected": "Nessun host selezionato",
|
||||
"selectHost": "Seleziona un host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Statistiche in tempo reale non disponibili"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disco"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connesso"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Utilizzare le metriche dell'host",
|
||||
"description": "Visualizza le metriche dell'host e utilizza i gestori host (servizi, pacchetti, firewall e altro) sugli host raggiungibili."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Intervallo delle metriche (secondi)",
|
||||
"description": "Con quale frequenza viene campionato un host visualizzato, a meno che l'host non imposti un intervallo personalizzato."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Conservazione della cronologia (giorni)",
|
||||
"description": "Per quanto tempo vengono conservati i campioni delle metriche per i grafici storici."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metriche attivate per i nuovi host",
|
||||
"description": "Se i nuovi host iniziano con la raccolta di metriche attivata."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Unità di temperatura",
|
||||
"description": "Come vengono visualizzate le temperature nelle metriche dell'host.",
|
||||
"celsius": "Centigrado",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Raccogliere metriche"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Intervallo delle metriche (secondi)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Widget abilitati"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Montature escluse"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Percorsi monitorati"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "ホストメトリクス"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "メモリ",
|
||||
"disk": "ディスク",
|
||||
"network": "ネットワーク",
|
||||
"uptime": "稼働時間",
|
||||
"processes": "プロセス",
|
||||
"available": "利用可能",
|
||||
"free": "空き",
|
||||
"connecting": "接続中...",
|
||||
"connectionFailed": "サーバーへの接続に失敗しました",
|
||||
"naCpus": "N/A CPU",
|
||||
"cpuCores_one": "{{count}} コア",
|
||||
"cpuCores_other": "{{count}} コア",
|
||||
"cpuUsage": "CPU使用率",
|
||||
"memoryUsage": "メモリ使用率",
|
||||
"diskUsage": "ディスク使用率",
|
||||
"selectFilesystem": "ファイルシステムを選択",
|
||||
"temperature": "温度",
|
||||
"highestTemperature": "最高温度",
|
||||
"primaryTemperatureSensor": "プライマリーセンサー",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "NVIDIA GPUが検出されませんでした。GPUのメトリックを表示するには、NVIDIAドライバーをインストールしてください(ドライバーにはnvidia-smiが含まれています)。",
|
||||
"driver": "ドライバー {{version}}",
|
||||
"utilization": "利用",
|
||||
"memory": "VRAM",
|
||||
"temperature": "温度",
|
||||
"power": "力",
|
||||
"fan": "ファン",
|
||||
"processes": "GPUプロセス",
|
||||
"noProcesses": "GPUを使用しているプロセスはありません"
|
||||
},
|
||||
"failedToFetchHostConfig": "ホスト設定の取得に失敗しました",
|
||||
"serverOffline": "サーバーオフライン",
|
||||
"cannotFetchMetrics": "オフラインのサーバーからメトリクスを取得できません。",
|
||||
"totpFailed": "TOTP検証に失敗しました",
|
||||
"noneAuthNotSupported": "ホストメトリクスは「none」認証タイプをサポートしていません。",
|
||||
"noHostSelected": "ホストが選択されていません",
|
||||
"load": "負荷",
|
||||
"systemInfo": "システム情報",
|
||||
"hostname": "ホスト名",
|
||||
"operatingSystem": "オペレーティングシステム",
|
||||
"kernel": "カーネル",
|
||||
"seconds": "秒",
|
||||
"networkInterfaces": "ネットワークインターフェース",
|
||||
"noInterfacesFound": "ネットワークインターフェースが見つかりません",
|
||||
"noProcessesFound": "プロセスが見つかりません",
|
||||
"processesTotal": "合計",
|
||||
"processesRunning": "実行中",
|
||||
"loginStats": "SSHログイン統計",
|
||||
"noRecentLoginData": "最近のログインデータがありません",
|
||||
"ports": {
|
||||
"title": "リスニングポート",
|
||||
"protocol": "プロトコル",
|
||||
"port": "ポート",
|
||||
"address": "アドレス",
|
||||
"process": "プロセス",
|
||||
"search": "ポートを検索...",
|
||||
"allProtocols": "すべて",
|
||||
"noData": "リスニングポートのデータがありません"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "ファイアウォール",
|
||||
"inactive": "非アクティブ",
|
||||
"policy": "ポリシー",
|
||||
"rules": "ルール",
|
||||
"noRules": "このチェーンにルールはありません",
|
||||
"noData": "利用可能なファイアウォールデータがありません",
|
||||
"action": "アクション",
|
||||
"protocol": "プロトコル",
|
||||
"port": "ポート",
|
||||
"source": "ソース",
|
||||
"anywhere": "任意",
|
||||
"chains": "チェーン"
|
||||
},
|
||||
"loadAvg": "ロードアベレージ",
|
||||
"swap": "スワップ",
|
||||
"architecture": "アーキテクチャ",
|
||||
"refresh": "更新",
|
||||
"retry": "再試行",
|
||||
"customize": "レイアウトをカスタマイズ",
|
||||
"reset": "リセット",
|
||||
"tabLive": "ライブ",
|
||||
"editModeInstructions": "カードをドラッグして並べ替え、下辺をドラッグしてサイズを変更し、幅ボタンでカードのスパンを変更します。下のボタンでカードを追加または削除してください。",
|
||||
"managers": {
|
||||
"services": "サービス",
|
||||
"processInspector": "プロセスインスペクタ",
|
||||
"logViewer": "ログビューア",
|
||||
"cron": "Cronジョブ",
|
||||
"packages": "パッケージ",
|
||||
"ssl": "SSL証明書",
|
||||
"firewall": "ファイアウォール",
|
||||
"users": "ユーザーと権限",
|
||||
"healthCheck": "ヘルスチェック",
|
||||
"diskBreakdown": "ディスク内訳",
|
||||
"systemdTimers": "タイマー",
|
||||
"topMemory": "メモリ使用量トップ",
|
||||
"noData": "データなし",
|
||||
"sudoHint": "特権アクションを有効にするには、ホストエディタでこのホストのsudoパスワードを設定してください。",
|
||||
"filter": "フィルター...",
|
||||
"start": "起動",
|
||||
"stop": "停止",
|
||||
"restart": "再起動",
|
||||
"actionDone": "{{name}} が更新されました",
|
||||
"actionFailed": "アクションに失敗しました",
|
||||
"signalSent": "PID {{pid}} にシグナルが送信されました",
|
||||
"killHint": "クリック: 終了 (SIGTERM)。右クリック: 強制終了 (SIGKILL)。",
|
||||
"working": "処理中...",
|
||||
"update": "更新",
|
||||
"upgradeAll": "すべてアップグレード",
|
||||
"allUpToDate": "すべて最新です",
|
||||
"save": "保存",
|
||||
"command": "コマンド",
|
||||
"enabled": "有効",
|
||||
"cronSaved": "crontab を更新しました",
|
||||
"clients": "クライアント",
|
||||
"dryRun": "ドライラン",
|
||||
"renew": "更新",
|
||||
"noAcmeClient": "このホストに ACME クライアント (certbot または acme.sh) が見つかりません。",
|
||||
"addInputRule": "INPUT ルールを追加",
|
||||
"firewallWarning": "変更は永続化されるまで実行中のみ有効です。自分をロックアウトしないよう注意してください。",
|
||||
"ruleApplied": "ルールを適用しました",
|
||||
"invalidPort": "有効なポート番号 (1-65535) を入力してください",
|
||||
"newUsername": "新しいユーザー名",
|
||||
"addUser": "追加",
|
||||
"deleteUser": "ユーザーを削除",
|
||||
"noHealthChecks": "ヘルスチェックはまだ設定されていません。",
|
||||
"follow": "フォロー",
|
||||
"noLogData": "ログ出力がありません。",
|
||||
"tree": "ツリー",
|
||||
"enableDisable": "起動時に有効/無効",
|
||||
"grantSudo": "sudo 権限を付与",
|
||||
"revokeSudo": "sudo 権限を剥奪",
|
||||
"sslIssueCert": "証明書を発行",
|
||||
"sslExpired": "期限切れ",
|
||||
"sslInDays": "あと {{days}} 日",
|
||||
"sslNeedDomain": "少なくとも1つのドメインを入力してください",
|
||||
"sslIssued": "証明書が発行されました",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (スタンドアロン)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNSプロバイダ(例: cloudflare)",
|
||||
"sslIssueHint": "DNSプロバイダの認証情報はホストに設定済みである必要があります。",
|
||||
"sslRevoke": "証明書を失効",
|
||||
"sslRevoked": "証明書が失効しました",
|
||||
"sslRevokeConfirm": "証明書 \"{{name}}\"を失効して削除しますか?この操作は取り消せません。",
|
||||
"healthRun": "実行",
|
||||
"healthName": "名前",
|
||||
"healthTarget": "ホスト / アドレス",
|
||||
"healthAddCheck": "チェックを追加",
|
||||
"healthSaved": "ヘルスチェックが保存されました",
|
||||
"healthMissingFields": "各チェックには名前とターゲットが必要です",
|
||||
"logFile": "ファイル",
|
||||
"logUnit": "ユニット",
|
||||
"logCustomPath": "/var/log 以下のカスタムパス (任意)",
|
||||
"logGrep": "行をフィルタ...",
|
||||
"firewallPersist": "ルールを永続化",
|
||||
"firewallPersisted": "ファイアウォールルールが永続化されました",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "このホストにWireGuardがインストールされていません",
|
||||
"wgNoInterfaces": "WireGuardインターフェースが設定されていません",
|
||||
"wgInterfaceUp": "起動",
|
||||
"wgInterfaceDown": "停止",
|
||||
"wgBringingUp": "{{name}}を起動中...",
|
||||
"wgBringingDown": "{{name}}を停止中...",
|
||||
"wgInterfaceUpDone": "{{name}}が起動しました",
|
||||
"wgInterfaceDownDone": "{{name}}が停止しました",
|
||||
"wgListenPort": "ポート",
|
||||
"wgPublicKey": "公開鍵",
|
||||
"wgEndpoint": "エンドポイント",
|
||||
"wgAllowedIPs": "許可されたIP",
|
||||
"wgLastHandshake": "最終ハンドシェイク",
|
||||
"wgHandshakeNever": "なし",
|
||||
"wgTransfer": "転送量",
|
||||
"wgNoPeers": "同業他社なし"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "メトリクス履歴",
|
||||
"historySuffix": "履歴",
|
||||
"cpuMemoryDisk": "CPU / メモリ / ディスク",
|
||||
"network": "ネットワーク",
|
||||
"download": "受信",
|
||||
"upload": "送信",
|
||||
"noData": "この期間の履歴データはありません。",
|
||||
"custom": "カスタム",
|
||||
"to": "~",
|
||||
"apply": "適用",
|
||||
"viewHistory": "履歴を表示"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "ホストメトリクス",
|
||||
"metricsCollectionLabel": "メトリクス収集",
|
||||
"enableMetricsLabel": "メトリクスを有効にする",
|
||||
"enableMetricsDesc": "このホストから CPU、RAM、ディスクなどのメトリクスを収集します",
|
||||
"useGlobalMetrics": "グローバル間隔を使用する",
|
||||
"useGlobalMetricsDesc": "サーバー全体のメトリクス収集間隔で上書きします",
|
||||
"metricsIntervalS": "メトリクス間隔 (秒)",
|
||||
"metricsIntervalDesc2": "メトリクススナップショットの間隔 (秒)",
|
||||
"excludedMountsLabel": "除外されるマウント",
|
||||
"monitoredMountsLabel": "カスタム監視パス",
|
||||
"monitoredMountsDesc": "/config などのバインドマウントされた Home Assistant パスを含め、個別のマウントとして公開されていないパスを追加します。",
|
||||
"monitoredMountPathPlaceholder": "パス(/config、/mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "ラベル(任意)",
|
||||
"addMonitoredMount": "パスを追加",
|
||||
"excludedMountsDesc": "このホストのディスク使用量とアラートから、特定のマウントポイントまたはファイルシステムの種類(例:NFS、CIFS)を非表示にします。",
|
||||
"excludedMountsPlaceholder": "マウントパス(/mnt/nas)またはファイルシステムの種類(nfs、cifs)",
|
||||
"excludedMountsEmpty": "マウントポイントは除外されません。検出されたすべてのファイルシステムが表示されます。",
|
||||
"addExcludedMount": "追加",
|
||||
"visibleWidgets": "表示するウィジェット",
|
||||
"widgetsMovedToHostMetrics": "カードは、ホストメトリクスタブで直接追加、配置、サイズ変更できるようになりました。このホストのホストメトリクスを開き、カスタマイズを使用して表示するカードを選択してください。",
|
||||
"hostNotFound": "ホストが見つかりません",
|
||||
"loadingHost": "ホストを読み込み中..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "指標とアラート",
|
||||
"feature_metrics_desc": "CPU、メモリ、ディスクの使用状況を長期的に監視し、異常が発生した際にアラートを受け取ることができます。"
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "メトリクス履歴保持期間",
|
||||
"metricsHistoryRetentionRange": "1〜90日",
|
||||
"days": "日"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "利用可能なデータはありません",
|
||||
"showCurrentValue": "現在の値を表示",
|
||||
"chartMetric": "メトリック",
|
||||
"metricRange": "範囲",
|
||||
"host": "ホスト",
|
||||
"loading": "読み込み中...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "ディスク",
|
||||
"metricHostname": "ホスト名",
|
||||
"metricKernel": "カーネル",
|
||||
"metricMemory": "メモリ",
|
||||
"metricNetwork": "ネットワーク",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "プロセス",
|
||||
"metricProcessesRunning": "実行中",
|
||||
"metricProcessesTotal": "合計",
|
||||
"metricUptime": "稼働時間",
|
||||
"metricsNotAvailable": "メトリクスは利用できません",
|
||||
"noHostSelected": "ホストが選択されていません",
|
||||
"selectHost": "ホストを選択..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "ライブ統計は利用できません"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "ディスク"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "接続中"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "ホストメトリクスを使用する",
|
||||
"description": "アクセス可能なホストに対して、ホストのメトリクスを表示したり、ホストマネージャ(サービス、パッケージ、ファイアウォールなど)を使用したりできます。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "測定間隔(秒)",
|
||||
"description": "ホストが独自の間隔を設定しない限り、閲覧対象のホストがサンプリングされる頻度。"
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "履歴保持期間(日数)",
|
||||
"description": "履歴チャートのメトリクスサンプルはどのくらいの期間保持されますか。"
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "新規ホスト向けのメトリクスが有効になっています",
|
||||
"description": "新しいホストがメトリクス収集を有効にした状態で起動するかどうか。"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "温度単位",
|
||||
"description": "ホストメトリクスにおける温度の表示方法。",
|
||||
"celsius": "摂氏",
|
||||
"fahrenheit": "華氏"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "指標を収集する"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "測定間隔(秒)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "有効なウィジェット"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "除外されたマウント"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "監視対象経路"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "호스트 메트릭"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "메모리",
|
||||
"disk": "디스크",
|
||||
"network": "네트워크",
|
||||
"uptime": "업타임",
|
||||
"processes": "프로세스",
|
||||
"available": "사용 가능",
|
||||
"free": "여유",
|
||||
"connecting": "연결 중...",
|
||||
"connectionFailed": "서버 연결 실패",
|
||||
"naCpus": "N/A CPU",
|
||||
"cpuCores_one": "{{count}} 코어",
|
||||
"cpuCores_other": "{{count}} 코어",
|
||||
"cpuUsage": "CPU 사용률",
|
||||
"memoryUsage": "메모리 사용률",
|
||||
"diskUsage": "디스크 사용률",
|
||||
"selectFilesystem": "파일 시스템을 선택하세요",
|
||||
"temperature": "온도",
|
||||
"highestTemperature": "최고 온도",
|
||||
"primaryTemperatureSensor": "1차 센서",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "NVIDIA GPU가 감지되지 않았습니다. NVIDIA 드라이버를 설치하세요(드라이버에는 nvidia-smi가 포함되어 있습니다). 그러면 GPU 메트릭을 확인할 수 있습니다.",
|
||||
"driver": "드라이버 {{version}}",
|
||||
"utilization": "이용",
|
||||
"memory": "VRAM",
|
||||
"temperature": "임시",
|
||||
"power": "힘",
|
||||
"fan": "팬",
|
||||
"processes": "GPU 프로세스",
|
||||
"noProcesses": "GPU를 사용하는 프로세스가 없습니다."
|
||||
},
|
||||
"failedToFetchHostConfig": "호스트 구성 가져오기 실패",
|
||||
"serverOffline": "서버 오프라인",
|
||||
"cannotFetchMetrics": "오프라인 서버에서 메트릭 가져오기 불가",
|
||||
"totpFailed": "TOTP 인증 실패",
|
||||
"noneAuthNotSupported": "호스트 메트릭에서 'none' 인증 유형을 지원하지 않습니다.",
|
||||
"noHostSelected": "호스트가 선택되지 않았습니다.",
|
||||
"load": "부하",
|
||||
"systemInfo": "시스템 정보",
|
||||
"hostname": "호스트 이름",
|
||||
"operatingSystem": "운영 체제",
|
||||
"kernel": "커널",
|
||||
"seconds": "초",
|
||||
"networkInterfaces": "네트워크 인터페이스",
|
||||
"noInterfacesFound": "네트워크 인터페이스를 찾을 수 없습니다.",
|
||||
"noProcessesFound": "프로세스를 찾을 수 없습니다.",
|
||||
"processesTotal": "전체",
|
||||
"processesRunning": "실행 중",
|
||||
"loginStats": "SSH 로그인 통계",
|
||||
"noRecentLoginData": "최근 로그인 데이터가 없습니다.",
|
||||
"ports": {
|
||||
"title": "수신 대기 포트",
|
||||
"protocol": "프로토콜",
|
||||
"port": "포트",
|
||||
"address": "주소",
|
||||
"process": "프로세스",
|
||||
"search": "포트 검색...",
|
||||
"allProtocols": "전체",
|
||||
"noData": "수신 대기 포트 데이터가 없습니다."
|
||||
},
|
||||
"firewall": {
|
||||
"title": "방화벽",
|
||||
"inactive": "비활성",
|
||||
"policy": "정책",
|
||||
"rules": "규칙",
|
||||
"noRules": "이 체인에 규칙이 없습니다.",
|
||||
"noData": "방화벽 데이터를 사용할 수 없습니다.",
|
||||
"action": "동작",
|
||||
"protocol": "프로토콜",
|
||||
"port": "포트",
|
||||
"source": "소스",
|
||||
"anywhere": "모든 곳",
|
||||
"chains": "체인"
|
||||
},
|
||||
"loadAvg": "로드 평균",
|
||||
"swap": "스왑",
|
||||
"architecture": "아키텍처",
|
||||
"refresh": "새로 고침",
|
||||
"retry": "재시도",
|
||||
"customize": "레이아웃 사용자 정의",
|
||||
"reset": "초기화",
|
||||
"tabLive": "실시간",
|
||||
"editModeInstructions": "카드를 드래그하여 재배열하고, 아래쪽 가장자리를 드래그하여 크기를 조정하며, 너비 버튼을 사용해 카드의 가로 폭을 변경하세요. 아래에서 카드를 추가하거나 제거할 수 있습니다.",
|
||||
"managers": {
|
||||
"services": "서비스",
|
||||
"processInspector": "프로세스 검사기",
|
||||
"logViewer": "로그 뷰어",
|
||||
"cron": "크론 작업",
|
||||
"packages": "패키지",
|
||||
"ssl": "SSL 인증서",
|
||||
"firewall": "방화벽",
|
||||
"users": "사용자 및 권한",
|
||||
"healthCheck": "상태 검사",
|
||||
"diskBreakdown": "디스크 분석",
|
||||
"systemdTimers": "타이머",
|
||||
"topMemory": "메모리 사용량 상위",
|
||||
"noData": "데이터 없음",
|
||||
"sudoHint": "호스트 편집기에서 이 호스트의 sudo 암호를 설정하여 권한이 필요한 작업을 활성화하세요.",
|
||||
"filter": "필터...",
|
||||
"start": "시작",
|
||||
"stop": "중지",
|
||||
"restart": "재시작",
|
||||
"actionDone": "{{name}} 업데이트됨",
|
||||
"actionFailed": "작업 실패",
|
||||
"signalSent": "PID {{pid}}에 시그널 전송됨",
|
||||
"killHint": "클릭: 종료 (SIGTERM). 마우스 오른쪽 클릭: 강제 종료 (SIGKILL).",
|
||||
"working": "작업 중...",
|
||||
"update": "업데이트",
|
||||
"upgradeAll": "모두 업그레이드",
|
||||
"allUpToDate": "모든 항목이 최신 상태입니다",
|
||||
"save": "저장",
|
||||
"command": "명령",
|
||||
"enabled": "활성화됨",
|
||||
"cronSaved": "Crontab이 업데이트되었습니다",
|
||||
"clients": "클라이언트",
|
||||
"dryRun": "테스트 실행",
|
||||
"renew": "갱신",
|
||||
"noAcmeClient": "이 호스트에 ACME 클라이언트(certbot 또는 acme.sh)가 없습니다.",
|
||||
"addInputRule": "INPUT 규칙 추가",
|
||||
"firewallWarning": "변경 사항은 저장하기 전까지 런타임에만 적용됩니다. 접근이 차단되지 않도록 주의하세요.",
|
||||
"ruleApplied": "규칙이 적용되었습니다",
|
||||
"invalidPort": "유효한 포트(1-65535)를 입력하세요",
|
||||
"newUsername": "새 사용자 이름",
|
||||
"addUser": "추가",
|
||||
"deleteUser": "사용자 삭제",
|
||||
"noHealthChecks": "아직 상태 점검이 구성되지 않았습니다.",
|
||||
"follow": "따라가기",
|
||||
"noLogData": "로그 출력이 없습니다.",
|
||||
"tree": "트리",
|
||||
"enableDisable": "부팅 시 활성화/비활성화",
|
||||
"grantSudo": "sudo 권한 부여",
|
||||
"revokeSudo": "sudo 권한 회수",
|
||||
"sslIssueCert": "인증서 발급",
|
||||
"sslExpired": "만료됨",
|
||||
"sslInDays": "{{days}}일 후",
|
||||
"sslNeedDomain": "최소 하나의 도메인을 입력하세요",
|
||||
"sslIssued": "인증서가 발급되었습니다",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (독립형)",
|
||||
"sslHttpWebroot": "HTTP (웹루트)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS 공급자 (예: cloudflare)",
|
||||
"sslIssueHint": "DNS 공급자 자격 증명이 호스트에 이미 구성되어 있어야 합니다.",
|
||||
"sslRevoke": "인증서 폐기",
|
||||
"sslRevoked": "인증서가 폐기되었습니다",
|
||||
"sslRevokeConfirm": "인증서 \"{{name}}\"을(를) 폐기하고 제거하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"healthRun": "실행",
|
||||
"healthName": "이름",
|
||||
"healthTarget": "호스트 / 주소",
|
||||
"healthAddCheck": "검사 추가",
|
||||
"healthSaved": "상태 검사가 저장되었습니다",
|
||||
"healthMissingFields": "각 검사에는 이름과 대상이 필요합니다",
|
||||
"logFile": "파일",
|
||||
"logUnit": "유닛",
|
||||
"logCustomPath": "/var/log 아래의 사용자 지정 경로 (선택 사항)",
|
||||
"logGrep": "줄 필터...",
|
||||
"firewallPersist": "규칙 영구 저장",
|
||||
"firewallPersisted": "방화벽 규칙이 영구 저장되었습니다",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "이 호스트에 WireGuard가 설치되어 있지 않습니다",
|
||||
"wgNoInterfaces": "구성된 WireGuard 인터페이스가 없습니다",
|
||||
"wgInterfaceUp": "올리기",
|
||||
"wgInterfaceDown": "내리기",
|
||||
"wgBringingUp": "{{name}} 올리는 중...",
|
||||
"wgBringingDown": "{{name}} 내리는 중...",
|
||||
"wgInterfaceUpDone": "{{name}}이(가) 올라왔습니다",
|
||||
"wgInterfaceDownDone": "{{name}}이(가) 내려갔습니다",
|
||||
"wgListenPort": "포트",
|
||||
"wgPublicKey": "공개 키",
|
||||
"wgEndpoint": "엔드포인트",
|
||||
"wgAllowedIPs": "허용된 IP",
|
||||
"wgLastHandshake": "마지막 핸드셰이크",
|
||||
"wgHandshakeNever": "없음",
|
||||
"wgTransfer": "전송",
|
||||
"wgNoPeers": "동료 없음"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "메트릭 기록",
|
||||
"historySuffix": "기록",
|
||||
"cpuMemoryDisk": "CPU / 메모리 / 디스크",
|
||||
"network": "네트워크",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "이 시간 범위에 대한 기록 데이터가 없습니다.",
|
||||
"custom": "사용자 지정",
|
||||
"to": "~",
|
||||
"apply": "적용",
|
||||
"viewHistory": "기록 보기"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "호스트 메트릭",
|
||||
"metricsCollectionLabel": "메트릭 수집",
|
||||
"enableMetricsLabel": "메트릭 활성화",
|
||||
"enableMetricsDesc": "이 호스트에서 CPU, RAM, 디스크 및 기타 메트릭을 수집합니다.",
|
||||
"useGlobalMetrics": "전역 간격 사용",
|
||||
"useGlobalMetricsDesc": "서버 전체 메트릭 간격으로 재정의합니다.",
|
||||
"metricsIntervalS": "메트릭 간격(초)",
|
||||
"metricsIntervalDesc2": "메트릭 스냅샷 간격(초)",
|
||||
"excludedMountsLabel": "제외된 마운트",
|
||||
"monitoredMountsLabel": "사용자 지정 모니터링 경로",
|
||||
"monitoredMountsDesc": "/config와 같이 바인드 마운트된 Home Assistant 경로를 포함하여 별도의 마운트로 노출되지 않는 경로를 추가합니다.",
|
||||
"monitoredMountPathPlaceholder": "경로(/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "레이블 (선택 사항)",
|
||||
"addMonitoredMount": "경로를 추가합니다",
|
||||
"excludedMountsDesc": "이 호스트의 디스크 사용량 및 알림에서 특정 마운트 지점 또는 파일 시스템 유형(예: NFS, CIFS)을 숨깁니다.",
|
||||
"excludedMountsPlaceholder": "마운트 경로(/mnt/nas) 또는 파일 시스템 유형(nfs, cifs)",
|
||||
"excludedMountsEmpty": "마운트된 항목을 제외하지 않습니다. 감지된 모든 파일 시스템이 표시됩니다.",
|
||||
"addExcludedMount": "추가",
|
||||
"visibleWidgets": "표시 위젯",
|
||||
"widgetsMovedToHostMetrics": "카드는 이제 호스트 메트릭 탭에서 직접 추가, 정렬 및 크기 조정됩니다. 이 호스트의 호스트 메트릭을 열고 사용자 정의를 사용하여 표시할 카드를 선택하세요.",
|
||||
"hostNotFound": "호스트를 찾을 수 없습니다",
|
||||
"loadingHost": "호스트 로드 중..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "지표 및 알림",
|
||||
"feature_metrics_desc": "CPU, 메모리 및 디스크 사용량을 시간 경과에 따라 추적하고, 문제가 발생하면 알림을 받으세요."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "메트릭 이력 보존 기간",
|
||||
"metricsHistoryRetentionRange": "1~90일",
|
||||
"days": "일"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "데이터 없음",
|
||||
"showCurrentValue": "현재 값 표시",
|
||||
"chartMetric": "메트릭",
|
||||
"metricRange": "범위",
|
||||
"host": "호스트",
|
||||
"loading": "로딩 중...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "디스크",
|
||||
"metricHostname": "호스트명",
|
||||
"metricKernel": "커널",
|
||||
"metricMemory": "메모리",
|
||||
"metricNetwork": "네트워크",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "프로세스",
|
||||
"metricProcessesRunning": "실행 중",
|
||||
"metricProcessesTotal": "전체",
|
||||
"metricUptime": "업타임",
|
||||
"metricsNotAvailable": "메트릭을 사용할 수 없습니다",
|
||||
"noHostSelected": "선택된 호스트 없음",
|
||||
"selectHost": "호스트 선택..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "실시간 통계를 사용할 수 없습니다."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "디스크"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "연결됨"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "호스트 메트릭 사용",
|
||||
"description": "접속 가능한 호스트에서 호스트 지표를 확인하고 호스트 관리자(서비스, 패키지, 방화벽 등)를 사용하세요."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "측정 간격(초)",
|
||||
"description": "호스트가 자체 간격을 설정하지 않는 한, 보고 있는 호스트를 샘플링하는 빈도입니다."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "기록 보존 기간(일)",
|
||||
"description": "기록 차트를 위해 측정값 샘플을 보관하는 기간은 얼마나 되나요?"
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "새 호스트에 대한 메트릭 활성화",
|
||||
"description": "새 호스트가 메트릭 수집 기능을 켠 상태로 시작하는지 여부."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "온도 단위",
|
||||
"description": "호스트 메트릭스에서 온도가 표시되는 방식입니다.",
|
||||
"celsius": "섭씨",
|
||||
"fahrenheit": "화씨"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "측정 지표 수집"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "측정 간격(초)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "활성화된 위젯"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "제외된 마운트"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "모니터링되는 경로"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Hoststatistieken"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Geheugen",
|
||||
"disk": "Schijf",
|
||||
"network": "Netwerk",
|
||||
"uptime": "Beschikbaarheid",
|
||||
"processes": "Processen",
|
||||
"available": "Beschikbaar",
|
||||
"free": "Vrij",
|
||||
"connecting": "Verbinden...",
|
||||
"connectionFailed": "Verbinding met de server mislukt.",
|
||||
"naCpus": "Niet van toepassing CPU('s)",
|
||||
"cpuCores_one": "{{count}} Kern",
|
||||
"cpuCores_other": "{{count}} Kernen",
|
||||
"cpuUsage": "CPU-gebruik",
|
||||
"memoryUsage": "Geheugengebruik",
|
||||
"diskUsage": "Schijfgebruik",
|
||||
"selectFilesystem": "Selecteer bestandssysteem",
|
||||
"temperature": "Temperatuur",
|
||||
"highestTemperature": "Hoogste temperatuur",
|
||||
"primaryTemperatureSensor": "Primaire sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "Geen NVIDIA GPU gedetecteerd. Installeer het NVIDIA-stuurprogramma (dit levert nvidia-smi) om GPU-statistieken te bekijken.",
|
||||
"driver": "Bestuurder {{version}}",
|
||||
"utilization": "Gebruik",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Stroom",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU-processen",
|
||||
"noProcesses": "Er zijn geen processen die de GPU gebruiken."
|
||||
},
|
||||
"failedToFetchHostConfig": "Het ophalen van de hostconfiguratie is mislukt.",
|
||||
"serverOffline": "Server offline",
|
||||
"cannotFetchMetrics": "Het is niet mogelijk om meetgegevens op te halen van een offline server.",
|
||||
"totpFailed": "TOTP-verificatie mislukt",
|
||||
"noneAuthNotSupported": "Host Metrics ondersteunt geen authenticatietype 'none'.",
|
||||
"noHostSelected": "Geen host geselecteerd",
|
||||
"load": "Laden",
|
||||
"systemInfo": "Systeeminformatie",
|
||||
"hostname": "Hostnaam",
|
||||
"operatingSystem": "Besturingssysteem",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "seconden",
|
||||
"networkInterfaces": "Netwerkinterfaces",
|
||||
"noInterfacesFound": "Geen netwerkinterfaces gevonden",
|
||||
"noProcessesFound": "Geen processen gevonden",
|
||||
"processesTotal": "totaal",
|
||||
"processesRunning": "rennen",
|
||||
"loginStats": "SSH-aanmeldingsstatistieken",
|
||||
"noRecentLoginData": "Geen recente inloggegevens",
|
||||
"ports": {
|
||||
"title": "Luisterpoorten",
|
||||
"protocol": "Protocol",
|
||||
"port": "Haven",
|
||||
"address": "Adres",
|
||||
"process": "Proces",
|
||||
"search": "Poorten doorzoeken...",
|
||||
"allProtocols": "Alle",
|
||||
"noData": "Geen gegevens over luisterpoorten"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inactief",
|
||||
"policy": "Beleid",
|
||||
"rules": "regels",
|
||||
"noRules": "Er zijn geen regels in deze keten.",
|
||||
"noData": "Geen firewallgegevens beschikbaar",
|
||||
"action": "Actie",
|
||||
"protocol": "Proto",
|
||||
"port": "Haven",
|
||||
"source": "Bron",
|
||||
"anywhere": "Overal",
|
||||
"chains": "kettingen"
|
||||
},
|
||||
"loadAvg": "Gemiddelde belasting",
|
||||
"swap": "Ruil",
|
||||
"architecture": "Architectuur",
|
||||
"refresh": "Vernieuwen",
|
||||
"retry": "Opnieuw proberen",
|
||||
"customize": "Lay-out aanpassen",
|
||||
"reset": "Reset",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Sleep kaarten om ze te herschikken, sleep de onderrand om het formaat aan te passen en gebruik de breedteknop om de afmetingen van een kaart te wijzigen. Voeg hieronder kaarten toe of verwijder ze.",
|
||||
"managers": {
|
||||
"services": "Diensten",
|
||||
"processInspector": "Procesinspecteur",
|
||||
"logViewer": "Logboekviewer",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Pakketten",
|
||||
"ssl": "SSL-certificaten",
|
||||
"firewall": "Firewall",
|
||||
"users": "Gebruikers en machtigingen",
|
||||
"healthCheck": "Gezondheidscontroles",
|
||||
"diskBreakdown": "Schijfdefect",
|
||||
"systemdTimers": "Timers",
|
||||
"topMemory": "Top op geheugen",
|
||||
"noData": "Geen gegevens",
|
||||
"sudoHint": "Stel een sudo-wachtwoord in voor deze host in de hosteditor om acties met verhoogde bevoegdheden mogelijk te maken.",
|
||||
"filter": "Filter...",
|
||||
"start": "Begin",
|
||||
"stop": "Stop",
|
||||
"restart": "Opnieuw opstarten",
|
||||
"actionDone": "{{name}} bijgewerkt",
|
||||
"actionFailed": "Actie mislukt",
|
||||
"signalSent": "Signaal verzonden naar PID {{pid}}",
|
||||
"killHint": "Klikken: beëindigen (SIGTERM). Rechtsklikken: geforceerd afsluiten (SIGKILL).",
|
||||
"working": "Bezig met verwerken...",
|
||||
"update": "Update",
|
||||
"upgradeAll": "Upgrade alles",
|
||||
"allUpToDate": "Alles is actueel.",
|
||||
"save": "Redden",
|
||||
"command": "Commando",
|
||||
"enabled": "Ingeschakeld",
|
||||
"cronSaved": "Crontab bijgewerkt",
|
||||
"clients": "Klanten",
|
||||
"dryRun": "Proefloop",
|
||||
"renew": "Vernieuwen",
|
||||
"noAcmeClient": "Er is geen ACME-client (certbot of acme.sh) gevonden op deze host.",
|
||||
"addInputRule": "Voeg een INPUT-regel toe",
|
||||
"firewallWarning": "Wijzigingen zijn alleen tijdens de uitvoering van het programma geldig totdat ze zijn opgeslagen. Pas op dat u uzelf niet buitensluit.",
|
||||
"ruleApplied": "Toegepaste regel",
|
||||
"invalidPort": "Voer een geldig poortnummer in (1-65535)",
|
||||
"newUsername": "Nieuwe gebruikersnaam",
|
||||
"addUser": "Toevoegen",
|
||||
"deleteUser": "Gebruiker verwijderen",
|
||||
"noHealthChecks": "Er zijn nog geen gezondheidscontroles geconfigureerd.",
|
||||
"follow": "Volgen",
|
||||
"noLogData": "Geen loguitvoer.",
|
||||
"tree": "Boom",
|
||||
"enableDisable": "In-/uitschakelen bij het opstarten",
|
||||
"grantSudo": "Verleen sudo",
|
||||
"revokeSudo": "Sudo intrekken",
|
||||
"sslIssueCert": "Certificaat uitreiken",
|
||||
"sslExpired": "Verlopen",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Voer ten minste één domein in",
|
||||
"sslIssued": "Certificaat afgegeven",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (zelfstandig)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS-provider (bijv. Cloudflare)",
|
||||
"sslIssueHint": "De inloggegevens van de DNS-provider moeten al op de host geconfigureerd zijn.",
|
||||
"sslRevoke": "Certificaat intrekken",
|
||||
"sslRevoked": "Certificaat ingetrokken",
|
||||
"sslRevokeConfirm": "Het certificaat \"{{name}}\" intrekken en verwijderen? Dit kan niet ongedaan gemaakt worden.",
|
||||
"healthRun": "Loop",
|
||||
"healthName": "Naam",
|
||||
"healthTarget": "Host / adres",
|
||||
"healthAddCheck": "Voeg een controle toe",
|
||||
"healthSaved": "Gezondheidscontroles opgeslagen",
|
||||
"healthMissingFields": "Elke controle heeft een naam en een doel nodig.",
|
||||
"logFile": "Bestand",
|
||||
"logUnit": "Eenheid",
|
||||
"logCustomPath": "Aangepast pad onder /var/log (optioneel)",
|
||||
"logGrep": "Filterlijnen...",
|
||||
"firewallPersist": "Persist-regels",
|
||||
"firewallPersisted": "De firewallregels bleven van kracht.",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard is niet geïnstalleerd op deze host.",
|
||||
"wgNoInterfaces": "Er zijn geen WireGuard-interfaces geconfigureerd.",
|
||||
"wgInterfaceUp": "Breng ter sprake",
|
||||
"wgInterfaceDown": "Neerhalen",
|
||||
"wgBringingUp": "{{name}} wordt geladen...",
|
||||
"wgBringingDown": "{{name}} naar beneden halen...",
|
||||
"wgInterfaceUpDone": "{{name}} is up",
|
||||
"wgInterfaceDownDone": "{{name}} is omlaag",
|
||||
"wgListenPort": "Haven",
|
||||
"wgPublicKey": "Openbare sleutel",
|
||||
"wgEndpoint": "Eindpunt",
|
||||
"wgAllowedIPs": "Toegestane IP-adressen",
|
||||
"wgLastHandshake": "Laatste handdruk",
|
||||
"wgHandshakeNever": "Nooit",
|
||||
"wgTransfer": "Overdracht",
|
||||
"wgNoPeers": "Geen gelijken"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Statistiekengeschiedenis",
|
||||
"historySuffix": "Geschiedenis",
|
||||
"cpuMemoryDisk": "CPU / Geheugen / Schijf",
|
||||
"network": "Netwerk",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Er zijn geen historische gegevens beschikbaar voor deze periode.",
|
||||
"custom": "Aangepast",
|
||||
"to": "naar",
|
||||
"apply": "Toepassen",
|
||||
"viewHistory": "Geschiedenis bekijken"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Hoststatistieken",
|
||||
"metricsCollectionLabel": "Gegevensverzameling",
|
||||
"enableMetricsLabel": "Statistieken inschakelen",
|
||||
"enableMetricsDesc": "Verzamel gegevens over CPU, RAM, schijfgebruik en andere parameters van deze host.",
|
||||
"useGlobalMetrics": "Gebruik globaal interval",
|
||||
"useGlobalMetricsDesc": "Overschrijf dit met het serverbrede metrische interval.",
|
||||
"metricsIntervalS": "Metriekinterval (s)",
|
||||
"metricsIntervalDesc2": "Seconden tussen momentopnamen van de meetwaarden",
|
||||
"excludedMountsLabel": "Uitgesloten montagesystemen",
|
||||
"monitoredMountsLabel": "Aangepaste bewaakte paden",
|
||||
"monitoredMountsDesc": "Voeg paden toe die niet als aparte mounts worden weergegeven, inclusief bind-mounted Home Assistant-paden zoals /config.",
|
||||
"monitoredMountPathPlaceholder": "Pad (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Label (optioneel)",
|
||||
"addMonitoredMount": "Pad toevoegen",
|
||||
"excludedMountsDesc": "Verberg specifieke koppelpunten of bestandssysteemtypen (bijv. nfs, cifs) voor schijfgebruik en waarschuwingen voor deze host.",
|
||||
"excludedMountsPlaceholder": "Mountpad (/mnt/nas) of bestandssysteemtype (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Geen mounts uitgesloten. Alle gedetecteerde bestandssystemen worden weergegeven.",
|
||||
"addExcludedMount": "Toevoegen",
|
||||
"visibleWidgets": "Zichtbare widgets",
|
||||
"widgetsMovedToHostMetrics": "Kaarten kunnen nu rechtstreeks in het tabblad Hoststatistieken worden toegevoegd, gerangschikt en van formaat worden veranderd. Open Hoststatistieken voor deze host en gebruik Aanpassen om te kiezen welke kaarten worden weergegeven.",
|
||||
"hostNotFound": "Host niet gevonden",
|
||||
"loadingHost": "Host wordt geladen..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Statistieken en waarschuwingen",
|
||||
"feature_metrics_desc": "Volg de CPU, het geheugen en de schijf gedurende een bepaalde tijd en ontvang een melding wanneer ze defect raken."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Historische statistieken bewaren",
|
||||
"metricsHistoryRetentionRange": "1 tot 90 dagen",
|
||||
"days": "dagen"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Geen gegevens beschikbaar",
|
||||
"showCurrentValue": "Toon huidige waarde",
|
||||
"chartMetric": "Metrisch",
|
||||
"metricRange": "Bereik",
|
||||
"host": "Gastheer",
|
||||
"loading": "Laden...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Schijf",
|
||||
"metricHostname": "Hostnaam",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Geheugen",
|
||||
"metricNetwork": "Netwerk",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processen",
|
||||
"metricProcessesRunning": "rennen",
|
||||
"metricProcessesTotal": "totaal",
|
||||
"metricUptime": "Beschikbaarheid",
|
||||
"metricsNotAvailable": "Gegevens niet beschikbaar",
|
||||
"noHostSelected": "Geen host geselecteerd",
|
||||
"selectHost": "Selecteer een host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live statistieken niet beschikbaar"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Schijf"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Aangesloten"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Gebruik hoststatistieken",
|
||||
"description": "Bekijk hoststatistieken en gebruik de hostmanagers (services, pakketten, firewall en meer) op hosts die u kunt bereiken."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metingen interval (seconden)",
|
||||
"description": "De frequentie waarmee een bekeken host wordt bemonsterd, tenzij de host zelf een interval instelt."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Bewaartermijn van de gegevens (dagen)",
|
||||
"description": "Hoe lang worden meetwaarden bewaard voor de historische grafieken?"
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Statistieken ingeschakeld voor nieuwe hosts",
|
||||
"description": "Of nieuwe hosts starten met ingeschakelde metrische gegevensverzameling."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperatuureenheid",
|
||||
"description": "Hoe temperaturen worden weergegeven in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Verzamel meetgegevens"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metingen interval (seconden)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Ingeschakelde widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Uitgesloten montagepunten"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Bewaakte paden"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Host Metrics"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Minne",
|
||||
"disk": "Disk",
|
||||
"network": "Nettverk",
|
||||
"uptime": "Oppetid",
|
||||
"processes": "Prosesser",
|
||||
"available": "Tilgjengelig",
|
||||
"free": "Ledig",
|
||||
"connecting": "Kobler til...",
|
||||
"connectionFailed": "Failed to connect to server",
|
||||
"naCpus": "N/A CPU(er)",
|
||||
"cpuCores_one": "{{count}} Core",
|
||||
"cpuCores_other": "{{count}} Cores",
|
||||
"cpuUsage": "CPU-bruk",
|
||||
"memoryUsage": "Minnebruk",
|
||||
"diskUsage": "Diskbruk",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Temperature",
|
||||
"highestTemperature": "Highest temperature",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Kunne ikke hente vertskonfigurasjon",
|
||||
"serverOffline": "Server frakoblet",
|
||||
"cannotFetchMetrics": "Kan ikke hente målinger fra frakoblet server",
|
||||
"totpFailed": "TOTP-verifisering mislyktes",
|
||||
"noneAuthNotSupported": "Host Metrics does not support 'none' authentication type.",
|
||||
"noHostSelected": "Ingen vert valgt",
|
||||
"load": "Last",
|
||||
"systemInfo": "Systeminformasjon",
|
||||
"hostname": "Vertsnavn",
|
||||
"operatingSystem": "Operativsystem",
|
||||
"kernel": "Kjerne",
|
||||
"seconds": "sekunder",
|
||||
"networkInterfaces": "Nettverksgrensesnitt",
|
||||
"noInterfacesFound": "Fant ingen nettverksgrensesnitt",
|
||||
"noProcessesFound": "Fant ingen prosesser",
|
||||
"processesTotal": "totalt",
|
||||
"processesRunning": "kjører",
|
||||
"loginStats": "SSH-påloggingsstatistikk",
|
||||
"noRecentLoginData": "Ingen nylige påloggingsdata",
|
||||
"ports": {
|
||||
"title": "Listening Ports",
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"address": "Address",
|
||||
"process": "Process",
|
||||
"search": "Search ports...",
|
||||
"allProtocols": "All",
|
||||
"noData": "No listening ports data"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inaktiv",
|
||||
"policy": "Policy",
|
||||
"rules": "rules",
|
||||
"noRules": "No rules in this chain",
|
||||
"noData": "No firewall data available",
|
||||
"action": "Action",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Source",
|
||||
"anywhere": "Anywhere",
|
||||
"chains": "chains"
|
||||
},
|
||||
"loadAvg": "Load Avg",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architecture",
|
||||
"refresh": "Oppdater",
|
||||
"retry": "Prøv igjen",
|
||||
"customize": "Customize layout",
|
||||
"reset": "Tilbakestill",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Drag cards to rearrange, drag the bottom edge to resize, and use the width button to change a card's span. Add or remove cards below.",
|
||||
"managers": {
|
||||
"services": "Services",
|
||||
"processInspector": "Process Inspector",
|
||||
"logViewer": "Log Viewer",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Packages",
|
||||
"ssl": "SSL Certificates",
|
||||
"firewall": "Firewall",
|
||||
"users": "Users & Permissions",
|
||||
"healthCheck": "Health Checks",
|
||||
"diskBreakdown": "Disk Breakdown",
|
||||
"systemdTimers": "Timers",
|
||||
"topMemory": "Top by Memory",
|
||||
"noData": "No data",
|
||||
"sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.",
|
||||
"filter": "Filter...",
|
||||
"start": "Start",
|
||||
"stop": "Stopp",
|
||||
"restart": "Start på nytt",
|
||||
"actionDone": "{{name}} updated",
|
||||
"actionFailed": "Action failed",
|
||||
"signalSent": "Signal sent to PID {{pid}}",
|
||||
"killHint": "Click: terminate (SIGTERM). Right-click: force kill (SIGKILL).",
|
||||
"working": "Working...",
|
||||
"update": "Update",
|
||||
"upgradeAll": "Upgrade all",
|
||||
"allUpToDate": "Everything is up to date",
|
||||
"save": "Lagre",
|
||||
"command": "Kommando",
|
||||
"enabled": "Aktivert",
|
||||
"cronSaved": "Crontab updated",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Dry run",
|
||||
"renew": "Renew",
|
||||
"noAcmeClient": "No ACME client (certbot or acme.sh) found on this host.",
|
||||
"addInputRule": "Add INPUT rule",
|
||||
"firewallWarning": "Changes are runtime-only until persisted. Be careful not to lock yourself out.",
|
||||
"ruleApplied": "Rule applied",
|
||||
"invalidPort": "Enter a valid port (1-65535)",
|
||||
"newUsername": "New username",
|
||||
"addUser": "Legg til",
|
||||
"deleteUser": "Delete user",
|
||||
"noHealthChecks": "No health checks configured yet.",
|
||||
"follow": "Follow",
|
||||
"noLogData": "No log output.",
|
||||
"tree": "Tree",
|
||||
"enableDisable": "Enable / disable at boot",
|
||||
"grantSudo": "Grant sudo",
|
||||
"revokeSudo": "Revoke sudo",
|
||||
"sslIssueCert": "Issue certificate",
|
||||
"sslExpired": "Utløpt",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Enter at least one domain",
|
||||
"sslIssued": "Certificate issued",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (standalone)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS provider (e.g. cloudflare)",
|
||||
"sslIssueHint": "DNS provider credentials must already be configured on the host.",
|
||||
"sslRevoke": "Revoke certificate",
|
||||
"sslRevoked": "Certificate revoked",
|
||||
"sslRevokeConfirm": "Revoke and remove the certificate \"{{name}}\"? This cannot be undone.",
|
||||
"healthRun": "Kjør",
|
||||
"healthName": "Navn",
|
||||
"healthTarget": "Host / address",
|
||||
"healthAddCheck": "Add check",
|
||||
"healthSaved": "Health checks saved",
|
||||
"healthMissingFields": "Each check needs a name and target",
|
||||
"logFile": "Fil",
|
||||
"logUnit": "Unit",
|
||||
"logCustomPath": "Custom path under /var/log (optional)",
|
||||
"logGrep": "Filter lines...",
|
||||
"firewallPersist": "Persist rules",
|
||||
"firewallPersisted": "Firewall rules persisted",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard is not installed on this host",
|
||||
"wgNoInterfaces": "No WireGuard interfaces configured",
|
||||
"wgInterfaceUp": "Bring up",
|
||||
"wgInterfaceDown": "Bring down",
|
||||
"wgBringingUp": "Bringing {{name}} up...",
|
||||
"wgBringingDown": "Bringing {{name}} down...",
|
||||
"wgInterfaceUpDone": "{{name}} is up",
|
||||
"wgInterfaceDownDone": "{{name}} is down",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Public key",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "Allowed IPs",
|
||||
"wgLastHandshake": "Last handshake",
|
||||
"wgHandshakeNever": "Never",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrics History",
|
||||
"historySuffix": "Historikk",
|
||||
"cpuMemoryDisk": "CPU / Memory / Disk",
|
||||
"network": "Nettverk",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "No history data available for this time range.",
|
||||
"custom": "Custom",
|
||||
"to": "to",
|
||||
"apply": "Apply",
|
||||
"viewHistory": "View History"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Host Metrics",
|
||||
"metricsCollectionLabel": "Metrics Collection",
|
||||
"enableMetricsLabel": "Enable Metrics",
|
||||
"enableMetricsDesc": "Collect CPU, RAM, disk, and other metrics from this host",
|
||||
"useGlobalMetrics": "Use Global Interval",
|
||||
"useGlobalMetricsDesc": "Override with the server-wide metrics interval",
|
||||
"metricsIntervalS": "Metrics Interval (s)",
|
||||
"metricsIntervalDesc2": "Seconds between metric snapshots",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Label (optional)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Legg til",
|
||||
"visibleWidgets": "Visible Widgets",
|
||||
"widgetsMovedToHostMetrics": "Cards are now added, arranged, and resized directly in the Host Metrics tab. Open Host Metrics for this host and use Customize to choose which cards are shown.",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrics History Retention",
|
||||
"metricsHistoryRetentionRange": "1 to 90 days",
|
||||
"days": "days"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "No data available",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range",
|
||||
"host": "Host",
|
||||
"loading": "Laster...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Vertsnavn",
|
||||
"metricKernel": "Kjerne",
|
||||
"metricMemory": "Minne",
|
||||
"metricNetwork": "Nettverk",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Prosesser",
|
||||
"metricProcessesRunning": "kjører",
|
||||
"metricProcessesTotal": "totalt",
|
||||
"metricUptime": "Oppetid",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"noHostSelected": "Ingen vert valgt",
|
||||
"selectHost": "Select a host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Tilkoblet"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Metryki hosta"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "Procesor",
|
||||
"memory": "Pamięć",
|
||||
"disk": "Dysk",
|
||||
"network": "Sieć",
|
||||
"uptime": "Czas działania",
|
||||
"processes": "Procesy",
|
||||
"available": "Dostępne",
|
||||
"free": "Wolne",
|
||||
"connecting": "Łączenie...",
|
||||
"connectionFailed": "Nie udało się połączyć z serwerem",
|
||||
"naCpus": "Brak danych o CPU",
|
||||
"cpuCores_one": "{{count}} rdzeń",
|
||||
"cpuCores_other": "{{count}} rdzeni",
|
||||
"cpuUsage": "Wykorzystanie procesora",
|
||||
"memoryUsage": "Wykorzystanie pamięci",
|
||||
"diskUsage": "Wykorzystanie dysku",
|
||||
"selectFilesystem": "Wybierz system plików",
|
||||
"temperature": "Temperatura",
|
||||
"highestTemperature": "Najwyższa temperatura",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Nie udało się pobrać konfiguracji hosta",
|
||||
"serverOffline": "Serwer offline",
|
||||
"cannotFetchMetrics": "Nie można pobrać metryk z serwera offline",
|
||||
"totpFailed": "Weryfikacja TOTP nie powiodła się",
|
||||
"noneAuthNotSupported": "Metryki hosta nie obsługują typu uwierzytelniania „none”.",
|
||||
"noHostSelected": "Nie wybrano hosta",
|
||||
"load": "Obciążenie",
|
||||
"systemInfo": "Informacje o systemie",
|
||||
"hostname": "Nazwa hosta",
|
||||
"operatingSystem": "System operacyjny",
|
||||
"kernel": "Jądro",
|
||||
"seconds": "sekundy",
|
||||
"networkInterfaces": "Interfejsy sieciowe",
|
||||
"noInterfacesFound": "Nie znaleziono interfejsów sieciowych",
|
||||
"noProcessesFound": "Nie znaleziono procesów",
|
||||
"processesTotal": "łącznie",
|
||||
"processesRunning": "działa",
|
||||
"loginStats": "Statystyki logowania SSH",
|
||||
"noRecentLoginData": "Brak ostatnich danych logowania",
|
||||
"ports": {
|
||||
"title": "Porty nasłuchujące",
|
||||
"protocol": "Protokół",
|
||||
"port": "Port",
|
||||
"address": "Adres",
|
||||
"process": "Proces",
|
||||
"search": "Przeszukaj porty...",
|
||||
"allProtocols": "Wszystkie",
|
||||
"noData": "Brak danych portów nasłuchujących"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Zapora sieciowa",
|
||||
"inactive": "Nieaktywna",
|
||||
"policy": "Polityka",
|
||||
"rules": "reguł",
|
||||
"noRules": "Brak reguł w tym łańcuchu",
|
||||
"noData": "Brak dostępnych danych zapory sieciowej",
|
||||
"action": "Akcja",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Źródło",
|
||||
"anywhere": "Gdziekolwiek",
|
||||
"chains": "łańcuchy"
|
||||
},
|
||||
"loadAvg": "Obciążenie średnie",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architektura",
|
||||
"refresh": "Odśwież",
|
||||
"retry": "Ponów",
|
||||
"customize": "Dostosuj układ",
|
||||
"reset": "Resetuj",
|
||||
"tabLive": "Na żywo",
|
||||
"editModeInstructions": "Przeciągnij karty, aby zmienić kolejność, przeciągnij dolną krawędź, aby zmienić wysokość, a przyciskiem szerokości zmień szerokość karty. Dodawaj lub usuwaj karty poniżej.",
|
||||
"managers": {
|
||||
"services": "Usługi",
|
||||
"processInspector": "Inspektor procesów",
|
||||
"logViewer": "Przeglądarka logów",
|
||||
"cron": "Zadania cron",
|
||||
"packages": "Pakiety",
|
||||
"ssl": "Certyfikaty SSL",
|
||||
"firewall": "Zapora sieciowa",
|
||||
"users": "Użytkownicy i uprawnienia",
|
||||
"healthCheck": "Kontrole stanu",
|
||||
"diskBreakdown": "Podział dysku",
|
||||
"systemdTimers": "Timery",
|
||||
"topMemory": "Wg pamięci",
|
||||
"noData": "Brak danych",
|
||||
"sudoHint": "Ustaw hasło sudo dla tego hosta w edytorze hosta, aby włączyć akcje uprzywilejowane.",
|
||||
"filter": "Filtr...",
|
||||
"start": "Start",
|
||||
"stop": "Zatrzymaj",
|
||||
"restart": "Uruchom ponownie",
|
||||
"actionDone": "Zaktualizowano {{name}}",
|
||||
"actionFailed": "Akcja nie powiodła się",
|
||||
"signalSent": "Sygnał wysłany do PID {{pid}}",
|
||||
"killHint": "Kliknięcie: zakończ (SIGTERM). Kliknięcie prawym przyciskiem myszy: wymuś zakończenie (SIGKILL).",
|
||||
"working": "Przetwarzanie...",
|
||||
"update": "Aktualizuj",
|
||||
"upgradeAll": "Uaktualnij wszystko",
|
||||
"allUpToDate": "Wszystko jest aktualne",
|
||||
"save": "Zapisz",
|
||||
"command": "Polecenie",
|
||||
"enabled": "Włączone",
|
||||
"cronSaved": "Zaktualizowano crontab",
|
||||
"clients": "Klienci",
|
||||
"dryRun": "Symulacja",
|
||||
"renew": "Odnów",
|
||||
"noAcmeClient": "Nie znaleziono klienta ACME (certbot lub acme.sh) na tym hoście.",
|
||||
"addInputRule": "Dodaj regułę INPUT",
|
||||
"firewallWarning": "Zmiany działają tylko do restartu, dopóki nie zostaną utrwalone. Uważaj, aby nie odciąć sobie dostępu.",
|
||||
"ruleApplied": "Zastosowano regułę",
|
||||
"invalidPort": "Wprowadź prawidłowy port (1-65535)",
|
||||
"newUsername": "Nowa nazwa użytkownika",
|
||||
"addUser": "Dodaj",
|
||||
"deleteUser": "Usuń użytkownika",
|
||||
"noHealthChecks": "Nie skonfigurowano jeszcze żadnych kontroli stanu.",
|
||||
"follow": "Śledź",
|
||||
"noLogData": "Brak wyjścia logu.",
|
||||
"tree": "Drzewo",
|
||||
"enableDisable": "Włącz/wyłącz przy starcie systemu",
|
||||
"grantSudo": "Przyznaj sudo",
|
||||
"revokeSudo": "Odwołaj sudo",
|
||||
"sslIssueCert": "Wystaw certyfikat",
|
||||
"sslExpired": "Wygasły",
|
||||
"sslInDays": "za {{days}} dni",
|
||||
"sslNeedDomain": "Wprowadź co najmniej jedną domenę",
|
||||
"sslIssued": "Wystawiono certyfikat",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (samodzielny)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "Dostawca DNS (np. Cloudflare)",
|
||||
"sslIssueHint": "Poświadczenia dostawcy DNS muszą być już skonfigurowane na hoście.",
|
||||
"sslRevoke": "Unieważnij certyfikat",
|
||||
"sslRevoked": "Unieważniono certyfikat",
|
||||
"sslRevokeConfirm": "Unieważnić i usunąć certyfikat „{{name}}”? Tego nie można cofnąć.",
|
||||
"healthRun": "Uruchom",
|
||||
"healthName": "Nazwa",
|
||||
"healthTarget": "Host / adres",
|
||||
"healthAddCheck": "Dodaj kontrolę",
|
||||
"healthSaved": "Zapisano kontrole stanu",
|
||||
"healthMissingFields": "Każda kontrola wymaga nazwy i celu",
|
||||
"logFile": "Plik",
|
||||
"logUnit": "Unit",
|
||||
"logCustomPath": "Ścieżka niestandardowa w /var/log (opcjonalnie)",
|
||||
"logGrep": "Filtruj wiersze...",
|
||||
"firewallPersist": "Utrwal reguły",
|
||||
"firewallPersisted": "Utrwalono reguły zapory",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard nie jest zainstalowany na tym hoście",
|
||||
"wgNoInterfaces": "Brak skonfigurowanych interfejsów WireGuard",
|
||||
"wgInterfaceUp": "Podnieś",
|
||||
"wgInterfaceDown": "Wyłącz",
|
||||
"wgBringingUp": "Podnoszenie {{name}}...",
|
||||
"wgBringingDown": "Wyłączanie {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} jest podniesiony",
|
||||
"wgInterfaceDownDone": "{{name}} jest wyłączony",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Klucz publiczny",
|
||||
"wgEndpoint": "Punkt końcowy",
|
||||
"wgAllowedIPs": "Dozwolone adresy IP",
|
||||
"wgLastHandshake": "Ostatni handshake",
|
||||
"wgHandshakeNever": "Nigdy",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Historia metryk",
|
||||
"historySuffix": "Historia",
|
||||
"cpuMemoryDisk": "Procesor / Pamięć / Dysk",
|
||||
"network": "Sieć",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Brak dostępnych danych historycznych dla tego przedziału czasu.",
|
||||
"custom": "Niestandardowy",
|
||||
"to": "do",
|
||||
"apply": "Zastosuj",
|
||||
"viewHistory": "Wyświetl historię"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Metryki hosta",
|
||||
"metricsCollectionLabel": "Zbieranie metryk",
|
||||
"enableMetricsLabel": "Włącz metryki",
|
||||
"enableMetricsDesc": "Zbieraj dane o procesorze, pamięci RAM, dysku i inne metryki z tego hosta",
|
||||
"useGlobalMetrics": "Użyj interwału globalnego",
|
||||
"useGlobalMetricsDesc": "Użyj globalnego interwału zbierania metryk",
|
||||
"metricsIntervalS": "Interwał metryk (s)",
|
||||
"metricsIntervalDesc2": "Sekundy między migawkami metryk",
|
||||
"excludedMountsLabel": "Wykluczone montowania",
|
||||
"monitoredMountsLabel": "Niestandardowe monitorowane ścieżki",
|
||||
"monitoredMountsDesc": "Dodaj ścieżki, które nie są udostępniane jako osobne montowania, w tym ścieżki Home Assistant montowane przez bind, takie jak /config.",
|
||||
"monitoredMountPathPlaceholder": "Ścieżka (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Etykieta (opcjonalnie)",
|
||||
"addMonitoredMount": "Dodaj ścieżkę",
|
||||
"excludedMountsDesc": "Ukryj określone punkty montowania lub typy systemów plików (np. nfs, cifs) w informacjach o wykorzystaniu dysku i alertach dla tego hosta.",
|
||||
"excludedMountsPlaceholder": "Ścieżka montowania (/mnt/nas) lub typ systemu plików (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Brak wykluczonych montowań. Wyświetlane są wszystkie wykryte systemy plików.",
|
||||
"addExcludedMount": "Dodaj",
|
||||
"visibleWidgets": "Widoczne widżety",
|
||||
"widgetsMovedToHostMetrics": "Karty dodaje się, układa i skaluje bezpośrednio na karcie „Metryki hosta”. Otwórz Metryki hosta dla tego hosta i użyj opcji „Dostosuj”, aby wybrać wyświetlane karty.",
|
||||
"hostNotFound": "Nie znaleziono hosta",
|
||||
"loadingHost": "Ładowanie hosta..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metryki i alerty",
|
||||
"feature_metrics_desc": "Śledź procesor, pamięć i dysk w czasie i otrzymuj alerty, gdy przekroczą próg."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Przechowywanie historii metryk",
|
||||
"metricsHistoryRetentionRange": "od 1 do 90 dni",
|
||||
"days": "dni"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Brak dostępnych danych",
|
||||
"showCurrentValue": "Pokaż wartość bieżącą",
|
||||
"chartMetric": "Metryka",
|
||||
"metricRange": "Zakres",
|
||||
"host": "Host",
|
||||
"loading": "Ładowanie...",
|
||||
"metricCpu": "Procesor",
|
||||
"metricDisk": "Dysk",
|
||||
"metricHostname": "Nazwa hosta",
|
||||
"metricKernel": "Jądro",
|
||||
"metricMemory": "Pamięć",
|
||||
"metricNetwork": "Sieć",
|
||||
"metricOs": "System operacyjny",
|
||||
"metricProcesses": "Procesy",
|
||||
"metricProcessesRunning": "działa",
|
||||
"metricProcessesTotal": "łącznie",
|
||||
"metricUptime": "Czas działania",
|
||||
"metricsNotAvailable": "Metryki niedostępne",
|
||||
"noHostSelected": "Nie wybrano hosta",
|
||||
"selectHost": "Wybierz hosta..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Statystyki na żywo niedostępne"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "Procesor",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Dysk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Połączono"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Métricas do Host"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"network": "Network",
|
||||
"uptime": "Uptime",
|
||||
"processes": "Processes",
|
||||
"available": "Available",
|
||||
"free": "Free",
|
||||
"connecting": "Conectando...",
|
||||
"connectionFailed": "Failed to connect to server",
|
||||
"naCpus": "N/A CPU(s)",
|
||||
"cpuCores_one": "{{count}} Core",
|
||||
"cpuCores_other": "{{count}} Cores",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"diskUsage": "Disk Usage",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Temperature",
|
||||
"highestTemperature": "Highest temperature",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Failed to fetch host configuration",
|
||||
"serverOffline": "Server Offline",
|
||||
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||
"totpFailed": "TOTP verification failed",
|
||||
"noneAuthNotSupported": "Host Metrics does not support 'none' authentication type.",
|
||||
"noHostSelected": "No host selected",
|
||||
"load": "Load",
|
||||
"systemInfo": "System Information",
|
||||
"hostname": "Hostname",
|
||||
"operatingSystem": "Operating System",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "seconds",
|
||||
"networkInterfaces": "Network Interfaces",
|
||||
"noInterfacesFound": "No network interfaces found",
|
||||
"noProcessesFound": "No processes found",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "running",
|
||||
"loginStats": "SSH Login Statistics",
|
||||
"noRecentLoginData": "No recent login data",
|
||||
"ports": {
|
||||
"title": "Listening Ports",
|
||||
"protocol": "Protocol",
|
||||
"port": "Porta",
|
||||
"address": "Address",
|
||||
"process": "Process",
|
||||
"search": "Search ports...",
|
||||
"allProtocols": "All",
|
||||
"noData": "No listening ports data"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inactive",
|
||||
"policy": "Policy",
|
||||
"rules": "rules",
|
||||
"noRules": "No rules in this chain",
|
||||
"noData": "No firewall data available",
|
||||
"action": "Action",
|
||||
"protocol": "Proto",
|
||||
"port": "Porta",
|
||||
"source": "Source",
|
||||
"anywhere": "Anywhere",
|
||||
"chains": "chains"
|
||||
},
|
||||
"loadAvg": "Load Avg",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architecture",
|
||||
"refresh": "Atualizar",
|
||||
"retry": "Retry",
|
||||
"customize": "Customize layout",
|
||||
"reset": "Reset",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Drag cards to rearrange, drag the bottom edge to resize, and use the width button to change a card's span. Add or remove cards below.",
|
||||
"managers": {
|
||||
"services": "Services",
|
||||
"processInspector": "Process Inspector",
|
||||
"logViewer": "Log Viewer",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Packages",
|
||||
"ssl": "SSL Certificates",
|
||||
"firewall": "Firewall",
|
||||
"users": "Users & Permissions",
|
||||
"healthCheck": "Health Checks",
|
||||
"diskBreakdown": "Disk Breakdown",
|
||||
"systemdTimers": "Timers",
|
||||
"topMemory": "Top by Memory",
|
||||
"noData": "No data",
|
||||
"sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.",
|
||||
"filter": "Filter...",
|
||||
"start": "Iniciar",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"actionDone": "{{name}} updated",
|
||||
"actionFailed": "Action failed",
|
||||
"signalSent": "Signal sent to PID {{pid}}",
|
||||
"killHint": "Click: terminate (SIGTERM). Right-click: force kill (SIGKILL).",
|
||||
"working": "Working...",
|
||||
"update": "Atualizar",
|
||||
"upgradeAll": "Upgrade all",
|
||||
"allUpToDate": "Everything is up to date",
|
||||
"save": "Salvar",
|
||||
"command": "Command",
|
||||
"enabled": "Enabled",
|
||||
"cronSaved": "Crontab updated",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Dry run",
|
||||
"renew": "Renew",
|
||||
"noAcmeClient": "No ACME client (certbot or acme.sh) found on this host.",
|
||||
"addInputRule": "Add INPUT rule",
|
||||
"firewallWarning": "Changes are runtime-only until persisted. Be careful not to lock yourself out.",
|
||||
"ruleApplied": "Rule applied",
|
||||
"invalidPort": "Enter a valid port (1-65535)",
|
||||
"newUsername": "New username",
|
||||
"addUser": "Adicionar",
|
||||
"deleteUser": "Delete user",
|
||||
"noHealthChecks": "No health checks configured yet.",
|
||||
"follow": "Follow",
|
||||
"noLogData": "No log output.",
|
||||
"tree": "Tree",
|
||||
"enableDisable": "Enable / disable at boot",
|
||||
"grantSudo": "Grant sudo",
|
||||
"revokeSudo": "Revoke sudo",
|
||||
"sslIssueCert": "Issue certificate",
|
||||
"sslExpired": "Expired",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Enter at least one domain",
|
||||
"sslIssued": "Certificate issued",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (standalone)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS provider (e.g. cloudflare)",
|
||||
"sslIssueHint": "DNS provider credentials must already be configured on the host.",
|
||||
"sslRevoke": "Revoke certificate",
|
||||
"sslRevoked": "Certificate revoked",
|
||||
"sslRevokeConfirm": "Revoke and remove the certificate \"{{name}}\"? This cannot be undone.",
|
||||
"healthRun": "Run",
|
||||
"healthName": "Name",
|
||||
"healthTarget": "Host / address",
|
||||
"healthAddCheck": "Add check",
|
||||
"healthSaved": "Health checks saved",
|
||||
"healthMissingFields": "Each check needs a name and target",
|
||||
"logFile": "File",
|
||||
"logUnit": "Unit",
|
||||
"logCustomPath": "Custom path under /var/log (optional)",
|
||||
"logGrep": "Filter lines...",
|
||||
"firewallPersist": "Persist rules",
|
||||
"firewallPersisted": "Firewall rules persisted",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard is not installed on this host",
|
||||
"wgNoInterfaces": "No WireGuard interfaces configured",
|
||||
"wgInterfaceUp": "Bring up",
|
||||
"wgInterfaceDown": "Bring down",
|
||||
"wgBringingUp": "Bringing {{name}} up...",
|
||||
"wgBringingDown": "Bringing {{name}} down...",
|
||||
"wgInterfaceUpDone": "{{name}} is up",
|
||||
"wgInterfaceDownDone": "{{name}} is down",
|
||||
"wgListenPort": "Porta",
|
||||
"wgPublicKey": "Public key",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "Allowed IPs",
|
||||
"wgLastHandshake": "Last handshake",
|
||||
"wgHandshakeNever": "Never",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrics History",
|
||||
"historySuffix": "Histórico",
|
||||
"cpuMemoryDisk": "CPU / Memory / Disk",
|
||||
"network": "Network",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "No history data available for this time range.",
|
||||
"custom": "Custom",
|
||||
"to": "to",
|
||||
"apply": "Apply",
|
||||
"viewHistory": "View History"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Métricas do Host",
|
||||
"metricsCollectionLabel": "Coleção de Métricas",
|
||||
"enableMetricsLabel": "Habilitar Métricas",
|
||||
"enableMetricsDesc": "Collect CPU, RAM, disk, and other metrics from this host",
|
||||
"useGlobalMetrics": "Use Global Interval",
|
||||
"useGlobalMetricsDesc": "Override with the server-wide metrics interval",
|
||||
"metricsIntervalS": "Metrics Interval (s)",
|
||||
"metricsIntervalDesc2": "Seconds between metric snapshots",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Label (optional)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Adicionar",
|
||||
"visibleWidgets": "Visible Widgets",
|
||||
"widgetsMovedToHostMetrics": "Cards are now added, arranged, and resized directly in the Host Metrics tab. Open Host Metrics for this host and use Customize to choose which cards are shown.",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrics History Retention",
|
||||
"metricsHistoryRetentionRange": "1 to 90 days",
|
||||
"days": "days"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "No data available",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range",
|
||||
"host": "Host",
|
||||
"loading": "Carregando...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Hostname",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memory",
|
||||
"metricNetwork": "Network",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processes",
|
||||
"metricProcessesRunning": "running",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Uptime",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"noHostSelected": "No host selected",
|
||||
"selectHost": "Select a host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connected"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Métricas do Servidor"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memória",
|
||||
"disk": "Disco",
|
||||
"network": "Rede",
|
||||
"uptime": "Tempo de atividade",
|
||||
"processes": "Processos",
|
||||
"available": "Disponível",
|
||||
"free": "Livre",
|
||||
"connecting": "A ligar...",
|
||||
"connectionFailed": "Falha ao ligar ao servidor",
|
||||
"naCpus": "N/D CPU(s)",
|
||||
"cpuCores_one": "{{count}} Núcleo",
|
||||
"cpuCores_other": "{{count}} Núcleos",
|
||||
"cpuUsage": "Utilização da CPU",
|
||||
"memoryUsage": "Utilização da memória",
|
||||
"diskUsage": "Utilização do disco",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Temperatura",
|
||||
"highestTemperature": "Temperatura máxima",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Falha ao obter a configuração do servidor",
|
||||
"serverOffline": "Servidor offline",
|
||||
"cannotFetchMetrics": "Não é possível obter métricas do servidor offline",
|
||||
"totpFailed": "Falha na verificação TOTP",
|
||||
"noneAuthNotSupported": "Host Metrics não suporta o tipo de autenticação 'none'.",
|
||||
"noHostSelected": "Nenhum host selecionado",
|
||||
"load": "Carga",
|
||||
"systemInfo": "Informação do Sistema",
|
||||
"hostname": "Nome do host",
|
||||
"operatingSystem": "Sistema Operativo",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "segundos",
|
||||
"networkInterfaces": "Interfaces de Rede",
|
||||
"noInterfacesFound": "Nenhuma interface de rede encontrada",
|
||||
"noProcessesFound": "Nenhum processo encontrado",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "em execução",
|
||||
"loginStats": "Estatísticas de início de sessão SSH",
|
||||
"noRecentLoginData": "Sem dados de início de sessão recentes",
|
||||
"ports": {
|
||||
"title": "Portas em escuta",
|
||||
"protocol": "Protocolo",
|
||||
"port": "Porta",
|
||||
"address": "Endereço",
|
||||
"process": "Processo",
|
||||
"search": "Pesquisar portas...",
|
||||
"allProtocols": "Todos",
|
||||
"noData": "Sem dados de portas em escuta"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inativo",
|
||||
"policy": "Política",
|
||||
"rules": "regras",
|
||||
"noRules": "Sem regras nesta cadeia",
|
||||
"noData": "Sem dados de firewall disponíveis",
|
||||
"action": "Ação",
|
||||
"protocol": "Proto",
|
||||
"port": "Porta",
|
||||
"source": "Origem",
|
||||
"anywhere": "Qualquer lugar",
|
||||
"chains": "cadeias"
|
||||
},
|
||||
"loadAvg": "Carga Média",
|
||||
"swap": "Swap",
|
||||
"architecture": "Arquitetura",
|
||||
"refresh": "Atualizar",
|
||||
"retry": "Tentar novamente",
|
||||
"customize": "Personalizar layout",
|
||||
"reset": "Redefinir",
|
||||
"tabLive": "Ao Vivo",
|
||||
"editModeInstructions": "Arraste os cartões para reorganizar, arraste a borda inferior para redimensionar e use o botão de largura para alterar a largura do cartão. Adicione ou remova cartões abaixo.",
|
||||
"managers": {
|
||||
"services": "Serviços",
|
||||
"processInspector": "Inspetor de Processos",
|
||||
"logViewer": "Visualizador de Registos",
|
||||
"cron": "Tarefas Cron",
|
||||
"packages": "Pacotes",
|
||||
"ssl": "Certificados SSL",
|
||||
"firewall": "Firewall",
|
||||
"users": "Utilizadores e Permissões",
|
||||
"healthCheck": "Verificações de Estado",
|
||||
"diskBreakdown": "Análise de Disco",
|
||||
"systemdTimers": "Temporizadores",
|
||||
"topMemory": "Top por Memória",
|
||||
"noData": "Sem dados",
|
||||
"sudoHint": "Defina uma palavra-passe sudo para este host no editor de hosts para ativar ações privilegiadas.",
|
||||
"filter": "Filtrar...",
|
||||
"start": "Iniciar",
|
||||
"stop": "Parar",
|
||||
"restart": "Reiniciar",
|
||||
"actionDone": "{{name}} atualizado",
|
||||
"actionFailed": "A ação falhou",
|
||||
"signalSent": "Sinal enviado para o PID {{pid}}",
|
||||
"killHint": "Clique: terminar (SIGTERM). Clique direito: forçar encerramento (SIGKILL).",
|
||||
"working": "A trabalhar...",
|
||||
"update": "Atualizar",
|
||||
"upgradeAll": "Atualizar tudo",
|
||||
"allUpToDate": "Tudo está atualizado",
|
||||
"save": "Guardar",
|
||||
"command": "Comando",
|
||||
"enabled": "Ativado",
|
||||
"cronSaved": "Crontab atualizado",
|
||||
"clients": "Clientes",
|
||||
"dryRun": "Simulação",
|
||||
"renew": "Renovar",
|
||||
"noAcmeClient": "Nenhum cliente ACME (certbot ou acme.sh) encontrado neste servidor.",
|
||||
"addInputRule": "Adicionar regra INPUT",
|
||||
"firewallWarning": "As alterações são efetuadas apenas em execução até serem guardadas. Tenha cuidado para não ficar bloqueado.",
|
||||
"ruleApplied": "Regra aplicada",
|
||||
"invalidPort": "Introduza uma porta válida (1-65535)",
|
||||
"newUsername": "Novo nome de utilizador",
|
||||
"addUser": "Adicionar",
|
||||
"deleteUser": "Eliminar utilizador",
|
||||
"noHealthChecks": "Nenhuma verificação de estado configurada ainda.",
|
||||
"follow": "Seguir",
|
||||
"noLogData": "Sem saída de registo.",
|
||||
"tree": "Árvore",
|
||||
"enableDisable": "Ativar / desativar no arranque",
|
||||
"grantSudo": "Conceder sudo",
|
||||
"revokeSudo": "Revogar sudo",
|
||||
"sslIssueCert": "Emitir certificado",
|
||||
"sslExpired": "Expirado",
|
||||
"sslInDays": "em {{days}}d",
|
||||
"sslNeedDomain": "Introduza pelo menos um domínio",
|
||||
"sslIssued": "Certificado emitido",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (autónomo)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "Fornecedor DNS (ex. cloudflare)",
|
||||
"sslIssueHint": "As credenciais do fornecedor DNS já devem estar configuradas no host.",
|
||||
"sslRevoke": "Revogar certificado",
|
||||
"sslRevoked": "Certificado revogado",
|
||||
"sslRevokeConfirm": "Revogar e remover o certificado \"{{name}}\"? Esta ação não pode ser anulada.",
|
||||
"healthRun": "Executar",
|
||||
"healthName": "Nome",
|
||||
"healthTarget": "Host / endereço",
|
||||
"healthAddCheck": "Adicionar verificação",
|
||||
"healthSaved": "Verificações de saúde guardadas",
|
||||
"healthMissingFields": "Cada verificação necessita de um nome e um destino",
|
||||
"logFile": "Ficheiro",
|
||||
"logUnit": "Unidade",
|
||||
"logCustomPath": "Caminho personalizado em /var/log (opcional)",
|
||||
"logGrep": "Filtrar linhas...",
|
||||
"firewallPersist": "Persistir regras",
|
||||
"firewallPersisted": "Regras de firewall persistidas",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "O WireGuard não está instalado neste host",
|
||||
"wgNoInterfaces": "Nenhuma interface WireGuard configurada",
|
||||
"wgInterfaceUp": "Ativar",
|
||||
"wgInterfaceDown": "Desativar",
|
||||
"wgBringingUp": "A ativar {{name}}...",
|
||||
"wgBringingDown": "A desativar {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} está ativada",
|
||||
"wgInterfaceDownDone": "{{name}} está desativada",
|
||||
"wgListenPort": "Porta",
|
||||
"wgPublicKey": "Chave pública",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "IPs permitidos",
|
||||
"wgLastHandshake": "Último handshake",
|
||||
"wgHandshakeNever": "Nunca",
|
||||
"wgTransfer": "Transferência",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Histórico de Métricas",
|
||||
"historySuffix": "Histórico",
|
||||
"cpuMemoryDisk": "CPU / Memória / Disco",
|
||||
"network": "Rede",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Sem dados de histórico disponíveis para este intervalo de tempo.",
|
||||
"custom": "Personalizado",
|
||||
"to": "a",
|
||||
"apply": "Aplicar",
|
||||
"viewHistory": "Ver Histórico"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Métricas do Host",
|
||||
"metricsCollectionLabel": "Recolha de Métricas",
|
||||
"enableMetricsLabel": "Ativar Métricas",
|
||||
"enableMetricsDesc": "Recolher métricas de CPU, RAM, disco e outras deste host",
|
||||
"useGlobalMetrics": "Usar Intervalo Global",
|
||||
"useGlobalMetricsDesc": "Substituir pelo intervalo de métricas global do servidor",
|
||||
"metricsIntervalS": "Intervalo de Métricas (s)",
|
||||
"metricsIntervalDesc2": "Segundos entre cada registo de métricas",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Etiqueta (opcional)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Adicionar",
|
||||
"visibleWidgets": "Widgets Visíveis",
|
||||
"widgetsMovedToHostMetrics": "Os cartões são agora adicionados, organizados e redimensionados diretamente no separador Métricas do Host. Abra as Métricas do Host para este host e use Personalizar para escolher quais os cartões a mostrar.",
|
||||
"hostNotFound": "Host não encontrado",
|
||||
"loadingHost": "A carregar o host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Retenção do histórico de métricas",
|
||||
"metricsHistoryRetentionRange": "1 a 90 dias",
|
||||
"days": "dias"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Sem dados disponíveis",
|
||||
"showCurrentValue": "Mostrar valor atual",
|
||||
"chartMetric": "Métrica",
|
||||
"metricRange": "Intervalo",
|
||||
"host": "Host",
|
||||
"loading": "A carregar...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disco",
|
||||
"metricHostname": "Nome do host",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memória",
|
||||
"metricNetwork": "Rede",
|
||||
"metricOs": "SO",
|
||||
"metricProcesses": "Processos",
|
||||
"metricProcessesRunning": "em execução",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Tempo de atividade",
|
||||
"metricsNotAvailable": "Métricas não disponíveis",
|
||||
"noHostSelected": "Nenhum anfitrião selecionado",
|
||||
"selectHost": "Selecionar um host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disco"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Ligado"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Metrici ale gazdei"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memorie",
|
||||
"disk": "Disc",
|
||||
"network": "Reţea",
|
||||
"uptime": "Timp de funcționare",
|
||||
"processes": "Procese",
|
||||
"available": "Disponibil",
|
||||
"free": "Gratuit",
|
||||
"connecting": "Se conectează...",
|
||||
"connectionFailed": "Conectarea la server a eșuat",
|
||||
"naCpus": "N/A CPU(uri)",
|
||||
"cpuCores_one": "{{count}} Nucleu",
|
||||
"cpuCores_other": "{{count}} Nuclee",
|
||||
"cpuUsage": "Utilizarea procesorului",
|
||||
"memoryUsage": "Utilizarea memoriei",
|
||||
"diskUsage": "Utilizarea discului",
|
||||
"selectFilesystem": "Selectați sistemul de fișiere",
|
||||
"temperature": "Temperatură",
|
||||
"highestTemperature": "Cea mai ridicată temperatură",
|
||||
"primaryTemperatureSensor": "Senzor principal",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "Nu a fost detectată nicio placă grafică NVIDIA. Instalați driverul NVIDIA (acesta oferă nvidia-smi) pentru a vedea valorile GPU.",
|
||||
"driver": "Șofer {{version}}",
|
||||
"utilization": "Utilizare",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temperatura",
|
||||
"power": "Putere",
|
||||
"fan": "Ventilator",
|
||||
"processes": "Procese GPU",
|
||||
"noProcesses": "Niciun proces nu folosește GPU-ul"
|
||||
},
|
||||
"failedToFetchHostConfig": "Nu s-a putut prelua configurația gazdei",
|
||||
"serverOffline": "Serverul este offline",
|
||||
"cannotFetchMetrics": "Nu se pot prelua valori de pe serverul offline",
|
||||
"totpFailed": "Verificarea TOTP a eșuat",
|
||||
"noneAuthNotSupported": "Metricile gazdei nu acceptă tipul de autentificare „none”.",
|
||||
"noHostSelected": "Nicio gazdă selectată",
|
||||
"load": "Încărca",
|
||||
"systemInfo": "Informații despre sistem",
|
||||
"hostname": "Nume gazdă",
|
||||
"operatingSystem": "Sistem de operare",
|
||||
"kernel": "Nucleu",
|
||||
"seconds": "secunde",
|
||||
"networkInterfaces": "Interfețe de rețea",
|
||||
"noInterfacesFound": "Nu s-au găsit interfețe de rețea",
|
||||
"noProcessesFound": "Nu s-au găsit procese",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "funcţionare",
|
||||
"loginStats": "Statistici de conectare SSH",
|
||||
"noRecentLoginData": "Nu există date de conectare recente",
|
||||
"ports": {
|
||||
"title": "Porturi de ascultare",
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"address": "Adresa",
|
||||
"process": "Proces",
|
||||
"search": "Căutați porturi...",
|
||||
"allProtocols": "Toate",
|
||||
"noData": "Nu există date despre porturile de ascultare"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inactiv",
|
||||
"policy": "Politică",
|
||||
"rules": "reguli",
|
||||
"noRules": "Nicio regulă în acest lanț",
|
||||
"noData": "Nu sunt disponibile date despre firewall",
|
||||
"action": "Acţiune",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Sursă",
|
||||
"anywhere": "Oriunde",
|
||||
"chains": "lanţuri"
|
||||
},
|
||||
"loadAvg": "Încărcare medie",
|
||||
"swap": "Schimb",
|
||||
"architecture": "Arhitectură",
|
||||
"refresh": "Reîmprospăta",
|
||||
"retry": "Reîncercați",
|
||||
"customize": "Personalizați aspectul",
|
||||
"reset": "Resetare",
|
||||
"tabLive": "Trăi",
|
||||
"editModeInstructions": "Trageți cărțile pentru a le rearanja, trageți marginea de jos pentru a o redimensiona și folosiți butonul de lățime pentru a modifica întinderea unei cărți. Adăugați sau eliminați cărți mai jos.",
|
||||
"managers": {
|
||||
"services": "Servicii",
|
||||
"processInspector": "Inspector de procese",
|
||||
"logViewer": "Vizualizator de jurnal",
|
||||
"cron": "Lucrări cron",
|
||||
"packages": "Pachete",
|
||||
"ssl": "Certificate SSL",
|
||||
"firewall": "Firewall",
|
||||
"users": "Utilizatori și permisiuni",
|
||||
"healthCheck": "Verificări medicale",
|
||||
"diskBreakdown": "Defalcarea discului",
|
||||
"systemdTimers": "Cronometre",
|
||||
"topMemory": "Top după memorie",
|
||||
"noData": "Fără date",
|
||||
"sudoHint": "Setați o parolă sudo pentru această gazdă în editorul de gazde pentru a activa acțiuni privilegiate.",
|
||||
"filter": "Filtra...",
|
||||
"start": "Început",
|
||||
"stop": "Stop",
|
||||
"restart": "Repornire",
|
||||
"actionDone": "{{name}} actualizat",
|
||||
"actionFailed": "Acțiunea a eșuat",
|
||||
"signalSent": "Semnal trimis către PID {{pid}}",
|
||||
"killHint": "Clic: terminare (SIGTERM). Clic dreapta: închidere forțată (SIGKILL).",
|
||||
"working": "Lucru...",
|
||||
"update": "Actualizare",
|
||||
"upgradeAll": "Actualizați toate",
|
||||
"allUpToDate": "Totul este la zi",
|
||||
"save": "Salva",
|
||||
"command": "Comanda",
|
||||
"enabled": "Activat",
|
||||
"cronSaved": "Crontab actualizat",
|
||||
"clients": "Clienți",
|
||||
"dryRun": "Sesizare de uzură",
|
||||
"renew": "Reînnoi",
|
||||
"noAcmeClient": "Nu a fost găsit niciun client ACME (certbot sau acme.sh) pe această gazdă.",
|
||||
"addInputRule": "Adăugați regula INPUT",
|
||||
"firewallWarning": "Modificările sunt disponibile doar în timpul execuției până când sunt persistente. Aveți grijă să nu vă blocați accesul.",
|
||||
"ruleApplied": "Regula aplicată",
|
||||
"invalidPort": "Introduceți un port valid (1-65535)",
|
||||
"newUsername": "Nume de utilizator nou",
|
||||
"addUser": "Adăuga",
|
||||
"deleteUser": "Șterge utilizatorul",
|
||||
"noHealthChecks": "Nu au fost configurate încă verificări de stare.",
|
||||
"follow": "Urmărește",
|
||||
"noLogData": "Nicio ieșire de jurnal.",
|
||||
"tree": "Copac",
|
||||
"enableDisable": "Activare / dezactivare la pornire",
|
||||
"grantSudo": "Acordă sudo",
|
||||
"revokeSudo": "Revocați comanda sudo",
|
||||
"sslIssueCert": "Emite certificat",
|
||||
"sslExpired": "Expirat",
|
||||
"sslInDays": "în {{days}}d",
|
||||
"sslNeedDomain": "Introduceți cel puțin un domeniu",
|
||||
"sslIssued": "Certificat emis",
|
||||
"sslDomainsPlaceholder": "exemplu.com, www.exemplu.com",
|
||||
"sslHttpStandalone": "HTTP (independent)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "Furnizor DNS (de exemplu, Cloudflare)",
|
||||
"sslIssueHint": "Acreditările furnizorului DNS trebuie să fie deja configurate pe gazdă.",
|
||||
"sslRevoke": "Revocați certificatul",
|
||||
"sslRevoked": "Certificat revocat",
|
||||
"sslRevokeConfirm": "Revocați și eliminați certificatul „{{name}}”? Această acțiune nu poate fi anulată.",
|
||||
"healthRun": "Aleargă",
|
||||
"healthName": "Nume",
|
||||
"healthTarget": "Gazdă / adresă",
|
||||
"healthAddCheck": "Adăugați cec",
|
||||
"healthSaved": "Verificările de sănătate salvate",
|
||||
"healthMissingFields": "Fiecare verificare necesită un nume și o țintă",
|
||||
"logFile": "Fişier",
|
||||
"logUnit": "Unitate",
|
||||
"logCustomPath": "Cale personalizată sub /var/log (opțional)",
|
||||
"logGrep": "Linii de filtrare...",
|
||||
"firewallPersist": "Reguli persistente",
|
||||
"firewallPersisted": "Regulile firewall-ului au persistat",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard nu este instalat pe această gazdă",
|
||||
"wgNoInterfaces": "Nicio interfață WireGuard configurată",
|
||||
"wgInterfaceUp": "A menționa",
|
||||
"wgInterfaceDown": "Doborâți",
|
||||
"wgBringingUp": "Se afișează {{name}}...",
|
||||
"wgBringingDown": "Se coboară {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} este activ",
|
||||
"wgInterfaceDownDone": "{{name}} este nefuncțional",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Cheie publică",
|
||||
"wgEndpoint": "Punct final",
|
||||
"wgAllowedIPs": "IP-uri permise",
|
||||
"wgLastHandshake": "Ultima strângere de mână",
|
||||
"wgHandshakeNever": "Nu",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "Fără colegi"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Istoricul metricilor",
|
||||
"historySuffix": "Istorie",
|
||||
"cpuMemoryDisk": "CPU / Memorie / Disc",
|
||||
"network": "Reţea",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Nu sunt disponibile date istorice pentru acest interval de timp.",
|
||||
"custom": "Personalizat",
|
||||
"to": "la",
|
||||
"apply": "Aplică",
|
||||
"viewHistory": "Vizualizați istoricul"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Metrici ale gazdei",
|
||||
"metricsCollectionLabel": "Colecție de metrici",
|
||||
"enableMetricsLabel": "Activați metricile",
|
||||
"enableMetricsDesc": "Colectați valori pentru CPU, RAM, disc și alte valori de la această gazdă",
|
||||
"useGlobalMetrics": "Utilizați intervalul global",
|
||||
"useGlobalMetricsDesc": "Suprascriere cu intervalul de metrici la nivel de server",
|
||||
"metricsIntervalS": "Interval (s) de metrică",
|
||||
"metricsIntervalDesc2": "Secunde între instantaneele metricelor",
|
||||
"excludedMountsLabel": "Monturi excluse",
|
||||
"monitoredMountsLabel": "Căi monitorizate personalizate",
|
||||
"monitoredMountsDesc": "Adăugați căi care nu sunt expuse ca monturi separate, inclusiv căi Home Assistant montate prin bind, cum ar fi /config.",
|
||||
"monitoredMountPathPlaceholder": "Cale (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Etichetă (opțional)",
|
||||
"addMonitoredMount": "Adăugați o cale",
|
||||
"excludedMountsDesc": "Ascunde anumite puncte de montare sau tipuri de sisteme de fișiere (de exemplu, nfs, cifs) de utilizarea discului și de alertele pentru această gazdă.",
|
||||
"excludedMountsPlaceholder": "Calea de montare (/mnt/nas) sau tipul sistemului de fișiere (nfs, cifs)",
|
||||
"excludedMountsEmpty": "Nicio montare exclusă. Sunt afișate toate sistemele de fișiere detectate.",
|
||||
"addExcludedMount": "Adăuga",
|
||||
"visibleWidgets": "Widgeturi vizibile",
|
||||
"widgetsMovedToHostMetrics": "Cardurile sunt acum adăugate, aranjate și redimensionate direct în fila Indicatori gazdă. Deschideți Indicatori gazdă pentru această gazdă și utilizați Personalizare pentru a alege ce carduri sunt afișate.",
|
||||
"hostNotFound": "Gazdă negăsită",
|
||||
"loadingHost": "Se încarcă gazda..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrici și alerte",
|
||||
"feature_metrics_desc": "Urmărește procesorul, memoria și discul în timp și primește alerte când acestea se defectează."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Păstrarea istoricului metricilor",
|
||||
"metricsHistoryRetentionRange": "1 până la 90 de zile",
|
||||
"days": "zile"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Nu sunt disponibile date",
|
||||
"showCurrentValue": "Afișează valoarea curentă",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Gamă",
|
||||
"host": "Gazdă",
|
||||
"loading": "Încărcare...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disc",
|
||||
"metricHostname": "Nume gazdă",
|
||||
"metricKernel": "Nucleu",
|
||||
"metricMemory": "Memorie",
|
||||
"metricNetwork": "Reţea",
|
||||
"metricOs": "Sistem de operare",
|
||||
"metricProcesses": "Procese",
|
||||
"metricProcessesRunning": "funcţionare",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Timp de funcționare",
|
||||
"metricsNotAvailable": "Indicatorii nu sunt disponibili",
|
||||
"noHostSelected": "Nicio gazdă selectată",
|
||||
"selectHost": "Selectați o gazdă..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Statistici live indisponibile"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disc"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Conectat"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Utilizați metricile gazdei",
|
||||
"description": "Vizualizați metricile gazdei și utilizați managerii de gazdă (servicii, pachete, firewall și altele) pe gazdele pe care le puteți accesa."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Interval de metrici (secunde)",
|
||||
"description": "Cât de des este eșantionată o gazdă vizualizată, cu excepția cazului în care gazda își stabilește propriul interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "Păstrarea istoricului (zile)",
|
||||
"description": "Cât timp sunt păstrate mostrele de metrici pentru graficele istorice."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrici activate pentru gazdele noi",
|
||||
"description": "Dacă noile gazde încep cu colectarea de metrici activată."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Unitate de temperatură",
|
||||
"description": "Cum sunt afișate temperaturile în Metricile gazdei.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Colectați valori metrice"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Interval de metrici (secunde)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Widgeturi activate"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Monturi excluse"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Căi monitorizate"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Метрики хоста"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "ЦП",
|
||||
"memory": "Память",
|
||||
"disk": "Диск",
|
||||
"network": "Сеть",
|
||||
"uptime": "Время работы",
|
||||
"processes": "Процессы",
|
||||
"available": "Доступно",
|
||||
"free": "Свободно",
|
||||
"connecting": "Подключение...",
|
||||
"connectionFailed": "Не удалось подключиться к серверу",
|
||||
"naCpus": "Н/Д ЦП",
|
||||
"cpuCores_one": "{{count}} ядро",
|
||||
"cpuCores_other": "{{count}} ядер",
|
||||
"cpuUsage": "Использование ЦП",
|
||||
"memoryUsage": "Использование памяти",
|
||||
"diskUsage": "Использование диска",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Температура",
|
||||
"highestTemperature": "Максимальная температура",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Не удалось получить конфигурацию хоста",
|
||||
"serverOffline": "Сервер недоступен",
|
||||
"cannotFetchMetrics": "Не удалось загрузить метрики с недоступного сервера",
|
||||
"totpFailed": "Проверка TOTP не пройдена",
|
||||
"noneAuthNotSupported": "Host Metrics не поддерживает тип аутентификации 'none'.",
|
||||
"noHostSelected": "Хост не выбран",
|
||||
"load": "Нагрузка",
|
||||
"systemInfo": "Информация о системе",
|
||||
"hostname": "Имя хоста",
|
||||
"operatingSystem": "Операционная система",
|
||||
"kernel": "Ядро",
|
||||
"seconds": "секунды",
|
||||
"networkInterfaces": "Сетевые интерфейсы",
|
||||
"noInterfacesFound": "Сетевые интерфейсы не найдены",
|
||||
"noProcessesFound": "Процессы не найдены",
|
||||
"processesTotal": "всего",
|
||||
"processesRunning": "выполняется",
|
||||
"loginStats": "Статистика входов SSH",
|
||||
"noRecentLoginData": "Нет данных о недавних входах",
|
||||
"ports": {
|
||||
"title": "Прослушиваемые порты",
|
||||
"protocol": "Протокол",
|
||||
"port": "Порт",
|
||||
"address": "Адрес",
|
||||
"process": "Процесс",
|
||||
"search": "Поиск портов...",
|
||||
"allProtocols": "Все",
|
||||
"noData": "Нет данных о прослушиваемых портах"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Брандмауэр",
|
||||
"inactive": "Неактивен",
|
||||
"policy": "Политика",
|
||||
"rules": "правила",
|
||||
"noRules": "Нет правил в этой цепочке",
|
||||
"noData": "Нет данных о брандмауэре",
|
||||
"action": "Действие",
|
||||
"protocol": "Протокол",
|
||||
"port": "Порт",
|
||||
"source": "Источник",
|
||||
"anywhere": "Любое",
|
||||
"chains": "цепочки"
|
||||
},
|
||||
"loadAvg": "Средняя нагрузка",
|
||||
"swap": "Своп",
|
||||
"architecture": "Архитектура",
|
||||
"refresh": "Обновить",
|
||||
"retry": "Повторить",
|
||||
"customize": "Настроить макет",
|
||||
"reset": "Сбросить",
|
||||
"tabLive": "Онлайн",
|
||||
"editModeInstructions": "Перетаскивайте карточки для изменения порядка, перетаскивайте нижний край для изменения размера и используйте кнопку ширины, чтобы изменить занимаемое пространство. Добавляйте или удаляйте карточки ниже.",
|
||||
"managers": {
|
||||
"services": "Службы",
|
||||
"processInspector": "Инспектор процессов",
|
||||
"logViewer": "Просмотр журналов",
|
||||
"cron": "Задания Cron",
|
||||
"packages": "Пакеты",
|
||||
"ssl": "SSL-сертификаты",
|
||||
"firewall": "Брандмауэр",
|
||||
"users": "Пользователи и разрешения",
|
||||
"healthCheck": "Проверки состояния",
|
||||
"diskBreakdown": "Разбивка дисков",
|
||||
"systemdTimers": "Таймеры",
|
||||
"topMemory": "Топ по памяти",
|
||||
"noData": "Нет данных",
|
||||
"sudoHint": "Установите пароль sudo для этого хоста в редакторе хостов, чтобы включить привилегированные действия.",
|
||||
"filter": "Фильтр...",
|
||||
"start": "Запустить",
|
||||
"stop": "Остановить",
|
||||
"restart": "Перезапустить",
|
||||
"actionDone": "{{name}} обновлено",
|
||||
"actionFailed": "Действие не удалось",
|
||||
"signalSent": "Сигнал отправлен PID {{pid}}",
|
||||
"killHint": "Нажмите: завершить (SIGTERM). Правый клик: принудительно завершить (SIGKILL).",
|
||||
"working": "Выполняется...",
|
||||
"update": "Обновить",
|
||||
"upgradeAll": "Обновить всё",
|
||||
"allUpToDate": "Всё актуально",
|
||||
"save": "Сохранить",
|
||||
"command": "Команда",
|
||||
"enabled": "Включено",
|
||||
"cronSaved": "Crontab обновлён",
|
||||
"clients": "Клиенты",
|
||||
"dryRun": "Пробный запуск",
|
||||
"renew": "Продлить",
|
||||
"noAcmeClient": "На этом хосте не найден ACME-клиент (certbot или acme.sh).",
|
||||
"addInputRule": "Добавить правило INPUT",
|
||||
"firewallWarning": "Изменения действуют только до перезагрузки, пока не сохранены. Будьте осторожны, чтобы не заблокировать себе доступ.",
|
||||
"ruleApplied": "Правило применено",
|
||||
"invalidPort": "Введите допустимый порт (1–65535)",
|
||||
"newUsername": "Новое имя пользователя",
|
||||
"addUser": "Добавить",
|
||||
"deleteUser": "Удалить пользователя",
|
||||
"noHealthChecks": "Проверки работоспособности ещё не настроены.",
|
||||
"follow": "Следить",
|
||||
"noLogData": "Нет данных журнала.",
|
||||
"tree": "Дерево",
|
||||
"enableDisable": "Включить/отключить при загрузке",
|
||||
"grantSudo": "Предоставить sudo",
|
||||
"revokeSudo": "Отозвать sudo",
|
||||
"sslIssueCert": "Выпустить сертификат",
|
||||
"sslExpired": "Истёк",
|
||||
"sslInDays": "через {{days}}д",
|
||||
"sslNeedDomain": "Введите хотя бы один домен",
|
||||
"sslIssued": "Сертификат выпущен",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (автономный)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS-провайдер (например, cloudflare)",
|
||||
"sslIssueHint": "Учетные данные DNS-провайдера должны быть уже настроены на хосте.",
|
||||
"sslRevoke": "Отозвать сертификат",
|
||||
"sslRevoked": "Сертификат отозван",
|
||||
"sslRevokeConfirm": "Отозвать и удалить сертификат \"{{name}}\"? Это действие нельзя отменить.",
|
||||
"healthRun": "Запустить",
|
||||
"healthName": "Название",
|
||||
"healthTarget": "Хост / адрес",
|
||||
"healthAddCheck": "Добавить проверку",
|
||||
"healthSaved": "Проверки работоспособности сохранены",
|
||||
"healthMissingFields": "Для каждой проверки нужны название и цель",
|
||||
"logFile": "Файл",
|
||||
"logUnit": "Юнит",
|
||||
"logCustomPath": "Произвольный путь в /var/log (необязательно)",
|
||||
"logGrep": "Фильтр строк...",
|
||||
"firewallPersist": "Сохранить правила",
|
||||
"firewallPersisted": "Правила брандмауэра сохранены",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard не установлен на этом хосте",
|
||||
"wgNoInterfaces": "Не настроено ни одного интерфейса WireGuard",
|
||||
"wgInterfaceUp": "Поднять",
|
||||
"wgInterfaceDown": "Отключить",
|
||||
"wgBringingUp": "Поднимается {{name}}...",
|
||||
"wgBringingDown": "Отключается {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} поднят",
|
||||
"wgInterfaceDownDone": "{{name}} отключен",
|
||||
"wgListenPort": "Порт",
|
||||
"wgPublicKey": "Открытый ключ",
|
||||
"wgEndpoint": "Конечная точка",
|
||||
"wgAllowedIPs": "Разрешенные IP-адреса",
|
||||
"wgLastHandshake": "Последнее рукопожатие",
|
||||
"wgHandshakeNever": "Никогда",
|
||||
"wgTransfer": "Передано",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "История метрик",
|
||||
"historySuffix": "История",
|
||||
"cpuMemoryDisk": "ЦП / Память / Диск",
|
||||
"network": "Сеть",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Нет исторических данных за этот период.",
|
||||
"custom": "Пользовательский",
|
||||
"to": "по",
|
||||
"apply": "Применить",
|
||||
"viewHistory": "Просмотреть историю"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Метрики хоста",
|
||||
"metricsCollectionLabel": "Сбор метрик",
|
||||
"enableMetricsLabel": "Включить метрики",
|
||||
"enableMetricsDesc": "Собирать метрики ЦП, ОЗУ, диска и другие с этого хоста",
|
||||
"useGlobalMetrics": "Использовать глобальный интервал",
|
||||
"useGlobalMetricsDesc": "Переопределить глобальным интервалом сбора метрик сервера",
|
||||
"metricsIntervalS": "Интервал сбора метрик (с)",
|
||||
"metricsIntervalDesc2": "Секунд между снимками метрик",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Метка (необязательно)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Добавить",
|
||||
"visibleWidgets": "Видимые виджеты",
|
||||
"widgetsMovedToHostMetrics": "Карточки теперь добавляются, упорядочиваются и изменяются в размере непосредственно на вкладке «Метрики хоста». Откройте метрики для этого хоста и используйте «Настроить», чтобы выбрать отображаемые карточки.",
|
||||
"hostNotFound": "Хост не найден",
|
||||
"loadingHost": "Загрузка хоста..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Срок хранения метрик",
|
||||
"metricsHistoryRetentionRange": "от 1 до 90 дней",
|
||||
"days": "дней"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Нет данных",
|
||||
"showCurrentValue": "Показать текущее значение",
|
||||
"chartMetric": "Метрика",
|
||||
"metricRange": "Диапазон",
|
||||
"host": "Хост",
|
||||
"loading": "Загрузка...",
|
||||
"metricCpu": "ЦП",
|
||||
"metricDisk": "Диск",
|
||||
"metricHostname": "Имя хоста",
|
||||
"metricKernel": "Ядро",
|
||||
"metricMemory": "Память",
|
||||
"metricNetwork": "Сеть",
|
||||
"metricOs": "ОС",
|
||||
"metricProcesses": "Процессы",
|
||||
"metricProcessesRunning": "запущено",
|
||||
"metricProcessesTotal": "всего",
|
||||
"metricUptime": "Время работы",
|
||||
"metricsNotAvailable": "Метрики недоступны",
|
||||
"noHostSelected": "Хост не выбран",
|
||||
"selectHost": "Выберите хост..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Диск"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Подключено"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Host Metrics"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"network": "Network",
|
||||
"uptime": "Uptime",
|
||||
"processes": "Processes",
|
||||
"available": "Available",
|
||||
"free": "Free",
|
||||
"connecting": "Connecting...",
|
||||
"connectionFailed": "Failed to connect to server",
|
||||
"naCpus": "N/A CPU(s)",
|
||||
"cpuCores_one": "{{count}} Core",
|
||||
"cpuCores_other": "{{count}} Cores",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"diskUsage": "Disk Usage",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Temperature",
|
||||
"highestTemperature": "Highest temperature",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Failed to fetch host configuration",
|
||||
"serverOffline": "Server Offline",
|
||||
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||
"totpFailed": "TOTP verification failed",
|
||||
"noneAuthNotSupported": "Host Metrics does not support 'none' authentication type.",
|
||||
"noHostSelected": "No host selected",
|
||||
"load": "Load",
|
||||
"systemInfo": "System Information",
|
||||
"hostname": "Hostname",
|
||||
"operatingSystem": "Operating System",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "seconds",
|
||||
"networkInterfaces": "Network Interfaces",
|
||||
"noInterfacesFound": "No network interfaces found",
|
||||
"noProcessesFound": "No processes found",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "running",
|
||||
"loginStats": "SSH Login Statistics",
|
||||
"noRecentLoginData": "No recent login data",
|
||||
"ports": {
|
||||
"title": "Listening Ports",
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"address": "Address",
|
||||
"process": "Process",
|
||||
"search": "Search ports...",
|
||||
"allProtocols": "All",
|
||||
"noData": "No listening ports data"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inactive",
|
||||
"policy": "Policy",
|
||||
"rules": "rules",
|
||||
"noRules": "No rules in this chain",
|
||||
"noData": "No firewall data available",
|
||||
"action": "Action",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Source",
|
||||
"anywhere": "Anywhere",
|
||||
"chains": "chains"
|
||||
},
|
||||
"loadAvg": "Load Avg",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architecture",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"customize": "Customize layout",
|
||||
"reset": "Reset",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Drag cards to rearrange, drag the bottom edge to resize, and use the width button to change a card's span. Add or remove cards below.",
|
||||
"managers": {
|
||||
"services": "Services",
|
||||
"processInspector": "Process Inspector",
|
||||
"logViewer": "Log Viewer",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Packages",
|
||||
"ssl": "SSL Certificates",
|
||||
"firewall": "Firewall",
|
||||
"users": "Users & Permissions",
|
||||
"healthCheck": "Health Checks",
|
||||
"diskBreakdown": "Disk Breakdown",
|
||||
"systemdTimers": "Timers",
|
||||
"topMemory": "Top by Memory",
|
||||
"noData": "No data",
|
||||
"sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.",
|
||||
"filter": "Filter...",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"actionDone": "{{name}} updated",
|
||||
"actionFailed": "Action failed",
|
||||
"signalSent": "Signal sent to PID {{pid}}",
|
||||
"killHint": "Click: terminate (SIGTERM). Right-click: force kill (SIGKILL).",
|
||||
"working": "Working...",
|
||||
"update": "Update",
|
||||
"upgradeAll": "Upgrade all",
|
||||
"allUpToDate": "Everything is up to date",
|
||||
"save": "Save",
|
||||
"command": "Command",
|
||||
"enabled": "Enabled",
|
||||
"cronSaved": "Crontab updated",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Dry run",
|
||||
"renew": "Renew",
|
||||
"noAcmeClient": "No ACME client (certbot or acme.sh) found on this host.",
|
||||
"addInputRule": "Add INPUT rule",
|
||||
"firewallWarning": "Changes are runtime-only until persisted. Be careful not to lock yourself out.",
|
||||
"ruleApplied": "Rule applied",
|
||||
"invalidPort": "Enter a valid port (1-65535)",
|
||||
"newUsername": "New username",
|
||||
"addUser": "Add",
|
||||
"deleteUser": "Delete user",
|
||||
"noHealthChecks": "No health checks configured yet.",
|
||||
"follow": "Follow",
|
||||
"noLogData": "No log output.",
|
||||
"tree": "Tree",
|
||||
"enableDisable": "Enable / disable at boot",
|
||||
"grantSudo": "Grant sudo",
|
||||
"revokeSudo": "Revoke sudo",
|
||||
"sslIssueCert": "Issue certificate",
|
||||
"sslExpired": "Expired",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Enter at least one domain",
|
||||
"sslIssued": "Certificate issued",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (standalone)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS provider (e.g. cloudflare)",
|
||||
"sslIssueHint": "DNS provider credentials must already be configured on the host.",
|
||||
"sslRevoke": "Revoke certificate",
|
||||
"sslRevoked": "Certificate revoked",
|
||||
"sslRevokeConfirm": "Revoke and remove the certificate \"{{name}}\"? This cannot be undone.",
|
||||
"healthRun": "Run",
|
||||
"healthName": "Name",
|
||||
"healthTarget": "Host / address",
|
||||
"healthAddCheck": "Add check",
|
||||
"healthSaved": "Health checks saved",
|
||||
"healthMissingFields": "Each check needs a name and target",
|
||||
"logFile": "File",
|
||||
"logUnit": "Unit",
|
||||
"logCustomPath": "Custom path under /var/log (optional)",
|
||||
"logGrep": "Filter lines...",
|
||||
"firewallPersist": "Persist rules",
|
||||
"firewallPersisted": "Firewall rules persisted",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard is not installed on this host",
|
||||
"wgNoInterfaces": "No WireGuard interfaces configured",
|
||||
"wgInterfaceUp": "Bring up",
|
||||
"wgInterfaceDown": "Bring down",
|
||||
"wgBringingUp": "Bringing {{name}} up...",
|
||||
"wgBringingDown": "Bringing {{name}} down...",
|
||||
"wgInterfaceUpDone": "{{name}} is up",
|
||||
"wgInterfaceDownDone": "{{name}} is down",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Public key",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "Allowed IPs",
|
||||
"wgLastHandshake": "Last handshake",
|
||||
"wgHandshakeNever": "Never",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrics History",
|
||||
"historySuffix": "History",
|
||||
"cpuMemoryDisk": "CPU / Memory / Disk",
|
||||
"network": "Network",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "No history data available for this time range.",
|
||||
"custom": "Custom",
|
||||
"to": "to",
|
||||
"apply": "Apply",
|
||||
"viewHistory": "View History"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Host Metrics",
|
||||
"metricsCollectionLabel": "Metrics Collection",
|
||||
"enableMetricsLabel": "Enable Metrics",
|
||||
"enableMetricsDesc": "Collect CPU, RAM, disk, and other metrics from this host",
|
||||
"useGlobalMetrics": "Use Global Interval",
|
||||
"useGlobalMetricsDesc": "Override with the server-wide metrics interval",
|
||||
"metricsIntervalS": "Metrics Interval (s)",
|
||||
"metricsIntervalDesc2": "Seconds between metric snapshots",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Label (optional)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Add",
|
||||
"visibleWidgets": "Visible Widgets",
|
||||
"widgetsMovedToHostMetrics": "Cards are now added, arranged, and resized directly in the Host Metrics tab. Open Host Metrics for this host and use Customize to choose which cards are shown.",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrics History Retention",
|
||||
"metricsHistoryRetentionRange": "1 to 90 days",
|
||||
"days": "days"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "No data available",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range",
|
||||
"host": "Host",
|
||||
"loading": "Loading...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Hostname",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memory",
|
||||
"metricNetwork": "Network",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processes",
|
||||
"metricProcessesRunning": "running",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Uptime",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"noHostSelected": "No host selected",
|
||||
"selectHost": "Select a host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connected"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Host Metrics"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"network": "Network",
|
||||
"uptime": "Uptime",
|
||||
"processes": "Processes",
|
||||
"available": "Available",
|
||||
"free": "Free",
|
||||
"connecting": "Connecting...",
|
||||
"connectionFailed": "Failed to connect to server",
|
||||
"naCpus": "N/A CPU(s)",
|
||||
"cpuCores_one": "{{count}} Core",
|
||||
"cpuCores_other": "{{count}} Cores",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"diskUsage": "Disk Usage",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Temperature",
|
||||
"highestTemperature": "Highest temperature",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Failed to fetch host configuration",
|
||||
"serverOffline": "Server Offline",
|
||||
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||
"totpFailed": "TOTP verification failed",
|
||||
"noneAuthNotSupported": "Host Metrics does not support 'none' authentication type.",
|
||||
"noHostSelected": "No host selected",
|
||||
"load": "Load",
|
||||
"systemInfo": "System Information",
|
||||
"hostname": "Hostname",
|
||||
"operatingSystem": "Operating System",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "seconds",
|
||||
"networkInterfaces": "Network Interfaces",
|
||||
"noInterfacesFound": "No network interfaces found",
|
||||
"noProcessesFound": "No processes found",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "running",
|
||||
"loginStats": "SSH Login Statistics",
|
||||
"noRecentLoginData": "No recent login data",
|
||||
"ports": {
|
||||
"title": "Listening Ports",
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"address": "Address",
|
||||
"process": "Process",
|
||||
"search": "Search ports...",
|
||||
"allProtocols": "All",
|
||||
"noData": "No listening ports data"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inactive",
|
||||
"policy": "Policy",
|
||||
"rules": "rules",
|
||||
"noRules": "No rules in this chain",
|
||||
"noData": "No firewall data available",
|
||||
"action": "Action",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Source",
|
||||
"anywhere": "Anywhere",
|
||||
"chains": "chains"
|
||||
},
|
||||
"loadAvg": "Load Avg",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architecture",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"customize": "Customize layout",
|
||||
"reset": "Reset",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Drag cards to rearrange, drag the bottom edge to resize, and use the width button to change a card's span. Add or remove cards below.",
|
||||
"managers": {
|
||||
"services": "Services",
|
||||
"processInspector": "Process Inspector",
|
||||
"logViewer": "Log Viewer",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Packages",
|
||||
"ssl": "SSL Certificates",
|
||||
"firewall": "Firewall",
|
||||
"users": "Users & Permissions",
|
||||
"healthCheck": "Health Checks",
|
||||
"diskBreakdown": "Disk Breakdown",
|
||||
"systemdTimers": "Timers",
|
||||
"topMemory": "Top by Memory",
|
||||
"noData": "No data",
|
||||
"sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.",
|
||||
"filter": "Filter...",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"actionDone": "{{name}} updated",
|
||||
"actionFailed": "Action failed",
|
||||
"signalSent": "Signal sent to PID {{pid}}",
|
||||
"killHint": "Click: terminate (SIGTERM). Right-click: force kill (SIGKILL).",
|
||||
"working": "Working...",
|
||||
"update": "Update",
|
||||
"upgradeAll": "Upgrade all",
|
||||
"allUpToDate": "Everything is up to date",
|
||||
"save": "Save",
|
||||
"command": "Command",
|
||||
"enabled": "Enabled",
|
||||
"cronSaved": "Crontab updated",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Dry run",
|
||||
"renew": "Renew",
|
||||
"noAcmeClient": "No ACME client (certbot or acme.sh) found on this host.",
|
||||
"addInputRule": "Add INPUT rule",
|
||||
"firewallWarning": "Changes are runtime-only until persisted. Be careful not to lock yourself out.",
|
||||
"ruleApplied": "Rule applied",
|
||||
"invalidPort": "Enter a valid port (1-65535)",
|
||||
"newUsername": "New username",
|
||||
"addUser": "Add",
|
||||
"deleteUser": "Delete user",
|
||||
"noHealthChecks": "No health checks configured yet.",
|
||||
"follow": "Follow",
|
||||
"noLogData": "No log output.",
|
||||
"tree": "Tree",
|
||||
"enableDisable": "Enable / disable at boot",
|
||||
"grantSudo": "Grant sudo",
|
||||
"revokeSudo": "Revoke sudo",
|
||||
"sslIssueCert": "Issue certificate",
|
||||
"sslExpired": "Expired",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Enter at least one domain",
|
||||
"sslIssued": "Certificate issued",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (standalone)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS provider (e.g. cloudflare)",
|
||||
"sslIssueHint": "DNS provider credentials must already be configured on the host.",
|
||||
"sslRevoke": "Revoke certificate",
|
||||
"sslRevoked": "Certificate revoked",
|
||||
"sslRevokeConfirm": "Revoke and remove the certificate \"{{name}}\"? This cannot be undone.",
|
||||
"healthRun": "Run",
|
||||
"healthName": "Name",
|
||||
"healthTarget": "Host / address",
|
||||
"healthAddCheck": "Add check",
|
||||
"healthSaved": "Health checks saved",
|
||||
"healthMissingFields": "Each check needs a name and target",
|
||||
"logFile": "File",
|
||||
"logUnit": "Unit",
|
||||
"logCustomPath": "Custom path under /var/log (optional)",
|
||||
"logGrep": "Filter lines...",
|
||||
"firewallPersist": "Persist rules",
|
||||
"firewallPersisted": "Firewall rules persisted",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard is not installed on this host",
|
||||
"wgNoInterfaces": "No WireGuard interfaces configured",
|
||||
"wgInterfaceUp": "Bring up",
|
||||
"wgInterfaceDown": "Bring down",
|
||||
"wgBringingUp": "Bringing {{name}} up...",
|
||||
"wgBringingDown": "Bringing {{name}} down...",
|
||||
"wgInterfaceUpDone": "{{name}} is up",
|
||||
"wgInterfaceDownDone": "{{name}} is down",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Public key",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "Allowed IPs",
|
||||
"wgLastHandshake": "Last handshake",
|
||||
"wgHandshakeNever": "Never",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrics History",
|
||||
"historySuffix": "History",
|
||||
"cpuMemoryDisk": "CPU / Memory / Disk",
|
||||
"network": "Network",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "No history data available for this time range.",
|
||||
"custom": "Custom",
|
||||
"to": "to",
|
||||
"apply": "Apply",
|
||||
"viewHistory": "View History"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Host Metrics",
|
||||
"metricsCollectionLabel": "Metrics Collection",
|
||||
"enableMetricsLabel": "Enable Metrics",
|
||||
"enableMetricsDesc": "Collect CPU, RAM, disk, and other metrics from this host",
|
||||
"useGlobalMetrics": "Use Global Interval",
|
||||
"useGlobalMetricsDesc": "Override with the server-wide metrics interval",
|
||||
"metricsIntervalS": "Metrics Interval (s)",
|
||||
"metricsIntervalDesc2": "Seconds between metric snapshots",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Label (optional)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Add",
|
||||
"visibleWidgets": "Visible Widgets",
|
||||
"widgetsMovedToHostMetrics": "Cards are now added, arranged, and resized directly in the Host Metrics tab. Open Host Metrics for this host and use Customize to choose which cards are shown.",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrics History Retention",
|
||||
"metricsHistoryRetentionRange": "1 to 90 days",
|
||||
"days": "days"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "No data available",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range",
|
||||
"host": "Host",
|
||||
"loading": "Loading...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Hostname",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memory",
|
||||
"metricNetwork": "Network",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processes",
|
||||
"metricProcessesRunning": "running",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Uptime",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"noHostSelected": "No host selected",
|
||||
"selectHost": "Select a host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connected"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Host Metrics"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"network": "Network",
|
||||
"uptime": "Uptime",
|
||||
"processes": "Processes",
|
||||
"available": "Available",
|
||||
"free": "Free",
|
||||
"connecting": "Connecting...",
|
||||
"connectionFailed": "Failed to connect to server",
|
||||
"naCpus": "N/A CPU(s)",
|
||||
"cpuCores_one": "{{count}} Core",
|
||||
"cpuCores_other": "{{count}} Cores",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"diskUsage": "Disk Usage",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Temperature",
|
||||
"highestTemperature": "Highest temperature",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Failed to fetch host configuration",
|
||||
"serverOffline": "Server Offline",
|
||||
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||
"totpFailed": "TOTP verification failed",
|
||||
"noneAuthNotSupported": "Host Metrics does not support 'none' authentication type.",
|
||||
"noHostSelected": "No host selected",
|
||||
"load": "Load",
|
||||
"systemInfo": "System Information",
|
||||
"hostname": "Hostname",
|
||||
"operatingSystem": "Operating System",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "seconds",
|
||||
"networkInterfaces": "Network Interfaces",
|
||||
"noInterfacesFound": "No network interfaces found",
|
||||
"noProcessesFound": "No processes found",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "running",
|
||||
"loginStats": "SSH Login Statistics",
|
||||
"noRecentLoginData": "No recent login data",
|
||||
"ports": {
|
||||
"title": "Listening Ports",
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"address": "Address",
|
||||
"process": "Process",
|
||||
"search": "Search ports...",
|
||||
"allProtocols": "All",
|
||||
"noData": "No listening ports data"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inactive",
|
||||
"policy": "Policy",
|
||||
"rules": "rules",
|
||||
"noRules": "No rules in this chain",
|
||||
"noData": "No firewall data available",
|
||||
"action": "Action",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Source",
|
||||
"anywhere": "Anywhere",
|
||||
"chains": "chains"
|
||||
},
|
||||
"loadAvg": "Load Avg",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architecture",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"customize": "Customize layout",
|
||||
"reset": "Reset",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Drag cards to rearrange, drag the bottom edge to resize, and use the width button to change a card's span. Add or remove cards below.",
|
||||
"managers": {
|
||||
"services": "Services",
|
||||
"processInspector": "Process Inspector",
|
||||
"logViewer": "Log Viewer",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Packages",
|
||||
"ssl": "SSL Certificates",
|
||||
"firewall": "Firewall",
|
||||
"users": "Users & Permissions",
|
||||
"healthCheck": "Health Checks",
|
||||
"diskBreakdown": "Disk Breakdown",
|
||||
"systemdTimers": "Timers",
|
||||
"topMemory": "Top by Memory",
|
||||
"noData": "No data",
|
||||
"sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.",
|
||||
"filter": "Filter...",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"actionDone": "{{name}} updated",
|
||||
"actionFailed": "Action failed",
|
||||
"signalSent": "Signal sent to PID {{pid}}",
|
||||
"killHint": "Click: terminate (SIGTERM). Right-click: force kill (SIGKILL).",
|
||||
"working": "Working...",
|
||||
"update": "Update",
|
||||
"upgradeAll": "Upgrade all",
|
||||
"allUpToDate": "Everything is up to date",
|
||||
"save": "Save",
|
||||
"command": "Command",
|
||||
"enabled": "Enabled",
|
||||
"cronSaved": "Crontab updated",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Dry run",
|
||||
"renew": "Renew",
|
||||
"noAcmeClient": "No ACME client (certbot or acme.sh) found on this host.",
|
||||
"addInputRule": "Add INPUT rule",
|
||||
"firewallWarning": "Changes are runtime-only until persisted. Be careful not to lock yourself out.",
|
||||
"ruleApplied": "Rule applied",
|
||||
"invalidPort": "Enter a valid port (1-65535)",
|
||||
"newUsername": "New username",
|
||||
"addUser": "Add",
|
||||
"deleteUser": "Delete user",
|
||||
"noHealthChecks": "No health checks configured yet.",
|
||||
"follow": "Follow",
|
||||
"noLogData": "No log output.",
|
||||
"tree": "Tree",
|
||||
"enableDisable": "Enable / disable at boot",
|
||||
"grantSudo": "Grant sudo",
|
||||
"revokeSudo": "Revoke sudo",
|
||||
"sslIssueCert": "Issue certificate",
|
||||
"sslExpired": "Expired",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Enter at least one domain",
|
||||
"sslIssued": "Certificate issued",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (standalone)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS provider (e.g. cloudflare)",
|
||||
"sslIssueHint": "DNS provider credentials must already be configured on the host.",
|
||||
"sslRevoke": "Revoke certificate",
|
||||
"sslRevoked": "Certificate revoked",
|
||||
"sslRevokeConfirm": "Revoke and remove the certificate \"{{name}}\"? This cannot be undone.",
|
||||
"healthRun": "Run",
|
||||
"healthName": "Name",
|
||||
"healthTarget": "Host / address",
|
||||
"healthAddCheck": "Add check",
|
||||
"healthSaved": "Health checks saved",
|
||||
"healthMissingFields": "Each check needs a name and target",
|
||||
"logFile": "File",
|
||||
"logUnit": "Unit",
|
||||
"logCustomPath": "Custom path under /var/log (optional)",
|
||||
"logGrep": "Filter lines...",
|
||||
"firewallPersist": "Persist rules",
|
||||
"firewallPersisted": "Firewall rules persisted",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard is not installed on this host",
|
||||
"wgNoInterfaces": "No WireGuard interfaces configured",
|
||||
"wgInterfaceUp": "Bring up",
|
||||
"wgInterfaceDown": "Bring down",
|
||||
"wgBringingUp": "Bringing {{name}} up...",
|
||||
"wgBringingDown": "Bringing {{name}} down...",
|
||||
"wgInterfaceUpDone": "{{name}} is up",
|
||||
"wgInterfaceDownDone": "{{name}} is down",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Public key",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "Allowed IPs",
|
||||
"wgLastHandshake": "Last handshake",
|
||||
"wgHandshakeNever": "Never",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrics History",
|
||||
"historySuffix": "History",
|
||||
"cpuMemoryDisk": "CPU / Memory / Disk",
|
||||
"network": "Network",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "No history data available for this time range.",
|
||||
"custom": "Custom",
|
||||
"to": "to",
|
||||
"apply": "Apply",
|
||||
"viewHistory": "View History"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Host Metrics",
|
||||
"metricsCollectionLabel": "Metrics Collection",
|
||||
"enableMetricsLabel": "Enable Metrics",
|
||||
"enableMetricsDesc": "Collect CPU, RAM, disk, and other metrics from this host",
|
||||
"useGlobalMetrics": "Use Global Interval",
|
||||
"useGlobalMetricsDesc": "Override with the server-wide metrics interval",
|
||||
"metricsIntervalS": "Metrics Interval (s)",
|
||||
"metricsIntervalDesc2": "Seconds between metric snapshots",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Label (optional)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Add",
|
||||
"visibleWidgets": "Visible Widgets",
|
||||
"widgetsMovedToHostMetrics": "Cards are now added, arranged, and resized directly in the Host Metrics tab. Open Host Metrics for this host and use Customize to choose which cards are shown.",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrics History Retention",
|
||||
"metricsHistoryRetentionRange": "1 to 90 days",
|
||||
"days": "days"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "No data available",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range",
|
||||
"host": "Host",
|
||||
"loading": "Loading...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Hostname",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memory",
|
||||
"metricNetwork": "Network",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processes",
|
||||
"metricProcessesRunning": "running",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Uptime",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"noHostSelected": "No host selected",
|
||||
"selectHost": "Select a host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connected"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Sunucu Metrikleri"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Bellek",
|
||||
"disk": "Disk",
|
||||
"network": "Ağ",
|
||||
"uptime": "Çalışma Süresi",
|
||||
"processes": "İşlemler",
|
||||
"available": "Kullanılabilir",
|
||||
"free": "Boş",
|
||||
"connecting": "Bağlanıyor...",
|
||||
"connectionFailed": "Sunucuya bağlanılamadı",
|
||||
"naCpus": "CPU yok",
|
||||
"cpuCores_one": "{{count}} Çekirdek",
|
||||
"cpuCores_other": "{{count}} Çekirdek",
|
||||
"cpuUsage": "CPU Kullanımı",
|
||||
"memoryUsage": "Bellek Kullanımı",
|
||||
"diskUsage": "Disk Kullanımı",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Sıcaklık",
|
||||
"highestTemperature": "En yüksek sıcaklık",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Host yapılandırması alınamadı",
|
||||
"serverOffline": "Sunucu Çevrimdışı",
|
||||
"cannotFetchMetrics": "Çevrimdışı sunucudan ölçümler alınamaz",
|
||||
"totpFailed": "TOTP doğrulaması başarısız",
|
||||
"noneAuthNotSupported": "Sunucu Metrikleri 'none' kimlik doğrulama türünü desteklemiyor.",
|
||||
"noHostSelected": "Sunucu seçilmedi",
|
||||
"load": "Yük",
|
||||
"systemInfo": "Sistem Bilgisi",
|
||||
"hostname": "Sunucu Adı",
|
||||
"operatingSystem": "İşletim Sistemi",
|
||||
"kernel": "Çekirdek",
|
||||
"seconds": "saniye",
|
||||
"networkInterfaces": "Ağ Arayüzleri",
|
||||
"noInterfacesFound": "Ağ arayüzleri bulunamadı",
|
||||
"noProcessesFound": "Hiç işlem bulunamadı",
|
||||
"processesTotal": "toplam",
|
||||
"processesRunning": "çalışan",
|
||||
"loginStats": "SSH Giriş İstatistikleri",
|
||||
"noRecentLoginData": "Son giriş verisi yok",
|
||||
"ports": {
|
||||
"title": "Dinlenen Portlar",
|
||||
"protocol": "Protokol",
|
||||
"port": "Port",
|
||||
"address": "Adres",
|
||||
"process": "İşlem",
|
||||
"search": "Portları ara...",
|
||||
"allProtocols": "Tümü",
|
||||
"noData": "Dinlenen port verisi yok"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Güvenlik Duvarı",
|
||||
"inactive": "Etkin Değil",
|
||||
"policy": "Politika",
|
||||
"rules": "kurallar",
|
||||
"noRules": "Bu zincirde kural yok",
|
||||
"noData": "Güvenlik duvarı verisi bulunamadı",
|
||||
"action": "Eylem",
|
||||
"protocol": "Protokol",
|
||||
"port": "Port",
|
||||
"source": "Kaynak",
|
||||
"anywhere": "Her yer",
|
||||
"chains": "zincirler"
|
||||
},
|
||||
"loadAvg": "Ortalama Yük",
|
||||
"swap": "Takas",
|
||||
"architecture": "Mimari",
|
||||
"refresh": "Yenile",
|
||||
"retry": "Yeniden dene",
|
||||
"customize": "Düzeni özelleştir",
|
||||
"reset": "Sıfırla",
|
||||
"tabLive": "Canlı",
|
||||
"editModeInstructions": "Kartları yeniden düzenlemek için sürükleyin, yeniden boyutlandırmak için alt kenarı sürükleyin ve bir kartın genişliğini değiştirmek için genişlik düğmesini kullanın. Aşağıdan kart ekleyin veya kaldırın.",
|
||||
"managers": {
|
||||
"services": "Hizmetler",
|
||||
"processInspector": "İşlem Denetçisi",
|
||||
"logViewer": "Günlük Görüntüleyici",
|
||||
"cron": "Cron Görevleri",
|
||||
"packages": "Paketler",
|
||||
"ssl": "SSL Sertifikaları",
|
||||
"firewall": "Güvenlik Duvarı",
|
||||
"users": "Kullanıcılar ve İzinler",
|
||||
"healthCheck": "Sağlık Kontrolleri",
|
||||
"diskBreakdown": "Disk Dağılımı",
|
||||
"systemdTimers": "Zamanlayıcılar",
|
||||
"topMemory": "Bellek Sıralaması",
|
||||
"noData": "Veri yok",
|
||||
"sudoHint": "Ayrıcalıklı eylemleri etkinleştirmek için host düzenleyicisinde bu host için bir sudo parolası belirleyin.",
|
||||
"filter": "Filtrele...",
|
||||
"start": "Başlat",
|
||||
"stop": "Durdur",
|
||||
"restart": "Yeniden Başlat",
|
||||
"actionDone": "{{name}} güncellendi",
|
||||
"actionFailed": "Eylem başarısız oldu",
|
||||
"signalSent": "PID {{pid}}'e sinyal gönderildi",
|
||||
"killHint": "Tıklama: sonlandır (SIGTERM). Sağ tıklama: zorla sonlandır (SIGKILL).",
|
||||
"working": "Çalışıyor...",
|
||||
"update": "Güncelle",
|
||||
"upgradeAll": "Tümünü yükselt",
|
||||
"allUpToDate": "Her şey güncel",
|
||||
"save": "Kaydet",
|
||||
"command": "Komut",
|
||||
"enabled": "Etkin",
|
||||
"cronSaved": "Crontab güncellendi",
|
||||
"clients": "İstemciler",
|
||||
"dryRun": "Deneme çalıştırması",
|
||||
"renew": "Yenile",
|
||||
"noAcmeClient": "Bu sunucuda ACME istemcisi (certbot veya acme.sh) bulunamadı.",
|
||||
"addInputRule": "INPUT kuralı ekle",
|
||||
"firewallWarning": "Değişiklikler, kalıcı hale getirilene kadar yalnızca çalışma zamanında geçerlidir. Kendinizi kilitlememeye dikkat edin.",
|
||||
"ruleApplied": "Kural uygulandı",
|
||||
"invalidPort": "Geçerli bir bağlantı noktası girin (1-65535)",
|
||||
"newUsername": "Yeni kullanıcı adı",
|
||||
"addUser": "Ekle",
|
||||
"deleteUser": "Kullanıcıyı sil",
|
||||
"noHealthChecks": "Henüz yapılandırılmış bir sağlık denetimi yok.",
|
||||
"follow": "Takip et",
|
||||
"noLogData": "Günlük çıktısı yok.",
|
||||
"tree": "Ağaç",
|
||||
"enableDisable": "Önyüklemede etkinleştir / devre dışı bırak",
|
||||
"grantSudo": "Sudo yetkisi ver",
|
||||
"revokeSudo": "Sudo yetkisini kaldır",
|
||||
"sslIssueCert": "Sertifika oluştur",
|
||||
"sslExpired": "Süresi dolmuş",
|
||||
"sslInDays": "{{days}} gün içinde",
|
||||
"sslNeedDomain": "En az bir alan adı girin",
|
||||
"sslIssued": "Sertifika oluşturuldu",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (bağımsız)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS sağlayıcısı (örn. cloudflare)",
|
||||
"sslIssueHint": "DNS sağlayıcı kimlik bilgileri sunucuda zaten yapılandırılmış olmalıdır.",
|
||||
"sslRevoke": "Sertifikayı iptal et",
|
||||
"sslRevoked": "Sertifika iptal edildi",
|
||||
"sslRevokeConfirm": "\"{{name}}\" sertifikası iptal edilip kaldırılsın mı? Bu işlem geri alınamaz.",
|
||||
"healthRun": "Çalıştır",
|
||||
"healthName": "Ad",
|
||||
"healthTarget": "Sunucu / adres",
|
||||
"healthAddCheck": "Kontrol ekle",
|
||||
"healthSaved": "Sağlık kontrolleri kaydedildi",
|
||||
"healthMissingFields": "Her kontrol bir ad ve hedef gerektirir",
|
||||
"logFile": "Dosya",
|
||||
"logUnit": "Birim",
|
||||
"logCustomPath": "/var/log altında özel yol (isteğe bağlı)",
|
||||
"logGrep": "Satırları filtrele...",
|
||||
"firewallPersist": "Kuralları kalıcı yap",
|
||||
"firewallPersisted": "Güvenlik duvarı kuralları kalıcı hale getirildi",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "Bu sunucuda WireGuard yüklü değil",
|
||||
"wgNoInterfaces": "Yapılandırılmış WireGuard arayüzü yok",
|
||||
"wgInterfaceUp": "Başlat",
|
||||
"wgInterfaceDown": "Durdur",
|
||||
"wgBringingUp": "{{name}} başlatılıyor...",
|
||||
"wgBringingDown": "{{name}} durduruluyor...",
|
||||
"wgInterfaceUpDone": "{{name}} çalışıyor",
|
||||
"wgInterfaceDownDone": "{{name}} çalışmıyor",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Açık anahtar",
|
||||
"wgEndpoint": "Uç nokta",
|
||||
"wgAllowedIPs": "İzin verilen IP'ler",
|
||||
"wgLastHandshake": "Son el sıkışma",
|
||||
"wgHandshakeNever": "Hiçbir zaman",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrik Geçmişi",
|
||||
"historySuffix": "Geçmiş",
|
||||
"cpuMemoryDisk": "CPU / Bellek / Disk",
|
||||
"network": "Ağ",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Bu zaman aralığı için geçmiş verisi yok.",
|
||||
"custom": "Özel",
|
||||
"to": "ile",
|
||||
"apply": "Uygula",
|
||||
"viewHistory": "Geçmişi Görüntüle"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Sunucu Metrikleri",
|
||||
"metricsCollectionLabel": "Metrik Toplama",
|
||||
"enableMetricsLabel": "Metrikleri Etkinleştir",
|
||||
"enableMetricsDesc": "Bu sunucudan CPU, RAM, disk ve diğer metrikleri topla",
|
||||
"useGlobalMetrics": "Genel Aralığı Kullan",
|
||||
"useGlobalMetricsDesc": "Sunucu geneli metrik aralığını geçersiz kıl",
|
||||
"metricsIntervalS": "Metrik Aralığı (sn)",
|
||||
"metricsIntervalDesc2": "Metrik anlık görüntüleri arasındaki saniye",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Etiket (isteğe bağlı)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Ekle",
|
||||
"visibleWidgets": "Görünür Widget'lar",
|
||||
"widgetsMovedToHostMetrics": "Kartlar artık doğrudan Sunucu Metrikleri sekmesinde eklenir, düzenlenir ve yeniden boyutlandırılır. Bu sunucu için Sunucu Metrikleri'ni açın ve hangi kartların gösterileceğini seçmek için Özelleştir'i kullanın.",
|
||||
"hostNotFound": "Host bulunamadı",
|
||||
"loadingHost": "Host yükleniyor..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrik Geçmişi Saklama",
|
||||
"metricsHistoryRetentionRange": "1 ila 90 gün",
|
||||
"days": "gün"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Veri yok",
|
||||
"showCurrentValue": "Geçerli Değeri Göster",
|
||||
"chartMetric": "Metrik",
|
||||
"metricRange": "Aralık",
|
||||
"host": "Sunucu",
|
||||
"loading": "Yükleniyor...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Ana Bilgisayar Adı",
|
||||
"metricKernel": "Çekirdek",
|
||||
"metricMemory": "Bellek",
|
||||
"metricNetwork": "Ağ",
|
||||
"metricOs": "İşletim Sistemi",
|
||||
"metricProcesses": "İşlemler",
|
||||
"metricProcessesRunning": "çalışan",
|
||||
"metricProcessesTotal": "toplam",
|
||||
"metricUptime": "Çalışma Süresi",
|
||||
"metricsNotAvailable": "Metrikler kullanılamıyor",
|
||||
"noHostSelected": "Sunucu seçilmedi",
|
||||
"selectHost": "Bir sunucu seçin..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Bağlandı"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Host Metrics"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"network": "Network",
|
||||
"uptime": "Uptime",
|
||||
"processes": "Processes",
|
||||
"available": "Available",
|
||||
"free": "Free",
|
||||
"connecting": "Connecting...",
|
||||
"connectionFailed": "Failed to connect to server",
|
||||
"naCpus": "N/A CPU(s)",
|
||||
"cpuCores_one": "{{count}} Core",
|
||||
"cpuCores_other": "{{count}} Cores",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"diskUsage": "Disk Usage",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Temperature",
|
||||
"highestTemperature": "Highest temperature",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Failed to fetch host configuration",
|
||||
"serverOffline": "Server Offline",
|
||||
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||
"totpFailed": "TOTP verification failed",
|
||||
"noneAuthNotSupported": "Host Metrics does not support 'none' authentication type.",
|
||||
"noHostSelected": "No host selected",
|
||||
"load": "Load",
|
||||
"systemInfo": "System Information",
|
||||
"hostname": "Hostname",
|
||||
"operatingSystem": "Operating System",
|
||||
"kernel": "Kernel",
|
||||
"seconds": "seconds",
|
||||
"networkInterfaces": "Network Interfaces",
|
||||
"noInterfacesFound": "No network interfaces found",
|
||||
"noProcessesFound": "No processes found",
|
||||
"processesTotal": "total",
|
||||
"processesRunning": "running",
|
||||
"loginStats": "SSH Login Statistics",
|
||||
"noRecentLoginData": "No recent login data",
|
||||
"ports": {
|
||||
"title": "Listening Ports",
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"address": "Address",
|
||||
"process": "Process",
|
||||
"search": "Search ports...",
|
||||
"allProtocols": "All",
|
||||
"noData": "No listening ports data"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"inactive": "Inactive",
|
||||
"policy": "Policy",
|
||||
"rules": "rules",
|
||||
"noRules": "No rules in this chain",
|
||||
"noData": "No firewall data available",
|
||||
"action": "Action",
|
||||
"protocol": "Proto",
|
||||
"port": "Port",
|
||||
"source": "Source",
|
||||
"anywhere": "Anywhere",
|
||||
"chains": "chains"
|
||||
},
|
||||
"loadAvg": "Load Avg",
|
||||
"swap": "Swap",
|
||||
"architecture": "Architecture",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"customize": "Customize layout",
|
||||
"reset": "Reset",
|
||||
"tabLive": "Live",
|
||||
"editModeInstructions": "Drag cards to rearrange, drag the bottom edge to resize, and use the width button to change a card's span. Add or remove cards below.",
|
||||
"managers": {
|
||||
"services": "Services",
|
||||
"processInspector": "Process Inspector",
|
||||
"logViewer": "Log Viewer",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Packages",
|
||||
"ssl": "SSL Certificates",
|
||||
"firewall": "Firewall",
|
||||
"users": "Users & Permissions",
|
||||
"healthCheck": "Health Checks",
|
||||
"diskBreakdown": "Disk Breakdown",
|
||||
"systemdTimers": "Timers",
|
||||
"topMemory": "Top by Memory",
|
||||
"noData": "No data",
|
||||
"sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.",
|
||||
"filter": "Filter...",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"actionDone": "{{name}} updated",
|
||||
"actionFailed": "Action failed",
|
||||
"signalSent": "Signal sent to PID {{pid}}",
|
||||
"killHint": "Click: terminate (SIGTERM). Right-click: force kill (SIGKILL).",
|
||||
"working": "Working...",
|
||||
"update": "Update",
|
||||
"upgradeAll": "Upgrade all",
|
||||
"allUpToDate": "Everything is up to date",
|
||||
"save": "Save",
|
||||
"command": "Command",
|
||||
"enabled": "Enabled",
|
||||
"cronSaved": "Crontab updated",
|
||||
"clients": "Clients",
|
||||
"dryRun": "Dry run",
|
||||
"renew": "Renew",
|
||||
"noAcmeClient": "No ACME client (certbot or acme.sh) found on this host.",
|
||||
"addInputRule": "Add INPUT rule",
|
||||
"firewallWarning": "Changes are runtime-only until persisted. Be careful not to lock yourself out.",
|
||||
"ruleApplied": "Rule applied",
|
||||
"invalidPort": "Enter a valid port (1-65535)",
|
||||
"newUsername": "New username",
|
||||
"addUser": "Add",
|
||||
"deleteUser": "Delete user",
|
||||
"noHealthChecks": "No health checks configured yet.",
|
||||
"follow": "Follow",
|
||||
"noLogData": "No log output.",
|
||||
"tree": "Tree",
|
||||
"enableDisable": "Enable / disable at boot",
|
||||
"grantSudo": "Grant sudo",
|
||||
"revokeSudo": "Revoke sudo",
|
||||
"sslIssueCert": "Issue certificate",
|
||||
"sslExpired": "Expired",
|
||||
"sslInDays": "in {{days}}d",
|
||||
"sslNeedDomain": "Enter at least one domain",
|
||||
"sslIssued": "Certificate issued",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (standalone)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS provider (e.g. cloudflare)",
|
||||
"sslIssueHint": "DNS provider credentials must already be configured on the host.",
|
||||
"sslRevoke": "Revoke certificate",
|
||||
"sslRevoked": "Certificate revoked",
|
||||
"sslRevokeConfirm": "Revoke and remove the certificate \"{{name}}\"? This cannot be undone.",
|
||||
"healthRun": "Run",
|
||||
"healthName": "Name",
|
||||
"healthTarget": "Host / address",
|
||||
"healthAddCheck": "Add check",
|
||||
"healthSaved": "Health checks saved",
|
||||
"healthMissingFields": "Each check needs a name and target",
|
||||
"logFile": "File",
|
||||
"logUnit": "Unit",
|
||||
"logCustomPath": "Custom path under /var/log (optional)",
|
||||
"logGrep": "Filter lines...",
|
||||
"firewallPersist": "Persist rules",
|
||||
"firewallPersisted": "Firewall rules persisted",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard is not installed on this host",
|
||||
"wgNoInterfaces": "No WireGuard interfaces configured",
|
||||
"wgInterfaceUp": "Bring up",
|
||||
"wgInterfaceDown": "Bring down",
|
||||
"wgBringingUp": "Bringing {{name}} up...",
|
||||
"wgBringingDown": "Bringing {{name}} down...",
|
||||
"wgInterfaceUpDone": "{{name}} is up",
|
||||
"wgInterfaceDownDone": "{{name}} is down",
|
||||
"wgListenPort": "Port",
|
||||
"wgPublicKey": "Public key",
|
||||
"wgEndpoint": "Endpoint",
|
||||
"wgAllowedIPs": "Allowed IPs",
|
||||
"wgLastHandshake": "Last handshake",
|
||||
"wgHandshakeNever": "Never",
|
||||
"wgTransfer": "Transfer",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrics History",
|
||||
"historySuffix": "History",
|
||||
"cpuMemoryDisk": "CPU / Memory / Disk",
|
||||
"network": "Network",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "No history data available for this time range.",
|
||||
"custom": "Custom",
|
||||
"to": "to",
|
||||
"apply": "Apply",
|
||||
"viewHistory": "View History"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Host Metrics",
|
||||
"metricsCollectionLabel": "Metrics Collection",
|
||||
"enableMetricsLabel": "Enable Metrics",
|
||||
"enableMetricsDesc": "Collect CPU, RAM, disk, and other metrics from this host",
|
||||
"useGlobalMetrics": "Use Global Interval",
|
||||
"useGlobalMetricsDesc": "Override with the server-wide metrics interval",
|
||||
"metricsIntervalS": "Metrics Interval (s)",
|
||||
"metricsIntervalDesc2": "Seconds between metric snapshots",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Label (optional)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Add",
|
||||
"visibleWidgets": "Visible Widgets",
|
||||
"widgetsMovedToHostMetrics": "Cards are now added, arranged, and resized directly in the Host Metrics tab. Open Host Metrics for this host and use Customize to choose which cards are shown.",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Metrics History Retention",
|
||||
"metricsHistoryRetentionRange": "1 to 90 days",
|
||||
"days": "days"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "No data available",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range",
|
||||
"host": "Host",
|
||||
"loading": "Loading...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Hostname",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memory",
|
||||
"metricNetwork": "Network",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Processes",
|
||||
"metricProcessesRunning": "running",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Uptime",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"noHostSelected": "No host selected",
|
||||
"selectHost": "Select a host..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connected"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "Chỉ số máy chủ"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Bộ nhớ",
|
||||
"disk": "Ổ đĩa",
|
||||
"network": "Mạng",
|
||||
"uptime": "Thời gian hoạt động",
|
||||
"processes": "Tiến trình",
|
||||
"available": "Khả dụng",
|
||||
"free": "Trống",
|
||||
"connecting": "Đang kết nối...",
|
||||
"connectionFailed": "Kết nối máy chủ thất bại",
|
||||
"naCpus": "N/A CPU",
|
||||
"cpuCores_one": "{{count}} Lõi",
|
||||
"cpuCores_other": "{{count}} Lõi",
|
||||
"cpuUsage": "Mức sử dụng CPU",
|
||||
"memoryUsage": "Mức sử dụng bộ nhớ",
|
||||
"diskUsage": "Mức sử dụng ổ đĩa",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "Nhiệt độ",
|
||||
"highestTemperature": "Nhiệt độ cao nhất",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "Không tải được cấu hình máy chủ",
|
||||
"serverOffline": "Máy chủ ngoại tuyến",
|
||||
"cannotFetchMetrics": "Không thể tải số liệu từ máy chủ ngoại tuyến",
|
||||
"totpFailed": "Xác minh TOTP thất bại",
|
||||
"noneAuthNotSupported": "Host Metrics không hỗ trợ kiểu xác thực 'none'.",
|
||||
"noHostSelected": "Chưa chọn máy chủ",
|
||||
"load": "Tải",
|
||||
"systemInfo": "Thông tin hệ thống",
|
||||
"hostname": "Tên máy chủ",
|
||||
"operatingSystem": "Hệ điều hành",
|
||||
"kernel": "Nhân",
|
||||
"seconds": "giây",
|
||||
"networkInterfaces": "Giao diện mạng",
|
||||
"noInterfacesFound": "Không tìm thấy giao diện mạng nào",
|
||||
"noProcessesFound": "Không tìm thấy tiến trình nào",
|
||||
"processesTotal": "Tổng",
|
||||
"processesRunning": "đang chạy",
|
||||
"loginStats": "Thống kê đăng nhập SSH",
|
||||
"noRecentLoginData": "Không có dữ liệu đăng nhập gần đây",
|
||||
"ports": {
|
||||
"title": "Cổng đang lắng nghe",
|
||||
"protocol": "Giao thức",
|
||||
"port": "Cổng",
|
||||
"address": "Địa chỉ",
|
||||
"process": "Tiến trình",
|
||||
"search": "Tìm kiếm cổng...",
|
||||
"allProtocols": "Tất cả",
|
||||
"noData": "Không có dữ liệu cổng đang lắng nghe"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Tường lửa",
|
||||
"inactive": "Không hoạt động",
|
||||
"policy": "Chính sách",
|
||||
"rules": "quy tắc",
|
||||
"noRules": "Không có quy tắc nào trong chuỗi này",
|
||||
"noData": "Không có dữ liệu tường lửa",
|
||||
"action": "Hành động",
|
||||
"protocol": "Giao thức",
|
||||
"port": "Cổng",
|
||||
"source": "Nguồn",
|
||||
"anywhere": "Mọi nơi",
|
||||
"chains": "chuỗi"
|
||||
},
|
||||
"loadAvg": "Tải trung bình",
|
||||
"swap": "Swap",
|
||||
"architecture": "Kiến trúc",
|
||||
"refresh": "Làm mới",
|
||||
"retry": "Thử lại",
|
||||
"customize": "Tùy chỉnh bố cục",
|
||||
"reset": "Đặt lại",
|
||||
"tabLive": "Trực tiếp",
|
||||
"editModeInstructions": "Kéo thẻ để sắp xếp lại, kéo cạnh dưới để thay đổi kích thước, và dùng nút chiều rộng để thay đổi độ rộng thẻ. Thêm hoặc xóa thẻ bên dưới.",
|
||||
"managers": {
|
||||
"services": "Dịch vụ",
|
||||
"processInspector": "Trình kiểm tra tiến trình",
|
||||
"logViewer": "Trình xem nhật ký",
|
||||
"cron": "Cron Jobs",
|
||||
"packages": "Gói",
|
||||
"ssl": "Chứng chỉ SSL",
|
||||
"firewall": "Tường lửa",
|
||||
"users": "Người dùng & Quyền",
|
||||
"healthCheck": "Kiểm tra sức khỏe",
|
||||
"diskBreakdown": "Phân tích ổ đĩa",
|
||||
"systemdTimers": "Bộ hẹn giờ",
|
||||
"topMemory": "Top theo bộ nhớ",
|
||||
"noData": "Không có dữ liệu",
|
||||
"sudoHint": "Đặt mật khẩu sudo cho máy chủ này trong trình chỉnh sửa máy chủ để kích hoạt các hành động đặc quyền.",
|
||||
"filter": "Lọc...",
|
||||
"start": "Khởi động",
|
||||
"stop": "Dừng",
|
||||
"restart": "Khởi động lại",
|
||||
"actionDone": "{{name}} được cập nhật",
|
||||
"actionFailed": "Hành động thất bại",
|
||||
"signalSent": "Đã gửi tín hiệu đến PID {{pid}}",
|
||||
"killHint": "Nhấp: kết thúc (SIGTERM). Nhấp chuột phải: buộc kết thúc (SIGKILL).",
|
||||
"working": "Đang làm việc...",
|
||||
"update": "Cập nhật",
|
||||
"upgradeAll": "Nâng cấp tất cả",
|
||||
"allUpToDate": "Tất cả đều mới nhất",
|
||||
"save": "Lưu",
|
||||
"command": "Lệnh",
|
||||
"enabled": "Đã bật",
|
||||
"cronSaved": "Đã cập nhật crontab",
|
||||
"clients": "Máy khách",
|
||||
"dryRun": "Chạy thử",
|
||||
"renew": "Gia hạn",
|
||||
"noAcmeClient": "Không tìm thấy máy khách ACME (certbot hoặc acme.sh) trên máy này.",
|
||||
"addInputRule": "Thêm quy tắc INPUT",
|
||||
"firewallWarning": "Thay đổi chỉ tạm thời, chưa được lưu vĩnh viễn. Cẩn thận kẻo tự khóa mình ra ngoài.",
|
||||
"ruleApplied": "Quy tắc đã được áp dụng",
|
||||
"invalidPort": "Nhập cổng hợp lệ (1-65535)",
|
||||
"newUsername": "Tên người dùng mới",
|
||||
"addUser": "Thêm",
|
||||
"deleteUser": "Xóa người dùng",
|
||||
"noHealthChecks": "Chưa có kiểm tra trạng thái nào được cấu hình.",
|
||||
"follow": "Theo dõi",
|
||||
"noLogData": "Không có dữ liệu nhật ký.",
|
||||
"tree": "Dạng cây",
|
||||
"enableDisable": "Bật / tắt khi khởi động",
|
||||
"grantSudo": "Cấp quyền sudo",
|
||||
"revokeSudo": "Thu hồi quyền sudo",
|
||||
"sslIssueCert": "Cấp chứng chỉ",
|
||||
"sslExpired": "Đã hết hạn",
|
||||
"sslInDays": "trong {{days}}d",
|
||||
"sslNeedDomain": "Nhập ít nhất một tên miền",
|
||||
"sslIssued": "Đã cấp chứng chỉ",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (độc lập)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "Nhà cung cấp DNS (ví dụ: cloudflare)",
|
||||
"sslIssueHint": "Thông tin xác thực nhà cung cấp DNS phải được cấu hình trên máy chủ.",
|
||||
"sslRevoke": "Thu hồi chứng chỉ",
|
||||
"sslRevoked": "Chứng chỉ đã bị thu hồi",
|
||||
"sslRevokeConfirm": "Thu hồi và xóa chứng chỉ \"{{name}}\"? Hành động này không thể hoàn tác.",
|
||||
"healthRun": "Chạy",
|
||||
"healthName": "Tên",
|
||||
"healthTarget": "Máy chủ / địa chỉ",
|
||||
"healthAddCheck": "Thêm kiểm tra",
|
||||
"healthSaved": "Đã lưu kiểm tra sức khỏe",
|
||||
"healthMissingFields": "Mỗi kiểm tra cần có tên và mục tiêu",
|
||||
"logFile": "Tệp",
|
||||
"logUnit": "Đơn vị",
|
||||
"logCustomPath": "Đường dẫn tùy chỉnh trong /var/log (tùy chọn)",
|
||||
"logGrep": "Lọc dòng...",
|
||||
"firewallPersist": "Lưu quy tắc",
|
||||
"firewallPersisted": "Đã lưu quy tắc tường lửa",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "WireGuard chưa được cài đặt trên máy chủ này",
|
||||
"wgNoInterfaces": "Chưa cấu hình giao diện WireGuard nào",
|
||||
"wgInterfaceUp": "Khởi động",
|
||||
"wgInterfaceDown": "Dừng",
|
||||
"wgBringingUp": "Đang khởi động {{name}}...",
|
||||
"wgBringingDown": "Đang dừng {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} đã hoạt động",
|
||||
"wgInterfaceDownDone": "{{name}} đã dừng",
|
||||
"wgListenPort": "Cổng",
|
||||
"wgPublicKey": "Khóa công khai",
|
||||
"wgEndpoint": "Điểm cuối",
|
||||
"wgAllowedIPs": "IP được phép",
|
||||
"wgLastHandshake": "Lần bắt tay cuối",
|
||||
"wgHandshakeNever": "Chưa bao giờ",
|
||||
"wgTransfer": "Truyền tải",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Lịch sử chỉ số",
|
||||
"historySuffix": "Lịch sử",
|
||||
"cpuMemoryDisk": "CPU / Bộ nhớ / Ổ đĩa",
|
||||
"network": "Mạng",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "Không có dữ liệu lịch sử cho khoảng thời gian này.",
|
||||
"custom": "Tùy chỉnh",
|
||||
"to": "đến",
|
||||
"apply": "Áp dụng",
|
||||
"viewHistory": "Xem lịch sử"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "Số liệu máy chủ",
|
||||
"metricsCollectionLabel": "Thu thập số liệu",
|
||||
"enableMetricsLabel": "Bật thu thập số liệu",
|
||||
"enableMetricsDesc": "Thu thập CPU, RAM, đĩa và các số liệu khác từ máy này",
|
||||
"useGlobalMetrics": "Dùng khoảng thời gian toàn cục",
|
||||
"useGlobalMetricsDesc": "Ghi đè bằng khoảng thời gian thu thập số liệu toàn máy chủ",
|
||||
"metricsIntervalS": "Khoảng thu thập số liệu (giây)",
|
||||
"metricsIntervalDesc2": "Số giây giữa các lần chụp số liệu",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "Nhãn (tùy chọn)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "Thêm",
|
||||
"visibleWidgets": "Widget hiển thị",
|
||||
"widgetsMovedToHostMetrics": "Các thẻ hiện được thêm, sắp xếp và thay đổi kích thước trực tiếp trong tab Số liệu máy. Mở Số liệu máy cho máy này và dùng Tùy chỉnh để chọn thẻ hiển thị.",
|
||||
"hostNotFound": "Không tìm thấy máy chủ",
|
||||
"loadingHost": "Đang tải máy chủ..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "Thời gian lưu trữ lịch sử số liệu",
|
||||
"metricsHistoryRetentionRange": "1 đến 90 ngày",
|
||||
"days": "ngày"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "Không có dữ liệu",
|
||||
"showCurrentValue": "Hiển thị giá trị hiện tại",
|
||||
"chartMetric": "Chỉ số",
|
||||
"metricRange": "Phạm vi",
|
||||
"host": "Máy chủ",
|
||||
"loading": "Đang tải...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Đĩa",
|
||||
"metricHostname": "Tên máy chủ",
|
||||
"metricKernel": "Nhân",
|
||||
"metricMemory": "Bộ nhớ",
|
||||
"metricNetwork": "Mạng",
|
||||
"metricOs": "Hệ điều hành",
|
||||
"metricProcesses": "Tiến trình",
|
||||
"metricProcessesRunning": "đang chạy",
|
||||
"metricProcessesTotal": "tổng",
|
||||
"metricUptime": "Thời gian hoạt động",
|
||||
"metricsNotAvailable": "Chỉ số không khả dụng",
|
||||
"noHostSelected": "Chưa chọn máy chủ",
|
||||
"selectHost": "Chọn máy chủ..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Ổ đĩa"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Đã kết nối"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "主机指标"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "内存",
|
||||
"disk": "磁盘",
|
||||
"network": "网络",
|
||||
"uptime": "运行时间",
|
||||
"processes": "进程",
|
||||
"available": "可用",
|
||||
"free": "空闲",
|
||||
"connecting": "连接中...",
|
||||
"connectionFailed": "连接服务器失败",
|
||||
"naCpus": "N/A CPU",
|
||||
"cpuCores_one": "{{count}} 核心",
|
||||
"cpuCores_other": "{{count}} 核心",
|
||||
"cpuUsage": "CPU 使用率",
|
||||
"memoryUsage": "内存使用率",
|
||||
"diskUsage": "磁盘使用率",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "温度",
|
||||
"highestTemperature": "最高温度",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "获取主机配置失败",
|
||||
"serverOffline": "服务器离线",
|
||||
"cannotFetchMetrics": "无法从离线服务器获取指标",
|
||||
"totpFailed": "TOTP 验证失败",
|
||||
"noneAuthNotSupported": "主机指标不支持 'none' 认证类型。",
|
||||
"noHostSelected": "未选择主机",
|
||||
"load": "负载",
|
||||
"systemInfo": "系统信息",
|
||||
"hostname": "主机名",
|
||||
"operatingSystem": "操作系统",
|
||||
"kernel": "内核",
|
||||
"seconds": "秒",
|
||||
"networkInterfaces": "网络接口",
|
||||
"noInterfacesFound": "未找到网络接口",
|
||||
"noProcessesFound": "未找到进程",
|
||||
"processesTotal": "总计",
|
||||
"processesRunning": "运行中",
|
||||
"loginStats": "SSH 登录统计",
|
||||
"noRecentLoginData": "无最近登录数据",
|
||||
"ports": {
|
||||
"title": "监听端口",
|
||||
"protocol": "协议",
|
||||
"port": "端口",
|
||||
"address": "地址",
|
||||
"process": "进程",
|
||||
"search": "搜索端口...",
|
||||
"allProtocols": "全部",
|
||||
"noData": "无监听端口数据"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "防火墙",
|
||||
"inactive": "未激活",
|
||||
"policy": "策略",
|
||||
"rules": "规则",
|
||||
"noRules": "该链中无规则",
|
||||
"noData": "无可用防火墙数据",
|
||||
"action": "操作",
|
||||
"protocol": "协议",
|
||||
"port": "端口",
|
||||
"source": "来源",
|
||||
"anywhere": "任意",
|
||||
"chains": "链"
|
||||
},
|
||||
"loadAvg": "平均负载",
|
||||
"swap": "交换空间",
|
||||
"architecture": "架构",
|
||||
"refresh": "刷新",
|
||||
"retry": "重试",
|
||||
"customize": "自定义布局",
|
||||
"reset": "重置",
|
||||
"tabLive": "实时",
|
||||
"editModeInstructions": "拖动卡片可重新排序,拖动底部边缘可调整大小,使用宽度按钮可更改卡片的跨度。在下方添加或移除卡片。",
|
||||
"managers": {
|
||||
"services": "服务",
|
||||
"processInspector": "进程查看器",
|
||||
"logViewer": "日志查看器",
|
||||
"cron": "定时任务",
|
||||
"packages": "软件包",
|
||||
"ssl": "SSL证书",
|
||||
"firewall": "防火墙",
|
||||
"users": "用户与权限",
|
||||
"healthCheck": "健康检查",
|
||||
"diskBreakdown": "磁盘详情",
|
||||
"systemdTimers": "计时器",
|
||||
"topMemory": "按内存占用排行",
|
||||
"noData": "无数据",
|
||||
"sudoHint": "在主机编辑器中为此主机设置 sudo 密码以启用特权操作。",
|
||||
"filter": "筛选...",
|
||||
"start": "启动",
|
||||
"stop": "停止",
|
||||
"restart": "重启",
|
||||
"actionDone": "{{name}} 已更新",
|
||||
"actionFailed": "操作失败",
|
||||
"signalSent": "信号已发送至 PID {{pid}}",
|
||||
"killHint": "单击:终止 (SIGTERM)。右键单击:强制终止 (SIGKILL)。",
|
||||
"working": "处理中...",
|
||||
"update": "更新",
|
||||
"upgradeAll": "全部升级",
|
||||
"allUpToDate": "所有内容均为最新",
|
||||
"save": "保存",
|
||||
"command": "命令",
|
||||
"enabled": "已启用",
|
||||
"cronSaved": "定时任务已更新",
|
||||
"clients": "客户端",
|
||||
"dryRun": "试运行",
|
||||
"renew": "续期",
|
||||
"noAcmeClient": "此主机上未找到 ACME 客户端 (certbot 或 acme.sh)。",
|
||||
"addInputRule": "添加 INPUT 规则",
|
||||
"firewallWarning": "更改仅在运行时有效,需持久化保存。小心不要将自己锁定在外。",
|
||||
"ruleApplied": "规则已应用",
|
||||
"invalidPort": "请输入有效端口 (1-65535)",
|
||||
"newUsername": "新用户名",
|
||||
"addUser": "添加",
|
||||
"deleteUser": "删除用户",
|
||||
"noHealthChecks": "尚未配置健康检查。",
|
||||
"follow": "跟踪",
|
||||
"noLogData": "无日志输出。",
|
||||
"tree": "树形",
|
||||
"enableDisable": "启用/禁用开机自启",
|
||||
"grantSudo": "授予 sudo",
|
||||
"revokeSudo": "撤销 sudo",
|
||||
"sslIssueCert": "签发证书",
|
||||
"sslExpired": "已过期",
|
||||
"sslInDays": "{{days}} 天后",
|
||||
"sslNeedDomain": "至少输入一个域名",
|
||||
"sslIssued": "证书已签发",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (独立模式)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS 提供商 (如 cloudflare)",
|
||||
"sslIssueHint": "DNS 提供商凭据必须已在主机上配置。",
|
||||
"sslRevoke": "吊销证书",
|
||||
"sslRevoked": "证书已吊销",
|
||||
"sslRevokeConfirm": "吊销并删除证书 \"{{name}}\"?此操作无法撤销。",
|
||||
"healthRun": "运行",
|
||||
"healthName": "名称",
|
||||
"healthTarget": "主机 / 地址",
|
||||
"healthAddCheck": "添加检查",
|
||||
"healthSaved": "健康检查已保存",
|
||||
"healthMissingFields": "每个检查都需要名称和目标",
|
||||
"logFile": "文件",
|
||||
"logUnit": "单元",
|
||||
"logCustomPath": "自定义路径 (位于 /var/log 下,可选)",
|
||||
"logGrep": "过滤行...",
|
||||
"firewallPersist": "持久化规则",
|
||||
"firewallPersisted": "防火墙规则已持久化",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "此主机未安装 WireGuard",
|
||||
"wgNoInterfaces": "未配置 WireGuard 接口",
|
||||
"wgInterfaceUp": "启用",
|
||||
"wgInterfaceDown": "停用",
|
||||
"wgBringingUp": "正在启用 {{name}}...",
|
||||
"wgBringingDown": "正在停用 {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} 已启用",
|
||||
"wgInterfaceDownDone": "{{name}} 已停用",
|
||||
"wgListenPort": "端口",
|
||||
"wgPublicKey": "公钥",
|
||||
"wgEndpoint": "对端",
|
||||
"wgAllowedIPs": "允许的 IP",
|
||||
"wgLastHandshake": "上次握手",
|
||||
"wgHandshakeNever": "从未",
|
||||
"wgTransfer": "传输",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "指标历史",
|
||||
"historySuffix": "历史",
|
||||
"cpuMemoryDisk": "CPU / 内存 / 磁盘",
|
||||
"network": "网络",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "此时间范围内没有可用的历史数据。",
|
||||
"custom": "自定义",
|
||||
"to": "至",
|
||||
"apply": "应用",
|
||||
"viewHistory": "查看历史"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "主机指标",
|
||||
"metricsCollectionLabel": "指标收集",
|
||||
"enableMetricsLabel": "启用指标",
|
||||
"enableMetricsDesc": "从此主机收集 CPU、RAM、磁盘和其他指标",
|
||||
"useGlobalMetrics": "使用全局间隔",
|
||||
"useGlobalMetricsDesc": "使用服务器范围的指标间隔覆盖",
|
||||
"metricsIntervalS": "指标间隔(秒)",
|
||||
"metricsIntervalDesc2": "指标快照之间的秒数",
|
||||
"excludedMountsLabel": "排除的挂载点",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "标签(可选)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "添加",
|
||||
"visibleWidgets": "可见小部件",
|
||||
"widgetsMovedToHostMetrics": "卡片现在可在“主机指标”选项卡中直接添加、排列和调整大小。打开此主机的“主机指标”,使用“自定义”选择要显示的卡片。",
|
||||
"hostNotFound": "未找到主机",
|
||||
"loadingHost": "正在加载主机..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "指标与告警",
|
||||
"feature_metrics_desc": "跟踪 CPU、内存和磁盘的运行情况,并在它们出现故障时发出告警。"
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "指标历史保留",
|
||||
"metricsHistoryRetentionRange": "1 到 90 天",
|
||||
"days": "天"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "暂无数据",
|
||||
"showCurrentValue": "显示当前值",
|
||||
"chartMetric": "指标",
|
||||
"metricRange": "范围",
|
||||
"host": "主机",
|
||||
"loading": "加载中...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "磁盘",
|
||||
"metricHostname": "主机名",
|
||||
"metricKernel": "内核",
|
||||
"metricMemory": "内存",
|
||||
"metricNetwork": "网络",
|
||||
"metricOs": "操作系统",
|
||||
"metricProcesses": "进程",
|
||||
"metricProcessesRunning": "运行中",
|
||||
"metricProcessesTotal": "总数",
|
||||
"metricUptime": "运行时间",
|
||||
"metricsNotAvailable": "指标不可用",
|
||||
"noHostSelected": "未选择主机",
|
||||
"selectHost": "选择主机..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "内存"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "磁盘"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "已连接"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"nav": {
|
||||
"hostMetrics": "主機指標"
|
||||
},
|
||||
"hostMetrics": {
|
||||
"cpu": "CPU",
|
||||
"memory": "記憶體",
|
||||
"disk": "磁碟",
|
||||
"network": "網路",
|
||||
"uptime": "運作時間",
|
||||
"processes": "程序",
|
||||
"available": "可用",
|
||||
"free": "可用",
|
||||
"connecting": "連線中...",
|
||||
"connectionFailed": "無法連線至伺服器",
|
||||
"naCpus": "N/A CPU",
|
||||
"cpuCores_one": "{{count}} 核心",
|
||||
"cpuCores_other": "{{count}} 核心",
|
||||
"cpuUsage": "CPU 使用率",
|
||||
"memoryUsage": "記憶體使用率",
|
||||
"diskUsage": "磁碟使用率",
|
||||
"selectFilesystem": "Select filesystem",
|
||||
"temperature": "溫度",
|
||||
"highestTemperature": "最高溫度",
|
||||
"primaryTemperatureSensor": "Primary sensor",
|
||||
"gpu": {
|
||||
"title": "GPU",
|
||||
"noDevice": "No NVIDIA GPU detected. Install the NVIDIA driver (it provides nvidia-smi) to see GPU metrics.",
|
||||
"driver": "Driver {{version}}",
|
||||
"utilization": "Utilization",
|
||||
"memory": "VRAM",
|
||||
"temperature": "Temp",
|
||||
"power": "Power",
|
||||
"fan": "Fan",
|
||||
"processes": "GPU processes",
|
||||
"noProcesses": "No processes are using the GPU"
|
||||
},
|
||||
"failedToFetchHostConfig": "無法取得主機設定",
|
||||
"serverOffline": "伺服器離線",
|
||||
"cannotFetchMetrics": "無法從離線伺服器取得指標",
|
||||
"totpFailed": "TOTP 驗證失敗",
|
||||
"noneAuthNotSupported": "主機指標不支援 'none' 驗證類型。",
|
||||
"noHostSelected": "未選擇主機",
|
||||
"load": "負載",
|
||||
"systemInfo": "系統資訊",
|
||||
"hostname": "主機名稱",
|
||||
"operatingSystem": "作業系統",
|
||||
"kernel": "核心",
|
||||
"seconds": "秒",
|
||||
"networkInterfaces": "網路介面",
|
||||
"noInterfacesFound": "找不到網路介面",
|
||||
"noProcessesFound": "找不到程序",
|
||||
"processesTotal": "總計",
|
||||
"processesRunning": "執行中",
|
||||
"loginStats": "SSH 登入統計資訊",
|
||||
"noRecentLoginData": "沒有最近的登入資料",
|
||||
"ports": {
|
||||
"title": "監聽中的連接埠",
|
||||
"protocol": "通訊協定",
|
||||
"port": "連接埠",
|
||||
"address": "位址",
|
||||
"process": "程序",
|
||||
"search": "搜尋連接埠...",
|
||||
"allProtocols": "全部",
|
||||
"noData": "無監聽連接埠資料"
|
||||
},
|
||||
"firewall": {
|
||||
"title": "防火牆",
|
||||
"inactive": "未啟用",
|
||||
"policy": "原則",
|
||||
"rules": "規則",
|
||||
"noRules": "此鏈中無規則",
|
||||
"noData": "無防火牆資料可用",
|
||||
"action": "動作",
|
||||
"protocol": "通訊協定",
|
||||
"port": "連接埠",
|
||||
"source": "來源",
|
||||
"anywhere": "任意位置",
|
||||
"chains": "鏈"
|
||||
},
|
||||
"loadAvg": "平均負載",
|
||||
"swap": "交換空間",
|
||||
"architecture": "架構",
|
||||
"refresh": "重新整理",
|
||||
"retry": "重試",
|
||||
"customize": "自訂版面配置",
|
||||
"reset": "重設",
|
||||
"tabLive": "即時",
|
||||
"editModeInstructions": "拖曳卡片以重新排列,拖曳底部邊緣以調整大小,並使用寬度按鈕變更卡片跨度。於下方新增或移除卡片。",
|
||||
"managers": {
|
||||
"services": "服務",
|
||||
"processInspector": "程序檢查器",
|
||||
"logViewer": "日誌檢視器",
|
||||
"cron": "Cron 工作",
|
||||
"packages": "套件",
|
||||
"ssl": "SSL 憑證",
|
||||
"firewall": "防火牆",
|
||||
"users": "使用者與權限",
|
||||
"healthCheck": "健康檢查",
|
||||
"diskBreakdown": "磁碟明細",
|
||||
"systemdTimers": "計時器",
|
||||
"topMemory": "記憶體用量排行",
|
||||
"noData": "無資料",
|
||||
"sudoHint": "在主機編輯器中為此主機設定 sudo 密碼,以啟用特權操作。",
|
||||
"filter": "篩選...",
|
||||
"start": "啟動",
|
||||
"stop": "停止",
|
||||
"restart": "重新啟動",
|
||||
"actionDone": "{{name}} 已更新",
|
||||
"actionFailed": "操作失敗",
|
||||
"signalSent": "訊號已傳送至 PID {{pid}}",
|
||||
"killHint": "按一下:終止 (SIGTERM)。按右鍵:強制終止 (SIGKILL)。",
|
||||
"working": "處理中...",
|
||||
"update": "更新",
|
||||
"upgradeAll": "全部升級",
|
||||
"allUpToDate": "所有項目皆為最新",
|
||||
"save": "儲存",
|
||||
"command": "指令",
|
||||
"enabled": "已啟用",
|
||||
"cronSaved": "Crontab 已更新",
|
||||
"clients": "客戶端",
|
||||
"dryRun": "試執行",
|
||||
"renew": "續期",
|
||||
"noAcmeClient": "此主機上找不到 ACME 用戶端 (certbot 或 acme.sh)。",
|
||||
"addInputRule": "新增 INPUT 規則",
|
||||
"firewallWarning": "變更僅在執行階段有效,儲存後才會永久生效。小心切勿將自己鎖定在外。",
|
||||
"ruleApplied": "規則已套用",
|
||||
"invalidPort": "輸入有效的連接埠 (1-65535)",
|
||||
"newUsername": "新使用者名稱",
|
||||
"addUser": "新增",
|
||||
"deleteUser": "刪除使用者",
|
||||
"noHealthChecks": "尚未設定健康檢查。",
|
||||
"follow": "追蹤",
|
||||
"noLogData": "無日誌輸出。",
|
||||
"tree": "樹狀",
|
||||
"enableDisable": "開機時啟用 / 停用",
|
||||
"grantSudo": "授予 sudo 權限",
|
||||
"revokeSudo": "撤銷 sudo 權限",
|
||||
"sslIssueCert": "頒發憑證",
|
||||
"sslExpired": "已過期",
|
||||
"sslInDays": "{{days}} 天內",
|
||||
"sslNeedDomain": "至少輸入一個網域",
|
||||
"sslIssued": "憑證已頒發",
|
||||
"sslDomainsPlaceholder": "example.com, www.example.com",
|
||||
"sslHttpStandalone": "HTTP (獨立模式)",
|
||||
"sslHttpWebroot": "HTTP (webroot)",
|
||||
"sslDns": "DNS",
|
||||
"sslDnsProvider": "DNS 提供者 (例如 cloudflare)",
|
||||
"sslIssueHint": "DNS 提供者的憑證必須已在主機上設定。",
|
||||
"sslRevoke": "撤銷憑證",
|
||||
"sslRevoked": "憑證已撤銷",
|
||||
"sslRevokeConfirm": "撤銷並移除憑證「{{name}}」?此操作無法復原。",
|
||||
"healthRun": "執行",
|
||||
"healthName": "名稱",
|
||||
"healthTarget": "主機 / 位址",
|
||||
"healthAddCheck": "新增檢查",
|
||||
"healthSaved": "健康檢查已儲存",
|
||||
"healthMissingFields": "每個檢查都需要名稱和目標",
|
||||
"logFile": "檔案",
|
||||
"logUnit": "單元",
|
||||
"logCustomPath": "/var/log 下的自訂路徑 (選用)",
|
||||
"logGrep": "篩選行...",
|
||||
"firewallPersist": "永久儲存規則",
|
||||
"firewallPersisted": "防火牆規則已永久儲存",
|
||||
"wireguard": "WireGuard",
|
||||
"wgNotInstalled": "此主機上未安裝 WireGuard",
|
||||
"wgNoInterfaces": "未設定任何 WireGuard 介面",
|
||||
"wgInterfaceUp": "啟用",
|
||||
"wgInterfaceDown": "停用",
|
||||
"wgBringingUp": "正在啟用 {{name}}...",
|
||||
"wgBringingDown": "正在停用 {{name}}...",
|
||||
"wgInterfaceUpDone": "{{name}} 已啟用",
|
||||
"wgInterfaceDownDone": "{{name}} 已停用",
|
||||
"wgListenPort": "連接埠",
|
||||
"wgPublicKey": "公開金鑰",
|
||||
"wgEndpoint": "端點",
|
||||
"wgAllowedIPs": "允許的 IP",
|
||||
"wgLastHandshake": "上次交握",
|
||||
"wgHandshakeNever": "從未",
|
||||
"wgTransfer": "傳輸",
|
||||
"wgNoPeers": "No peers"
|
||||
}
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "指標歷史記錄",
|
||||
"historySuffix": "歷史記錄",
|
||||
"cpuMemoryDisk": "CPU / 記憶體 / 磁碟",
|
||||
"network": "網路",
|
||||
"download": "RX",
|
||||
"upload": "TX",
|
||||
"noData": "此時間範圍內沒有可用的歷史資料。",
|
||||
"custom": "自訂",
|
||||
"to": "至",
|
||||
"apply": "套用",
|
||||
"viewHistory": "檢視歷史記錄"
|
||||
},
|
||||
"hosts": {
|
||||
"tabHostMetrics": "主機指標",
|
||||
"metricsCollectionLabel": "指標收集",
|
||||
"enableMetricsLabel": "啟用指標",
|
||||
"enableMetricsDesc": "從此主機收集 CPU、記憶體、磁碟和其他指標",
|
||||
"useGlobalMetrics": "使用全域間隔",
|
||||
"useGlobalMetricsDesc": "以伺服器範圍的指標間隔覆寫",
|
||||
"metricsIntervalS": "指標間隔(秒)",
|
||||
"metricsIntervalDesc2": "每次指標快照之間的秒數",
|
||||
"excludedMountsLabel": "Excluded Mounts",
|
||||
"monitoredMountsLabel": "Custom monitored paths",
|
||||
"monitoredMountsDesc": "Add paths that are not exposed as separate mounts, including bind-mounted Home Assistant paths such as /config.",
|
||||
"monitoredMountPathPlaceholder": "Path (/config, /mnt/data)",
|
||||
"monitoredMountLabelPlaceholder": "標籤(選填)",
|
||||
"addMonitoredMount": "Add path",
|
||||
"excludedMountsDesc": "Hide specific mount points or filesystem types (e.g. nfs, cifs) from disk usage and alerts for this host.",
|
||||
"excludedMountsPlaceholder": "Mount path (/mnt/nas) or filesystem type (nfs, cifs)",
|
||||
"excludedMountsEmpty": "No mounts excluded. All detected filesystems are shown.",
|
||||
"addExcludedMount": "新增",
|
||||
"visibleWidgets": "可見的小工具",
|
||||
"widgetsMovedToHostMetrics": "現在卡片可直接在主機指標分頁中新增、排列和調整大小。開啟此主機的主機指標,然後使用自訂功能選擇要顯示的卡片。",
|
||||
"hostNotFound": "找不到主機",
|
||||
"loadingHost": "正在載入主機..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
"feature_metrics_desc": "Track CPU, memory and disk over time, and get alerted when they go bad."
|
||||
},
|
||||
"admin": {
|
||||
"metricsHistoryRetention": "指標歷史保留",
|
||||
"metricsHistoryRetentionRange": "1 至 90 天",
|
||||
"days": "天"
|
||||
},
|
||||
"homepage": {
|
||||
"noData": "無可用資料",
|
||||
"showCurrentValue": "顯示目前值",
|
||||
"chartMetric": "指標",
|
||||
"metricRange": "範圍",
|
||||
"host": "主機",
|
||||
"loading": "載入中...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "磁碟",
|
||||
"metricHostname": "主機名稱",
|
||||
"metricKernel": "核心",
|
||||
"metricMemory": "記憶體",
|
||||
"metricNetwork": "網路",
|
||||
"metricOs": "作業系統",
|
||||
"metricProcesses": "程序",
|
||||
"metricProcessesRunning": "執行中",
|
||||
"metricProcessesTotal": "總數",
|
||||
"metricUptime": "運作時間",
|
||||
"metricsNotAvailable": "效能指標無法使用",
|
||||
"noHostSelected": "未選取主機",
|
||||
"selectHost": "選擇主機..."
|
||||
},
|
||||
"terminalStatus": {
|
||||
"unavailable": "Live stats unavailable"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "磁碟"
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "已連線"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Host Metrics",
|
||||
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)",
|
||||
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
|
||||
},
|
||||
"historyRetentionDays": {
|
||||
"label": "History retention (days)",
|
||||
"description": "How long metrics samples are kept for the history charts."
|
||||
},
|
||||
"enabledForNewHosts": {
|
||||
"label": "Metrics on for new hosts",
|
||||
"description": "Whether new hosts start with metrics collection turned on."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"temperatureUnit": {
|
||||
"label": "Temperature unit",
|
||||
"description": "How temperatures are shown in Host Metrics.",
|
||||
"celsius": "Celsius",
|
||||
"fahrenheit": "Fahrenheit"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"metricsEnabled": {
|
||||
"label": "Collect metrics"
|
||||
},
|
||||
"metricsInterval": {
|
||||
"label": "Metrics interval (seconds)"
|
||||
},
|
||||
"enabledWidgets": {
|
||||
"label": "Enabled widgets"
|
||||
},
|
||||
"excludedMounts": {
|
||||
"label": "Excluded mounts"
|
||||
},
|
||||
"monitoredMounts": {
|
||||
"label": "Monitored paths"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"name": "Host Metrics",
|
||||
"version": "1.0.0",
|
||||
"description": "Live CPU, memory, disk, network and process stats, plus tools to manage services, packages, firewall rules, cron jobs, users and more.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Monitoring",
|
||||
"icon": "Activity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"credentials:use",
|
||||
"credentials:read",
|
||||
"ssh:connect",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"events:core",
|
||||
"users:impersonate"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"service": "host-metrics.viewers",
|
||||
"version": "1.0.0",
|
||||
"permission": "host-metrics.use"
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"titleKey": "nav.hostMetrics",
|
||||
"icon": "Activity",
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use",
|
||||
"titleKey": "permissions.use.title",
|
||||
"descriptionKey": "permissions.use.description",
|
||||
"defaultRoles": ["admin", "user"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"admin": [
|
||||
{
|
||||
"key": "metricsInterval",
|
||||
"type": "number",
|
||||
"labelKey": "settings.admin.metricsInterval.label",
|
||||
"descriptionKey": "settings.admin.metricsInterval.description",
|
||||
"default": 30,
|
||||
"min": 5,
|
||||
"max": 3600
|
||||
},
|
||||
{
|
||||
"key": "historyRetentionDays",
|
||||
"type": "number",
|
||||
"labelKey": "settings.admin.historyRetentionDays.label",
|
||||
"descriptionKey": "settings.admin.historyRetentionDays.description",
|
||||
"default": 7,
|
||||
"min": 1,
|
||||
"max": 90
|
||||
},
|
||||
{
|
||||
"key": "enabledForNewHosts",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.admin.enabledForNewHosts.label",
|
||||
"descriptionKey": "settings.admin.enabledForNewHosts.description",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"user": [
|
||||
{
|
||||
"key": "temperatureUnit",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.temperatureUnit.label",
|
||||
"descriptionKey": "settings.user.temperatureUnit.description",
|
||||
"default": "celsius",
|
||||
"options": [
|
||||
{
|
||||
"value": "celsius",
|
||||
"labelKey": "settings.user.temperatureUnit.celsius"
|
||||
},
|
||||
{
|
||||
"value": "fahrenheit",
|
||||
"labelKey": "settings.user.temperatureUnit.fahrenheit"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"host": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "metricsEnabled",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.metricsEnabled.label",
|
||||
"default": true,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "metricsInterval",
|
||||
"type": "number",
|
||||
"labelKey": "settings.host.metricsInterval.label",
|
||||
"min": 5,
|
||||
"max": 3600,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "enabledWidgets",
|
||||
"type": "json",
|
||||
"labelKey": "settings.host.enabledWidgets.label",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "excludedMounts",
|
||||
"type": "json",
|
||||
"labelKey": "settings.host.excludedMounts.label",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "monitoredMounts",
|
||||
"type": "json",
|
||||
"labelKey": "settings.host.monitoredMounts.label",
|
||||
"hidden": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"actionSlots": [
|
||||
{
|
||||
"id": "host-metrics.managers",
|
||||
"accepts": ["component"]
|
||||
},
|
||||
{
|
||||
"id": "host-metrics.toolbar",
|
||||
"accepts": ["component"]
|
||||
}
|
||||
],
|
||||
"uiPresets": {
|
||||
"simple": {
|
||||
"columns": 1
|
||||
},
|
||||
"balanced": {
|
||||
"columns": 3
|
||||
},
|
||||
"advanced": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
"uses": [
|
||||
"homepage.hostMetrics",
|
||||
"homepage.widgets",
|
||||
"terminal.toolbarStatus"
|
||||
]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
-- host-metrics 0001: adopt_host_metrics_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `host_metrics_preferences` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`host_id` int NOT NULL,
|
||||
`layout` text NOT NULL,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `host_metrics_preferences` TO `p_host_metrics_host_metrics_preferences`;
|
||||
CREATE TABLE IF NOT EXISTS `host_health_checks` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`host_id` int NOT NULL,
|
||||
`checks` text NOT NULL,
|
||||
`interval_seconds` int NOT NULL DEFAULT 300,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `host_health_checks` TO `p_host_metrics_host_health_checks`;
|
||||
CREATE TABLE IF NOT EXISTS `host_health_history` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`host_id` int NOT NULL,
|
||||
`check_id` varchar(255) NOT NULL,
|
||||
`ts` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`ok` boolean NOT NULL,
|
||||
`latency_ms` int,
|
||||
`detail` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `host_health_history` TO `p_host_metrics_host_health_history`;
|
||||
CREATE TABLE IF NOT EXISTS `host_metrics_history` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`host_id` int NOT NULL,
|
||||
`ts` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`cpu_percent` double,
|
||||
`mem_percent` double,
|
||||
`disk_percent` double,
|
||||
`net_rx_bytes` int,
|
||||
`net_tx_bytes` int,
|
||||
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `host_metrics_history` TO `p_host_metrics_host_metrics_history`;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- host-metrics 0001: adopt_host_metrics_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "host_metrics_preferences" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"layout" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_host_metrics_prefs_user_host" ON "host_metrics_preferences" ("user_id", "host_id");
|
||||
ALTER TABLE "host_metrics_preferences" RENAME TO "p_host_metrics_host_metrics_preferences";
|
||||
CREATE TABLE IF NOT EXISTS "host_health_checks" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"checks" text NOT NULL,
|
||||
"interval_seconds" integer NOT NULL DEFAULT 300,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_host_health_checks_user_host" ON "host_health_checks" ("user_id", "host_id");
|
||||
ALTER TABLE "host_health_checks" RENAME TO "p_host_metrics_host_health_checks";
|
||||
CREATE TABLE IF NOT EXISTS "host_health_history" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"check_id" varchar(255) NOT NULL,
|
||||
"ts" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ok" boolean NOT NULL,
|
||||
"latency_ms" integer,
|
||||
"detail" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_host_health_history_lookup" ON "host_health_history" ("user_id", "host_id", "check_id", "ts");
|
||||
ALTER TABLE "host_health_history" RENAME TO "p_host_metrics_host_health_history";
|
||||
CREATE TABLE IF NOT EXISTS "host_metrics_history" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"host_id" integer NOT NULL,
|
||||
"ts" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"cpu_percent" double precision,
|
||||
"mem_percent" double precision,
|
||||
"disk_percent" double precision,
|
||||
"net_rx_bytes" integer,
|
||||
"net_tx_bytes" integer,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_host_metrics_history_host_ts" ON "host_metrics_history" ("host_id", "ts");
|
||||
ALTER TABLE "host_metrics_history" RENAME TO "p_host_metrics_host_metrics_history";
|
||||
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"host_metrics_preferences": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"hostId": {
|
||||
"type": "refHost",
|
||||
"notNull": true
|
||||
},
|
||||
"layout": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_host_metrics_prefs_user_host",
|
||||
"columns": ["userId", "hostId"],
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"adopts": "host_metrics_preferences"
|
||||
},
|
||||
"host_health_checks": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"hostId": {
|
||||
"type": "refHost",
|
||||
"notNull": true
|
||||
},
|
||||
"checks": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"intervalSeconds": {
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": 300
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_host_health_checks_user_host",
|
||||
"columns": ["userId", "hostId"],
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"adopts": "host_health_checks"
|
||||
},
|
||||
"host_health_history": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"hostId": {
|
||||
"type": "refHost",
|
||||
"notNull": true
|
||||
},
|
||||
"checkId": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"ts": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"ok": {
|
||||
"type": "boolean",
|
||||
"notNull": true
|
||||
},
|
||||
"latencyMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"detail": {
|
||||
"type": "text"
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_host_health_history_lookup",
|
||||
"columns": ["userId", "hostId", "checkId", "ts"]
|
||||
}
|
||||
],
|
||||
"adopts": "host_health_history"
|
||||
},
|
||||
"host_metrics_history": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"hostId": {
|
||||
"type": "refHost",
|
||||
"notNull": true
|
||||
},
|
||||
"ts": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"cpuPercent": {
|
||||
"type": "real"
|
||||
},
|
||||
"memPercent": {
|
||||
"type": "real"
|
||||
},
|
||||
"diskPercent": {
|
||||
"type": "real"
|
||||
},
|
||||
"netRxBytes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"netTxBytes": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_host_metrics_history_host_ts",
|
||||
"columns": ["hostId", "ts"]
|
||||
}
|
||||
],
|
||||
"adopts": "host_metrics_history"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
-- host-metrics 0001: adopt_host_metrics_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "host_metrics_preferences" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"layout" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_host_metrics_prefs_user_host" ON "host_metrics_preferences" ("user_id", "host_id");
|
||||
ALTER TABLE "host_metrics_preferences" RENAME TO "p_host_metrics_host_metrics_preferences";
|
||||
CREATE TABLE IF NOT EXISTS "host_health_checks" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"checks" text NOT NULL,
|
||||
"interval_seconds" integer NOT NULL DEFAULT 300,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_host_health_checks_user_host" ON "host_health_checks" ("user_id", "host_id");
|
||||
ALTER TABLE "host_health_checks" RENAME TO "p_host_metrics_host_health_checks";
|
||||
CREATE TABLE IF NOT EXISTS "host_health_history" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"check_id" text NOT NULL,
|
||||
"ts" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ok" integer NOT NULL,
|
||||
"latency_ms" integer,
|
||||
"detail" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_host_health_history_lookup" ON "host_health_history" ("user_id", "host_id", "check_id", "ts");
|
||||
ALTER TABLE "host_health_history" RENAME TO "p_host_metrics_host_health_history";
|
||||
CREATE TABLE IF NOT EXISTS "host_metrics_history" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"host_id" integer NOT NULL,
|
||||
"ts" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"cpu_percent" real,
|
||||
"mem_percent" real,
|
||||
"disk_percent" real,
|
||||
"net_rx_bytes" integer,
|
||||
"net_tx_bytes" integer,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_host_metrics_history_host_ts" ON "host_metrics_history" ("host_id", "ts");
|
||||
ALTER TABLE "host_metrics_history" RENAME TO "p_host_metrics_host_metrics_history";
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@termix-plugin/host-metrics",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p .",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8",
|
||||
"ssh2": "^1.17.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginSsh } from "@termix/plugin-sdk/backend";
|
||||
import { detectHostPlatform } from "@termix/plugin-sdk/host-commands";
|
||||
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
|
||||
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
|
||||
import { collectDiskMetrics } from "./widgets/disk-collector.js";
|
||||
import { collectNetworkMetrics } from "./widgets/network-collector.js";
|
||||
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
|
||||
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
|
||||
import { collectSystemMetrics } from "./widgets/system-collector.js";
|
||||
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
||||
import { collectPortsMetrics } from "./widgets/ports-collector.js";
|
||||
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
|
||||
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
|
||||
import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js";
|
||||
import type { FirewallMetrics, PortsMetrics } from "../shared/stats-widgets.js";
|
||||
import { supportsMetrics, type MetricsHost } from "./helpers.js";
|
||||
import { sessionKey, type MetricsSessions } from "./sessions.js";
|
||||
import type { MetricsState } from "./state.js";
|
||||
import type { CollectorRegistry } from "./collectors.js";
|
||||
|
||||
export function isHostKeyVerificationError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message.includes("Host denied (verification failed)") ||
|
||||
error.message.includes("Host key changed"))
|
||||
);
|
||||
}
|
||||
|
||||
export type CollectedMetrics = {
|
||||
cpu: Awaited<ReturnType<typeof collectCpuMetrics>>;
|
||||
memory: Awaited<ReturnType<typeof collectMemoryMetrics>>;
|
||||
disk: Awaited<ReturnType<typeof collectDiskMetrics>>;
|
||||
network: Awaited<ReturnType<typeof collectNetworkMetrics>>;
|
||||
uptime: Awaited<ReturnType<typeof collectUptimeMetrics>>;
|
||||
processes: Awaited<ReturnType<typeof collectProcessesMetrics>>;
|
||||
system: Awaited<ReturnType<typeof collectSystemMetrics>>;
|
||||
login_stats: Awaited<ReturnType<typeof collectLoginStats>>;
|
||||
ports: PortsMetrics;
|
||||
firewall: FirewallMetrics;
|
||||
temperature: Awaited<ReturnType<typeof collectTemperatureMetrics>>;
|
||||
gpu: GpuMetrics;
|
||||
/** Results from other plugins' collectors, by collector id. */
|
||||
extra?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface CollectDeps {
|
||||
ssh: PluginSsh;
|
||||
state: MetricsState;
|
||||
sessions: MetricsSessions;
|
||||
collectors: CollectorRegistry;
|
||||
}
|
||||
|
||||
async function optional<T>(collect: () => Promise<T>, fallback: T) {
|
||||
try {
|
||||
return await collect();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One sample. Reuses a connection the person opened from the tab when there
|
||||
* is one, otherwise borrows the "stats" pool, which answers password prompts
|
||||
* only and fails fast on anything that needs a person.
|
||||
*/
|
||||
export async function collectMetrics(
|
||||
deps: CollectDeps,
|
||||
host: MetricsHost,
|
||||
mounts: {
|
||||
excludedMounts: string[];
|
||||
monitoredMounts: Array<{ path: string; label?: string }>;
|
||||
},
|
||||
onAuthenticated?: () => void,
|
||||
): Promise<CollectedMetrics> {
|
||||
const { ssh, state, sessions, collectors } = deps;
|
||||
if (!supportsMetrics(host, ssh)) {
|
||||
throw new Error("Metrics collection only supported for SSH hosts");
|
||||
}
|
||||
|
||||
if (state.authFailures.shouldSkip(host.id)) {
|
||||
const reason = state.authFailures.getSkipReason(host.id);
|
||||
throw new Error(reason || "Authentication failed");
|
||||
}
|
||||
|
||||
const cached = state.metricsCache.get(host.id) as CollectedMetrics | null;
|
||||
if (cached) {
|
||||
onAuthenticated?.();
|
||||
return cached;
|
||||
}
|
||||
|
||||
return state.requestQueue.queueRequest(host.id, async () => {
|
||||
const key = sessionKey(host.id, host.userId);
|
||||
const existing = sessions.get(key);
|
||||
|
||||
try {
|
||||
const collect = async (client: Client): Promise<CollectedMetrics> => {
|
||||
onAuthenticated?.();
|
||||
const platform = await detectHostPlatform(client);
|
||||
const result: CollectedMetrics = {
|
||||
cpu: await collectCpuMetrics(
|
||||
client,
|
||||
platform,
|
||||
host.id,
|
||||
state.rateSamples.cpu,
|
||||
),
|
||||
memory: await collectMemoryMetrics(client, platform),
|
||||
disk: await collectDiskMetrics(
|
||||
client,
|
||||
mounts.excludedMounts,
|
||||
mounts.monitoredMounts,
|
||||
platform,
|
||||
),
|
||||
network: await collectNetworkMetrics(
|
||||
client,
|
||||
platform,
|
||||
host.id,
|
||||
state.rateSamples.network,
|
||||
),
|
||||
uptime: await collectUptimeMetrics(client, platform),
|
||||
processes: await collectProcessesMetrics(client),
|
||||
system: await collectSystemMetrics(client, platform),
|
||||
login_stats: await optional(() => collectLoginStats(client), {
|
||||
recentLogins: [],
|
||||
failedLogins: [],
|
||||
totalLogins: 0,
|
||||
uniqueIPs: 0,
|
||||
}),
|
||||
ports: await optional(() => collectPortsMetrics(client), {
|
||||
source: "none",
|
||||
ports: [],
|
||||
}),
|
||||
firewall: await optional(() => collectFirewallMetrics(client), {
|
||||
type: "none",
|
||||
status: "unknown",
|
||||
chains: [],
|
||||
}),
|
||||
temperature: await optional(() => collectTemperatureMetrics(client), {
|
||||
source: "none",
|
||||
highestCelsius: null,
|
||||
sensors: [],
|
||||
}),
|
||||
gpu: await optional(() => collectGpuMetrics(client), {
|
||||
source: "none",
|
||||
gpus: [],
|
||||
processes: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const extra = await collectors.run(client, host.id, host.userId);
|
||||
if (Object.keys(extra).length > 0) result.extra = extra;
|
||||
|
||||
state.metricsCache.set(host.id, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
if (existing?.isConnected) {
|
||||
existing.activeOperations++;
|
||||
try {
|
||||
const result = await collect(existing.client);
|
||||
sessions.touch(key);
|
||||
return result;
|
||||
} finally {
|
||||
existing.activeOperations--;
|
||||
}
|
||||
}
|
||||
|
||||
return await ssh.withConnection<CollectedMetrics, Client>(
|
||||
host,
|
||||
{
|
||||
pool: "stats",
|
||||
purpose: "metrics",
|
||||
overrides: { readyTimeout: 60000 },
|
||||
},
|
||||
collect,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("TOTP authentication required")) {
|
||||
throw error;
|
||||
} else if (
|
||||
error.message.includes("No password available") ||
|
||||
error.message.includes("Unsupported authentication type") ||
|
||||
error.message.includes("No SSH key available") ||
|
||||
error.message.includes("Invalid SSH key format")
|
||||
) {
|
||||
state.authFailures.recordFailure(host.id, "AUTH", true);
|
||||
} else if (isHostKeyVerificationError(error)) {
|
||||
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
|
||||
} else if (
|
||||
error.message.includes("authentication") ||
|
||||
error.message.includes("Permission denied") ||
|
||||
error.message.includes("All configured authentication methods failed")
|
||||
) {
|
||||
state.authFailures.recordFailure(host.id, "AUTH");
|
||||
} else if (
|
||||
error.message.includes("timeout") ||
|
||||
error.message.includes("ETIMEDOUT")
|
||||
) {
|
||||
state.authFailures.recordFailure(host.id, "TIMEOUT");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
import type { MetricsLogger } from "./log.js";
|
||||
|
||||
export const COLLECTORS_SERVICE = "host-metrics.collectors";
|
||||
|
||||
/**
|
||||
* The "host-metrics.collectors" service, version 1. Another plugin provides
|
||||
* it under its own provider name (manifest `provides[].names`) to add data
|
||||
* to every sample. It runs on the sample's SSH connection, as the host's
|
||||
* owner, and its result lands in the snapshot under `extra[<name>]`. A
|
||||
* collector that throws is skipped for that sample.
|
||||
*/
|
||||
export interface MetricsCollectorV1 {
|
||||
collect: (client: Client, hostId: number) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export class CollectorRegistry {
|
||||
constructor(
|
||||
private readonly services: PluginServices,
|
||||
private readonly log: MetricsLogger,
|
||||
) {}
|
||||
|
||||
async run(
|
||||
client: Client,
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const results: Record<string, unknown> = {};
|
||||
for (const provider of this.services.providers(COLLECTORS_SERVICE)) {
|
||||
if (!provider) continue;
|
||||
try {
|
||||
const collector = this.services.get<Partial<MetricsCollectorV1>>(
|
||||
COLLECTORS_SERVICE,
|
||||
{ provider, userId: ownerUserId },
|
||||
);
|
||||
if (typeof collector.collect !== "function") continue;
|
||||
results[provider] = await collector.collect(client, hostId);
|
||||
} catch (error) {
|
||||
this.log.debug(`Metrics collector ${provider} failed`, {
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PluginSsh, PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/** A host as ctx.ssh.resolveHost returns it, with the fields polling reads. */
|
||||
export type MetricsHost = PluginSshHost & {
|
||||
userId: string;
|
||||
name?: string | null;
|
||||
connectionType?: string | null;
|
||||
enableSsh?: boolean | null;
|
||||
password?: string | null;
|
||||
sudoPassword?: string | null;
|
||||
enableDocker?: boolean | null;
|
||||
jumpHosts?: Array<{ hostId: number }> | null;
|
||||
};
|
||||
|
||||
/** Metrics run over SSH, unattended, so the auth type has to allow that. */
|
||||
export function supportsMetrics(
|
||||
host: Pick<MetricsHost, "connectionType" | "enableSsh" | "authType">,
|
||||
ssh: Pick<PluginSsh, "supportsBackground">,
|
||||
): boolean {
|
||||
const connectionType = host.connectionType || "ssh";
|
||||
if (connectionType !== "ssh" && host.enableSsh !== true) return false;
|
||||
return ssh.supportsBackground(host.authType || "none");
|
||||
}
|
||||
|
||||
export function sudoPasswordOf(host: MetricsHost): string | undefined {
|
||||
return host.sudoPassword || undefined;
|
||||
}
|
||||
|
||||
export type ConnectionLogType = "info" | "success" | "warning" | "error";
|
||||
|
||||
export interface ConnectionLog {
|
||||
type: ConnectionLogType;
|
||||
stage: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The entry shape the connection log panel reads. */
|
||||
export function connectionLog(
|
||||
type: ConnectionLogType,
|
||||
stage: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): ConnectionLog {
|
||||
return { type, stage, message, ...(details ? { details } : {}) };
|
||||
}
|
||||
|
||||
export function newSessionId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
|
||||
|
||||
/**
|
||||
* Registered as ctx.registry.provide("host-metrics.hostImportNormalizer", ...)
|
||||
* for core's bulk host import. A Termix export carries these settings under
|
||||
* pluginSettings["host-metrics"]; exports from before 2.9.0 carried them in
|
||||
* statsConfig, where the host's own interval only counted when
|
||||
* useGlobalMetricsInterval was false.
|
||||
*/
|
||||
export function hostImportNormalizer(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const bag = raw.pluginSettings as
|
||||
Record<string, Record<string, unknown>> | undefined;
|
||||
const own = bag?.["host-metrics"];
|
||||
if (own && typeof own === "object") {
|
||||
const settings = readHostMetricsSettings(own);
|
||||
return { ...settings };
|
||||
}
|
||||
|
||||
const legacy =
|
||||
raw.statsConfig && typeof raw.statsConfig === "object"
|
||||
? (raw.statsConfig as Record<string, unknown>)
|
||||
: null;
|
||||
if (!legacy) return null;
|
||||
const settings = readHostMetricsSettings({
|
||||
...legacy,
|
||||
metricsInterval:
|
||||
legacy.useGlobalMetricsInterval === false ? legacy.metricsInterval : null,
|
||||
});
|
||||
return { ...settings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered as "host-metrics.hostPayloadLegacy": the 2.8 statsConfig shape
|
||||
* Termix-Mobile still reads. Remove once the mobile app reads
|
||||
* pluginSettings["host-metrics"] and GET /host/status.
|
||||
*/
|
||||
export function hostPayloadLegacy(
|
||||
values: Record<string, unknown>,
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const settings = readHostMetricsSettings(values);
|
||||
const statusInterval =
|
||||
typeof host.statusCheckInterval === "number"
|
||||
? host.statusCheckInterval
|
||||
: null;
|
||||
return {
|
||||
statsConfig: {
|
||||
enabledWidgets: settings.enabledWidgets,
|
||||
statusCheckEnabled: host.statusCheckEnabled !== false,
|
||||
statusCheckInterval: statusInterval ?? 30,
|
||||
useGlobalStatusInterval: statusInterval === null,
|
||||
metricsEnabled: settings.metricsEnabled,
|
||||
metricsInterval: settings.metricsInterval ?? 30,
|
||||
useGlobalMetricsInterval: settings.metricsInterval == null,
|
||||
excludedMounts: settings.excludedMounts,
|
||||
monitoredMounts: settings.monitoredMounts,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { createLogger } from "./log.js";
|
||||
import { createHostMetricsRepository } from "./repository.js";
|
||||
import { createMetricsState } from "./state.js";
|
||||
import { MetricsSessions } from "./sessions.js";
|
||||
import { CollectorRegistry } from "./collectors.js";
|
||||
import { MetricsPoller, TOPIC_HEALTH_CHECK } from "./poller.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { hostImportNormalizer, hostPayloadLegacy } from "./host-import.js";
|
||||
import { newSessionId, supportsMetrics } from "./helpers.js";
|
||||
|
||||
export type { MetricsCollectorV1 } from "./collectors.js";
|
||||
export type { CollectionStatusPayload, SnapshotPayload } from "./poller.js";
|
||||
|
||||
/** The "host-metrics.viewers" service, version 1. Runs as the caller's user. */
|
||||
export interface MetricsViewersV1 {
|
||||
register: (
|
||||
hostId: number,
|
||||
) => Promise<{ viewerSessionId: string } | { skipped: true; reason: string }>;
|
||||
heartbeat: (viewerSessionId: string) => boolean;
|
||||
unregister: (hostId: number, viewerSessionId: string) => void;
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const log = createLogger(ctx.log);
|
||||
const repository = await createHostMetricsRepository(ctx.db);
|
||||
const state = createMetricsState();
|
||||
const sessions = new MetricsSessions(ctx.schedule, log);
|
||||
const collectors = new CollectorRegistry(ctx.services, log);
|
||||
const poller = new MetricsPoller({
|
||||
ctx,
|
||||
log,
|
||||
repository,
|
||||
ssh: ctx.ssh,
|
||||
state,
|
||||
sessions,
|
||||
collectors,
|
||||
});
|
||||
ctx.disposables.add(() => {
|
||||
poller.dispose();
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
registerRoutes(ctx.http.router<Router>(), {
|
||||
ctx,
|
||||
log,
|
||||
poller,
|
||||
sessions,
|
||||
repository,
|
||||
onHealthCheck: (event) => ctx.events.emit(TOPIC_HEALTH_CHECK, event),
|
||||
});
|
||||
|
||||
ctx.schedule.every(60_000, () => poller.cleanupInactiveViewers());
|
||||
ctx.schedule.every(10 * 60_000, () => {
|
||||
state.authFailures.cleanup();
|
||||
state.backoff.cleanup();
|
||||
});
|
||||
|
||||
ctx.events.on("host.updated", (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) void poller.hostUpdated(hostId);
|
||||
});
|
||||
ctx.events.on("host.deleted", (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) poller.hostDeleted(hostId);
|
||||
});
|
||||
ctx.events.on("host.status", (payload) => {
|
||||
const { hostId, status } = payload as { hostId?: number; status?: string };
|
||||
if (hostId && status) poller.hostStatusChanged(hostId, status);
|
||||
});
|
||||
ctx.events.on("host.key.updated", (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) poller.hostKeyAccepted(hostId);
|
||||
});
|
||||
ctx.settings.onChange("metricsInterval", () => void poller.retimeAll());
|
||||
|
||||
ctx.services.provide<MetricsViewersV1>("host-metrics.viewers", {
|
||||
register: async (hostId) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return { skipped: true, reason: "no_user" };
|
||||
const host = await poller.resolve(hostId, userId);
|
||||
if (!host) return { skipped: true, reason: "host_not_found" };
|
||||
if (!supportsMetrics(host, ctx.ssh)) {
|
||||
return { skipped: true, reason: "metrics_unsupported" };
|
||||
}
|
||||
if (!(await poller.settingsFor(hostId)).metricsEnabled) {
|
||||
return { skipped: true, reason: "metrics_disabled" };
|
||||
}
|
||||
const viewerSessionId = newSessionId("service");
|
||||
poller.registerViewer(hostId, viewerSessionId, userId);
|
||||
return { viewerSessionId };
|
||||
},
|
||||
heartbeat: (viewerSessionId) =>
|
||||
poller.updateHeartbeat(viewerSessionId, ctx.currentActor()),
|
||||
unregister: (hostId, viewerSessionId) =>
|
||||
poller.unregisterViewer(hostId, viewerSessionId, ctx.currentActor()),
|
||||
});
|
||||
|
||||
ctx.registry.provide(
|
||||
"host-metrics.hostImportNormalizer",
|
||||
hostImportNormalizer,
|
||||
);
|
||||
ctx.registry.provide("host-metrics.hostPayloadLegacy", hostPayloadLegacy);
|
||||
|
||||
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
|
||||
}
|
||||
|
||||
export async function deactivate() {}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import type {
|
||||
PluginSsh,
|
||||
PluginSshPromptChannel,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
connectionLog,
|
||||
newSessionId,
|
||||
type ConnectionLog,
|
||||
type MetricsHost,
|
||||
} from "./helpers.js";
|
||||
import type { MetricsLogger } from "./log.js";
|
||||
import { sessionKey, type MetricsSessions } from "./sessions.js";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 60_000;
|
||||
const TOTP_TTL_MS = 180_000;
|
||||
const MAX_TOTP_ATTEMPTS = 3;
|
||||
|
||||
export type StartResult =
|
||||
| { status: 200; body: Record<string, unknown> }
|
||||
| { status: 400 | 401 | 500; body: Record<string, unknown> };
|
||||
|
||||
export interface InteractiveDeps {
|
||||
ssh: PluginSsh;
|
||||
sessions: MetricsSessions;
|
||||
log: MetricsLogger;
|
||||
registerViewer: (hostId: number, sessionId: string, userId: string) => void;
|
||||
}
|
||||
|
||||
function stageForError(message: string): [string, string] {
|
||||
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
|
||||
return ["dns", `DNS resolution failed: ${message}`];
|
||||
}
|
||||
if (message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT")) {
|
||||
return ["tcp", `TCP connection failed: ${message}`];
|
||||
}
|
||||
if (message.includes("handshake") || message.includes("key exchange")) {
|
||||
return ["handshake", `SSH handshake failed: ${message}`];
|
||||
}
|
||||
if (/authentication/i.test(message)) {
|
||||
return ["auth", `Authentication failed: ${message}`];
|
||||
}
|
||||
if (message.includes("verification failed")) {
|
||||
return [
|
||||
"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 ["error", `SSH connection failed: ${message}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a connection from the tab, where a person can answer a TOTP prompt,
|
||||
* and keeps it for polling. Resolves with the HTTP answer.
|
||||
*/
|
||||
export async function startInteractive(
|
||||
deps: InteractiveDeps,
|
||||
host: MetricsHost,
|
||||
userId: string,
|
||||
): Promise<StartResult> {
|
||||
const { ssh, sessions, log } = deps;
|
||||
const logs: ConnectionLog[] = [
|
||||
connectionLog("info", "stats_connecting", "Starting metrics collection"),
|
||||
connectionLog("info", "dns", `Resolving DNS for ${host.ip}`),
|
||||
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
|
||||
connectionLog("info", "handshake", "Initiating SSH handshake"),
|
||||
];
|
||||
if (host.authType === "password") {
|
||||
logs.push(connectionLog("info", "auth", "Authenticating with password"));
|
||||
} else if (host.authType === "key") {
|
||||
logs.push(connectionLog("info", "auth", "Authenticating with SSH key"));
|
||||
}
|
||||
|
||||
const key = sessionKey(host.id, userId);
|
||||
if (sessions.get(key)?.isConnected) {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"success",
|
||||
"stats_polling",
|
||||
"Using existing metrics session",
|
||||
),
|
||||
);
|
||||
return { status: 200, body: { success: true, connectionLogs: logs } };
|
||||
}
|
||||
|
||||
const client = new Client();
|
||||
const prepared = await ssh.prepare(host, {
|
||||
purpose: "metrics",
|
||||
client,
|
||||
log: (level, message) => logs.push(connectionLog(level, "auth", message)),
|
||||
});
|
||||
const config = prepared.config as ConnectConfig;
|
||||
config.readyTimeout = CONNECT_TIMEOUT_MS;
|
||||
if (prepared.outcome.status !== "ready") {
|
||||
const outcome = prepared.outcome;
|
||||
logs.push(connectionLog("error", "auth", outcome.message));
|
||||
if (outcome.status === "interaction-required") {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: outcome.message,
|
||||
requiresAuthInteraction: outcome.interaction,
|
||||
...(outcome.flag ? { [outcome.flag]: true } : {}),
|
||||
connectionLogs: logs,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: outcome.message, connectionLogs: logs },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await new Promise<Record<string, unknown>>(
|
||||
(resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
fn();
|
||||
};
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
settle(() => {
|
||||
client.end();
|
||||
reject(new Error("Connection timeout"));
|
||||
}),
|
||||
CONNECT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
client.on(
|
||||
"keyboard-interactive",
|
||||
(name, instructions, _lang, prompts, finish) => {
|
||||
const decision = ssh.classifyKeyboardInteractive(
|
||||
{ name, instructions, prompts },
|
||||
host,
|
||||
);
|
||||
if (decision.kind !== "totp") {
|
||||
finish(ssh.autoResponses(prompts, host.password));
|
||||
return;
|
||||
}
|
||||
const sessionId = newSessionId("totp");
|
||||
sessions.addPending({
|
||||
client,
|
||||
finish,
|
||||
config,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
userId,
|
||||
prompts: prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo ?? false,
|
||||
})),
|
||||
totpPromptIndex: decision.promptIndex,
|
||||
resolvedPassword: host.password ?? undefined,
|
||||
totpAttempts: 0,
|
||||
});
|
||||
logs.push(
|
||||
connectionLog("info", "stats_totp", "TOTP verification required"),
|
||||
);
|
||||
settle(() =>
|
||||
resolve({
|
||||
success: false,
|
||||
requires_totp: true,
|
||||
sessionId,
|
||||
prompt: prompts[decision.promptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
client.on("ready", () =>
|
||||
settle(() => {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"success",
|
||||
"connected",
|
||||
"SSH connection established successfully",
|
||||
),
|
||||
connectionLog(
|
||||
"success",
|
||||
"stats_polling",
|
||||
"Metrics session established",
|
||||
),
|
||||
);
|
||||
sessions.open(key, {
|
||||
client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
hostId: host.id,
|
||||
userId,
|
||||
});
|
||||
const viewerSessionId = newSessionId("viewer");
|
||||
deps.registerViewer(host.id, viewerSessionId, userId);
|
||||
resolve({ success: true, viewerSessionId });
|
||||
}),
|
||||
);
|
||||
|
||||
client.on("error", (error) =>
|
||||
settle(() => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const [stage, text] = stageForError(message);
|
||||
logs.push(connectionLog("error", stage, text));
|
||||
log.error("SSH connection error in metrics/start", {
|
||||
operation: "metrics_start_ssh_error",
|
||||
hostId: host.id,
|
||||
error: message,
|
||||
});
|
||||
reject(error);
|
||||
}),
|
||||
);
|
||||
|
||||
if (Array.isArray(host.jumpHosts) && host.jumpHosts.length > 0) {
|
||||
logs.push(
|
||||
connectionLog("info", "proxy", "Connecting via jump host chain"),
|
||||
);
|
||||
}
|
||||
const jumpHostPrompt: PluginSshPromptChannel = {
|
||||
ask: (request) =>
|
||||
new Promise((answer) => {
|
||||
if (request.kind !== "totp" || settled) {
|
||||
answer(null);
|
||||
return;
|
||||
}
|
||||
const sessionId = newSessionId("totp");
|
||||
sessions.addPending({
|
||||
client,
|
||||
finish: (responses) => answer(responses[0] ?? null),
|
||||
config,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
userId,
|
||||
prompts: [{ prompt: request.prompt, echo: false }],
|
||||
totpPromptIndex: 0,
|
||||
resolvedPassword: host.password ?? undefined,
|
||||
totpAttempts: 0,
|
||||
});
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"info",
|
||||
"stats_totp",
|
||||
"Jump host TOTP verification required",
|
||||
),
|
||||
);
|
||||
settle(() =>
|
||||
resolve({
|
||||
success: false,
|
||||
requires_totp: true,
|
||||
sessionId,
|
||||
prompt: request.prompt,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
};
|
||||
ssh
|
||||
.openTransport(host, config as Record<string, unknown>, {
|
||||
prompt: jumpHostPrompt,
|
||||
})
|
||||
.then((transport) => {
|
||||
if (transport.jumpClient) {
|
||||
const jumpClient = transport.jumpClient as Client;
|
||||
client.on("close", () => jumpClient.end());
|
||||
}
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
// Already answered with a jump host prompt: fail the pending submit.
|
||||
if (settled) client.emit("error", error);
|
||||
settle(() => {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"error",
|
||||
"proxy",
|
||||
`Connection setup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
return { status: 200, body: { ...result, connectionLogs: logs } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"error",
|
||||
"stats_connecting",
|
||||
`Failed to start metrics: ${message}`,
|
||||
),
|
||||
);
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
error: message || "Failed to start metrics collection",
|
||||
connectionLogs: logs,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type TotpResult = { status: number; body: Record<string, unknown> };
|
||||
|
||||
/** Sends the TOTP code a person typed and keeps the connection on success. */
|
||||
export async function submitTotp(
|
||||
deps: InteractiveDeps,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
totpCode: string,
|
||||
): Promise<TotpResult> {
|
||||
const { sessions, log } = deps;
|
||||
const session = sessions.getPending(sessionId);
|
||||
if (!session) {
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: "TOTP session not found or expired" },
|
||||
};
|
||||
}
|
||||
if (Date.now() - session.createdAt > TOTP_TTL_MS) {
|
||||
sessions.dropPending(sessionId, true);
|
||||
return { status: 408, body: { error: "TOTP session timeout" } };
|
||||
}
|
||||
if (session.userId !== userId) {
|
||||
return { status: 403, body: { error: "Unauthorized" } };
|
||||
}
|
||||
|
||||
session.totpAttempts++;
|
||||
if (session.totpAttempts > MAX_TOTP_ATTEMPTS) {
|
||||
sessions.dropPending(sessionId, true);
|
||||
return { status: 429, body: { error: "Too many TOTP attempts" } };
|
||||
}
|
||||
|
||||
const passwordFor = (prompt: string) =>
|
||||
/password/i.test(prompt) && session.resolvedPassword
|
||||
? session.resolvedPassword
|
||||
: "";
|
||||
|
||||
try {
|
||||
const responses = (session.prompts || []).map((p, index) =>
|
||||
index === session.totpPromptIndex
|
||||
? totpCode.trim()
|
||||
: passwordFor(p.prompt),
|
||||
);
|
||||
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error("TOTP verification timeout")),
|
||||
30_000,
|
||||
);
|
||||
session.client.once(
|
||||
"keyboard-interactive",
|
||||
(_name, _instructions, _lang, prompts, finish) => {
|
||||
log.warn("Second keyboard-interactive received after TOTP", {
|
||||
operation: "totp_second_keyboard_interactive",
|
||||
hostId: session.hostId,
|
||||
});
|
||||
finish(prompts.map((p) => passwordFor(p.prompt)));
|
||||
},
|
||||
);
|
||||
session.client.once("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
session.client.once("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
session.finish(responses);
|
||||
await ready;
|
||||
|
||||
sessions.open(sessionKey(session.hostId, userId), {
|
||||
client: session.client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
hostId: session.hostId,
|
||||
userId,
|
||||
});
|
||||
sessions.dropPending(sessionId);
|
||||
|
||||
const viewerSessionId = newSessionId("viewer");
|
||||
deps.registerViewer(session.hostId, viewerSessionId, userId);
|
||||
return { status: 200, body: { success: true, viewerSessionId } };
|
||||
} catch (error) {
|
||||
log.error("TOTP verification failed", {
|
||||
operation: "totp_verification_failed",
|
||||
hostId: session.hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (session.totpAttempts >= MAX_TOTP_ATTEMPTS) {
|
||||
sessions.dropPending(sessionId, true);
|
||||
}
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: "TOTP verification failed",
|
||||
attemptsRemaining: Math.max(
|
||||
0,
|
||||
MAX_TOTP_ATTEMPTS - session.totpAttempts,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type Meta = Record<string, unknown>;
|
||||
|
||||
export interface MetricsLogger {
|
||||
debug: (message: string, meta?: Meta) => void;
|
||||
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 {
|
||||
return meta && Object.keys(meta).length > 0
|
||||
? `${message} ${JSON.stringify(meta)}`
|
||||
: message;
|
||||
}
|
||||
|
||||
/** ctx.log with the structured context the old stats logger carried. */
|
||||
export function createLogger(log: PluginLogger): MetricsLogger {
|
||||
return {
|
||||
debug: (message, meta) => log.debug(withMeta(message, meta)),
|
||||
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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
execCommand,
|
||||
shellSingleQuote,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface CronEntry {
|
||||
raw: string;
|
||||
enabled: boolean;
|
||||
schedule: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
const READ_CRONTAB_CMD = "crontab -l 2>/dev/null";
|
||||
|
||||
/** Parse a crontab into entries (comments/blank lines are dropped except as toggles). */
|
||||
export function parseCrontab(output: string): CronEntry[] {
|
||||
const entries: CronEntry[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.replace(/\s+$/, "");
|
||||
if (!line.trim()) continue;
|
||||
// A commented-out job: "# <schedule> <command>" (our toggle convention).
|
||||
const disabled = line.match(
|
||||
/^#\s*((?:@\w+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+.+)$/,
|
||||
);
|
||||
if (disabled) {
|
||||
const { schedule, command } = splitScheduleCommand(disabled[1]);
|
||||
if (command) {
|
||||
entries.push({ raw: line, enabled: false, schedule, command });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Skip pure comments / env assignments.
|
||||
if (line.trimStart().startsWith("#")) continue;
|
||||
if (/^\s*[A-Z_]+=/.test(line)) continue;
|
||||
const { schedule, command } = splitScheduleCommand(line.trim());
|
||||
if (!command) continue;
|
||||
entries.push({ raw: line, enabled: true, schedule, command });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function splitScheduleCommand(line: string): {
|
||||
schedule: string;
|
||||
command: string;
|
||||
} {
|
||||
if (line.startsWith("@")) {
|
||||
const [sched, ...rest] = line.split(/\s+/);
|
||||
return { schedule: sched, command: rest.join(" ") };
|
||||
}
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 6) return { schedule: "", command: "" };
|
||||
return {
|
||||
schedule: parts.slice(0, 5).join(" "),
|
||||
command: parts.slice(5).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
const CRON_SCHEDULE_RE =
|
||||
/^(@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)|[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,A-Za-z-]+\s+[\d*/,A-Za-z-]+)$/;
|
||||
|
||||
export function isValidCronSchedule(schedule: string): boolean {
|
||||
return CRON_SCHEDULE_RE.test(schedule.trim());
|
||||
}
|
||||
|
||||
/** Serialize entries into a full crontab body (toggled entries are commented). */
|
||||
export function serializeCrontab(entries: CronEntry[]): string {
|
||||
const lines = entries.map((e) => {
|
||||
const body = `${e.schedule} ${e.command}`.trim();
|
||||
return e.enabled ? body : `# ${body}`;
|
||||
});
|
||||
return lines.join("\n") + (lines.length ? "\n" : "");
|
||||
}
|
||||
|
||||
/** Build a command that atomically replaces the user crontab from the given body. */
|
||||
export function buildApplyCrontabCommand(body: string): string {
|
||||
// printf the exact bytes into `crontab -` (reads new crontab from stdin).
|
||||
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
|
||||
}
|
||||
|
||||
export function registerCronRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/cron/{id}:
|
||||
* get:
|
||||
* summary: List the SSH user's crontab entries
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The parsed crontab entries. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "cron_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
|
||||
return { entries: parseCrontab(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/cron/{id}:
|
||||
* post:
|
||||
* summary: Replace the SSH user's crontab
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [entries]
|
||||
* properties:
|
||||
* entries: { type: array, items: { type: object, properties: { schedule: { type: string }, command: { type: string }, enabled: { type: boolean } } } }
|
||||
* responses:
|
||||
* 200: { description: The crontab was written. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
deps,
|
||||
"connect",
|
||||
"cron_replace",
|
||||
async (client, _host, req) => {
|
||||
const { entries } = req.body as { entries?: CronEntry[] };
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new ManagerInputError("entries must be an array");
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (typeof e?.command !== "string" || !e.command.trim()) {
|
||||
throw new ManagerInputError("Each entry needs a command");
|
||||
}
|
||||
if (e.command.includes("\n")) {
|
||||
throw new ManagerInputError("Commands cannot contain newlines");
|
||||
}
|
||||
if (!isValidCronSchedule(String(e.schedule))) {
|
||||
throw new ManagerInputError(`Invalid schedule: ${e.schedule}`);
|
||||
}
|
||||
}
|
||||
const body = serializeCrontab(entries);
|
||||
const { stdout, stderr, code } = await execCommand(
|
||||
client,
|
||||
buildApplyCrontabCommand(body),
|
||||
15000,
|
||||
);
|
||||
return { success: code === 0, output: stdout || stderr };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { execElevated, detectPlatform } from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidPort,
|
||||
isValidIpProtocol,
|
||||
isValidFirewallTarget,
|
||||
type IpProtocol,
|
||||
type FirewallTarget,
|
||||
} from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface FirewallRuleSpec {
|
||||
protocol: IpProtocol;
|
||||
port: number;
|
||||
target: FirewallTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an iptables add/delete for an INPUT rule. We only ever touch INPUT for a
|
||||
* specific dport with an explicit target, and never the chain policy, so an
|
||||
* existing ESTABLISHED/SSH rule is left intact.
|
||||
*/
|
||||
export function buildIptablesRuleCommand(
|
||||
op: "add" | "delete",
|
||||
spec: FirewallRuleSpec,
|
||||
): string {
|
||||
const flag = op === "add" ? "-A" : "-D";
|
||||
return `iptables ${flag} INPUT -p ${spec.protocol} --dport ${spec.port} -j ${spec.target}`;
|
||||
}
|
||||
|
||||
export function buildNftRuleCommand(
|
||||
op: "add" | "delete",
|
||||
spec: FirewallRuleSpec,
|
||||
): string {
|
||||
// nftables uses the inet filter table's input chain by convention.
|
||||
const verb = op === "add" ? "add" : "delete";
|
||||
const action =
|
||||
spec.target.toLowerCase() === "reject"
|
||||
? "reject"
|
||||
: spec.target.toLowerCase();
|
||||
return `nft ${verb} rule inet filter input ${spec.protocol} dport ${spec.port} ${action}`;
|
||||
}
|
||||
|
||||
export function registerFirewallRoutes(
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/firewall/{id}:
|
||||
* get:
|
||||
* summary: Read the host firewall (iptables, nftables or ufw)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The firewall type, status and rules. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/firewall/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "firewall_read", async (client) => {
|
||||
return await collectFirewallMetrics(client);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/firewall/{id}/rule:
|
||||
* post:
|
||||
* summary: Add or delete an input rule
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [op, protocol, port, target]
|
||||
* properties:
|
||||
* op: { type: string, enum: [add, delete] }
|
||||
* protocol: { type: string, enum: [tcp, udp] }
|
||||
* port: { type: integer }
|
||||
* target: { type: string, description: ACCEPT, DROP or REJECT }
|
||||
* responses:
|
||||
* 200: { description: The command result and firewall backend used. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/firewall/:id/rule",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
deps,
|
||||
"connect",
|
||||
"firewall_rule",
|
||||
async (client, host, req) => {
|
||||
const { op, protocol, port, target } = req.body as {
|
||||
op?: "add" | "delete";
|
||||
protocol?: string;
|
||||
port?: number;
|
||||
target?: string;
|
||||
};
|
||||
if (op !== "add" && op !== "delete") {
|
||||
throw new ManagerInputError("Invalid op");
|
||||
}
|
||||
if (!isValidIpProtocol(protocol))
|
||||
throw new ManagerInputError("Invalid protocol");
|
||||
if (!isValidPort(port)) throw new ManagerInputError("Invalid port");
|
||||
if (!isValidFirewallTarget(target))
|
||||
throw new ManagerInputError("Invalid target");
|
||||
|
||||
const spec: FirewallRuleSpec = {
|
||||
protocol,
|
||||
port: Number(port),
|
||||
target,
|
||||
};
|
||||
const fw = await collectFirewallMetrics(client);
|
||||
const cmd =
|
||||
fw.type === "nftables"
|
||||
? buildNftRuleCommand(op, spec)
|
||||
: buildIptablesRuleCommand(op, spec);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
backend: fw.type,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/firewall/{id}/persist:
|
||||
* post:
|
||||
* summary: Save the current firewall rules so they survive a reboot
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The save result. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/firewall/:id/persist",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
deps,
|
||||
"connect",
|
||||
"firewall_persist",
|
||||
async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
// Best-effort persistence across common tools.
|
||||
const cmd =
|
||||
"(command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save) || " +
|
||||
"(command -v service >/dev/null 2>&1 && service iptables save) || " +
|
||||
"(command -v nft >/dev/null 2>&1 && nft list ruleset > /etc/nftables.conf) || true";
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
pkg: platform.pkg,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import {
|
||||
execCommand,
|
||||
shellSingleQuote,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { isValidPort } from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import type { HostMetricsRepository } from "../repository.js";
|
||||
|
||||
export interface HealthCheck {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "tcp" | "http";
|
||||
target: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface HealthResult {
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const TARGET_RE = /^[A-Za-z0-9.\-_:]+$/;
|
||||
const PATH_RE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*$/;
|
||||
|
||||
export function isValidHealthCheck(c: unknown): c is HealthCheck {
|
||||
if (!c || typeof c !== "object") return false;
|
||||
const o = c as Record<string, unknown>;
|
||||
if (typeof o.id !== "string" || !o.id) return false;
|
||||
if (typeof o.name !== "string") return false;
|
||||
if (o.type !== "tcp" && o.type !== "http") return false;
|
||||
if (typeof o.target !== "string" || !TARGET_RE.test(o.target)) return false;
|
||||
if (o.type === "tcp" && !isValidPort(o.port)) return false;
|
||||
if (
|
||||
o.path !== undefined &&
|
||||
(typeof o.path !== "string" || !PATH_RE.test(o.path))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Build a command that runs the check from the host and prints "ok latency". */
|
||||
export function buildHealthCheckCommand(check: HealthCheck): string {
|
||||
if (check.type === "tcp") {
|
||||
const host = shellSingleQuote(check.target);
|
||||
const port = check.port;
|
||||
// Prefer bash /dev/tcp; time it with date in ms.
|
||||
return `start=$(date +%s%3N); if timeout 3 bash -c '</dev/tcp/'${host}'/'${port} 2>/dev/null; then echo "ok $(( $(date +%s%3N) - start ))"; else echo "fail $(( $(date +%s%3N) - start ))"; fi`;
|
||||
}
|
||||
// http
|
||||
const scheme = check.target.includes("://") ? "" : "http://";
|
||||
const url = shellSingleQuote(`${scheme}${check.target}${check.path ?? ""}`);
|
||||
return `curl -s -o /dev/null -m 5 -w '%{http_code} %{time_total}' ${url} || echo '000 0'`;
|
||||
}
|
||||
|
||||
export function parseHealthResult(
|
||||
check: HealthCheck,
|
||||
output: string,
|
||||
): HealthResult {
|
||||
const line = output.trim().split("\n").pop() ?? "";
|
||||
if (check.type === "tcp") {
|
||||
const [status, ms] = line.split(/\s+/);
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: status === "ok",
|
||||
latencyMs: Number(ms) || null,
|
||||
detail: status === "ok" ? "open" : "closed/timeout",
|
||||
};
|
||||
}
|
||||
const m = line.match(/^(\d{3})\s+([\d.]+)/);
|
||||
if (!m)
|
||||
return { checkId: check.id, ok: false, latencyMs: null, detail: line };
|
||||
const code = Number(m[1]);
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: code >= 200 && code < 400,
|
||||
latencyMs: Math.round(Number(m[2]) * 1000),
|
||||
detail: `HTTP ${code}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function runChecks(
|
||||
client: Client,
|
||||
checks: HealthCheck[],
|
||||
): Promise<HealthResult[]> {
|
||||
return Promise.all(
|
||||
checks.map(async (check) => {
|
||||
try {
|
||||
const { stdout } = await execCommand(
|
||||
client,
|
||||
buildHealthCheckCommand(check),
|
||||
8000,
|
||||
);
|
||||
return parseHealthResult(check, stdout);
|
||||
} catch {
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
detail: "error",
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const HISTORY_KEEP = 500;
|
||||
|
||||
async function loadChecks(
|
||||
repository: HostMetricsRepository,
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HealthCheck[]> {
|
||||
const row = await repository.findChecks(userId, hostId);
|
||||
if (!row?.checks) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.checks);
|
||||
return Array.isArray(parsed) ? parsed.filter(isValidHealthCheck) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerHealthRoutes(
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId, repository } = deps;
|
||||
|
||||
const record = async (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
results: HealthResult[],
|
||||
) => {
|
||||
if (!results.length) return;
|
||||
await repository.recordHealth(userId, hostId, results, HISTORY_KEEP);
|
||||
for (const result of results) {
|
||||
deps.onHealthCheck({
|
||||
hostId,
|
||||
userId,
|
||||
checkId: result.checkId,
|
||||
ok: result.ok,
|
||||
detail: result.detail ?? undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/health/{id}:
|
||||
* get:
|
||||
* summary: Get the host's health checks and their last results
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The configured checks and results. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/health/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "health_get", async (client, host) => {
|
||||
const userId = host.actorId;
|
||||
const checks = await loadChecks(repository, userId, host.id);
|
||||
const results = checks.length ? await runChecks(client, checks) : [];
|
||||
await record(userId, host.id, results);
|
||||
const history = await repository.listHealth(userId, host.id, 200);
|
||||
return { checks, results, history };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/health/{id}/config:
|
||||
* post:
|
||||
* summary: Save the host's health checks
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [checks]
|
||||
* properties:
|
||||
* checks: { type: array, items: { type: object, properties: { id: { type: string }, name: { type: string }, type: { type: string }, target: { type: string }, port: { type: integer }, path: { type: string } } } }
|
||||
* intervalSeconds: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The saved checks. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/health/:id/config",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
deps,
|
||||
"connect",
|
||||
"health_config",
|
||||
async (_client, host, req) => {
|
||||
const userId = host.actorId;
|
||||
const { checks, intervalSeconds } = req.body as {
|
||||
checks?: unknown;
|
||||
intervalSeconds?: number;
|
||||
};
|
||||
if (!Array.isArray(checks) || !checks.every(isValidHealthCheck)) {
|
||||
throw new ManagerInputError("Invalid checks");
|
||||
}
|
||||
const interval =
|
||||
typeof intervalSeconds === "number" &&
|
||||
intervalSeconds >= 30 &&
|
||||
intervalSeconds <= 86400
|
||||
? Math.round(intervalSeconds)
|
||||
: 300;
|
||||
await repository.saveChecks(
|
||||
userId,
|
||||
host.id,
|
||||
JSON.stringify(checks),
|
||||
interval,
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/health/{id}/run:
|
||||
* post:
|
||||
* summary: Run the host's health checks now
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The result of each check. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/health/:id/run",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "health_run", async (client, host) => {
|
||||
const checks = await loadChecks(repository, host.actorId, host.id);
|
||||
const results = await runChecks(client, checks);
|
||||
await record(host.actorId, host.id, results);
|
||||
return { results };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { detectPlatform } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { registerServiceRoutes } from "./services.js";
|
||||
import { registerProcessRoutes } from "./processes.js";
|
||||
import { registerSimpleReadRoutes } from "./simple-reads.js";
|
||||
import { registerCronRoutes } from "./cron.js";
|
||||
import { registerPackageRoutes } from "./packages.js";
|
||||
import { registerSslRoutes } from "./ssl.js";
|
||||
import { registerFirewallRoutes } from "./firewall.js";
|
||||
import { registerUserRoutes } from "./users.js";
|
||||
import { registerHealthRoutes } from "./health.js";
|
||||
import { registerLogRoutes } from "./logs.js";
|
||||
import { registerWireGuardRoutes } from "./wireguard.js";
|
||||
|
||||
/**
|
||||
* Registers every Host Metrics manager route under the `/host-metrics/managers`
|
||||
* prefix on the stats app. All routes are on-demand (not polled).
|
||||
*/
|
||||
export function registerManagerRoutes(
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/platform/{id}:
|
||||
* get:
|
||||
* summary: Detect available management tooling on a host
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Platform capabilities (systemd, package manager, certbot, docker).
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/platform/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "platform_detect", (client) =>
|
||||
detectPlatform(client),
|
||||
),
|
||||
);
|
||||
|
||||
registerServiceRoutes(app, deps);
|
||||
registerProcessRoutes(app, deps);
|
||||
registerSimpleReadRoutes(app, deps);
|
||||
registerCronRoutes(app, deps);
|
||||
registerPackageRoutes(app, deps);
|
||||
registerSslRoutes(app, deps);
|
||||
registerFirewallRoutes(app, deps);
|
||||
registerUserRoutes(app, deps);
|
||||
registerHealthRoutes(app, deps);
|
||||
registerLogRoutes(app, deps);
|
||||
registerWireGuardRoutes(app, deps);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
execCommand,
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
/** Directories from which arbitrary log files may be tailed. */
|
||||
export const LOG_PATH_ALLOWLIST = ["/var/log"];
|
||||
|
||||
const COMMON_LOGS = [
|
||||
"/var/log/syslog",
|
||||
"/var/log/messages",
|
||||
"/var/log/auth.log",
|
||||
"/var/log/secure",
|
||||
"/var/log/kern.log",
|
||||
"/var/log/dpkg.log",
|
||||
"/var/log/nginx/access.log",
|
||||
"/var/log/nginx/error.log",
|
||||
];
|
||||
|
||||
const LIST_LOGS_CMD = `ls -1 ${LOG_PATH_ALLOWLIST.map(shellSingleQuote).join(" ")} 2>/dev/null`;
|
||||
|
||||
export function clampLines(n: unknown): number {
|
||||
const v = typeof n === "string" ? Number(n) : n;
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return 200;
|
||||
return Math.min(2000, Math.max(1, Math.round(v)));
|
||||
}
|
||||
|
||||
export function buildTailCommand(path: string, lines: number): string {
|
||||
// Keep stderr intact so execElevated can detect a permission error and
|
||||
// escalate; suppressing it (2>/dev/null) would hide the denial and return an
|
||||
// empty log with no chance to retry under sudo.
|
||||
return `tail -n ${lines} ${shellSingleQuote(path)}`;
|
||||
}
|
||||
|
||||
export function buildJournalCommand(unit: string, lines: number): string {
|
||||
return `journalctl -u ${shellSingleQuote(unit)} -n ${lines} --no-pager`;
|
||||
}
|
||||
|
||||
export function registerLogRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/logs/{id}/files:
|
||||
* get:
|
||||
* summary: List readable log files under /var/log
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: Common logs and the files found. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id/files",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "logs_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
|
||||
const found = stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => `/var/log/${name}`);
|
||||
return { common: COMMON_LOGS, files: found };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/logs/{id}:
|
||||
* get:
|
||||
* summary: Tail a log file or a systemd unit's journal
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* - in: query
|
||||
* name: path
|
||||
* schema: { type: string }
|
||||
* description: A log file under an allowed directory.
|
||||
* - in: query
|
||||
* name: unit
|
||||
* schema: { type: string }
|
||||
* description: A systemd unit. Used instead of path when given.
|
||||
* - in: query
|
||||
* name: lines
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The last lines of the log. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "logs_tail", async (client, host, req) => {
|
||||
const path = req.query.path as string | undefined;
|
||||
const unit = req.query.unit as string | undefined;
|
||||
const lines = clampLines(req.query.lines);
|
||||
|
||||
let cmd: string;
|
||||
if (unit) {
|
||||
if (!isValidSystemdUnit(unit))
|
||||
throw new ManagerInputError("Invalid unit");
|
||||
cmd = buildJournalCommand(unit, lines);
|
||||
} else if (path) {
|
||||
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
|
||||
throw new ManagerInputError("Path not allowed");
|
||||
}
|
||||
cmd = buildTailCommand(path, lines);
|
||||
} else {
|
||||
throw new ManagerInputError("Provide a path or unit");
|
||||
}
|
||||
|
||||
// Try unprivileged; many logs need root (auth.log, etc.).
|
||||
const result = await execElevated(client, cmd, host.sudoPassword);
|
||||
return { content: result.stdout, lines };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
execCommand,
|
||||
execElevated,
|
||||
detectPlatform,
|
||||
buildListUpgradableCommand,
|
||||
buildPackageActionCommand,
|
||||
parseUpgradable,
|
||||
type PackageAction,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { isValidPackageName } from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export function registerPackageRoutes(
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/packages/{id}:
|
||||
* get:
|
||||
* summary: List upgradable packages and the package manager
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The package manager and upgradable packages. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/packages/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "packages_list", async (client) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const cmd = buildListUpgradableCommand(platform.pkg);
|
||||
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
|
||||
const { stdout } = await execCommand(client, cmd, 60000);
|
||||
return {
|
||||
pkg: platform.pkg,
|
||||
upgradable: parseUpgradable(platform.pkg, stdout),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/packages/{id}/action:
|
||||
* post:
|
||||
* summary: Install or upgrade a package, or upgrade everything
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [action]
|
||||
* properties:
|
||||
* action: { type: string, enum: [install, upgrade, upgrade-all] }
|
||||
* pkg: { type: string, description: The package name. Not needed for upgrade-all. }
|
||||
* responses:
|
||||
* 200: { description: The command output. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/packages/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
deps,
|
||||
"connect",
|
||||
"packages_action",
|
||||
async (client, host, req) => {
|
||||
const { action, pkg: name } = req.body as {
|
||||
action?: PackageAction;
|
||||
pkg?: string;
|
||||
};
|
||||
if (
|
||||
action !== "upgrade-all" &&
|
||||
action !== "install" &&
|
||||
action !== "upgrade"
|
||||
) {
|
||||
throw new ManagerInputError("Invalid action");
|
||||
}
|
||||
if (action !== "upgrade-all" && !isValidPackageName(name)) {
|
||||
throw new ManagerInputError("Invalid package name");
|
||||
}
|
||||
const platform = await detectPlatform(client);
|
||||
const cmd = buildPackageActionCommand(platform.pkg, action, name);
|
||||
if (!cmd) throw new ManagerInputError("No supported package manager");
|
||||
// Package operations can be slow; allow up to 10 minutes.
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 600000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface ProcessRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
user: string;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
rss: number;
|
||||
stat: string;
|
||||
command: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
const LIST_PROCESSES_CMD =
|
||||
"ps -eo pid,ppid,user:20,pcpu,pmem,rss,stat,comm,args --sort=-pcpu --no-headers 2>/dev/null | head -n 300";
|
||||
|
||||
/** Parse `ps -eo pid,ppid,user,pcpu,pmem,rss,stat,comm,args` output. */
|
||||
export function parseProcessList(output: string): ProcessRow[] {
|
||||
const rows: ProcessRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(
|
||||
/^(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/,
|
||||
);
|
||||
if (!m) continue;
|
||||
rows.push({
|
||||
pid: Number(m[1]),
|
||||
ppid: Number(m[2]),
|
||||
user: m[3],
|
||||
cpu: Number(m[4]),
|
||||
mem: Number(m[5]),
|
||||
rss: Number(m[6]),
|
||||
stat: m[7],
|
||||
command: m[8],
|
||||
args: m[9],
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildKillCommand(pid: number, signal: Signal): string {
|
||||
return `kill -${signal} ${pid}`;
|
||||
}
|
||||
|
||||
export function registerProcessRoutes(
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/processes/{id}:
|
||||
* get:
|
||||
* summary: List processes (rich, sortable, filterable client-side)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: Process list. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/processes/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "processes_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
|
||||
return { processes: parseProcessList(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/processes/{id}/signal:
|
||||
* post:
|
||||
* summary: Send a signal to a process (TERM/KILL/HUP/INT)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* pid: { type: integer }
|
||||
* signal: { type: string }
|
||||
* responses:
|
||||
* 200: { description: Signal result. }
|
||||
* 400: { description: Invalid pid or signal. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/processes/:id/signal",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
deps,
|
||||
"connect",
|
||||
"processes_signal",
|
||||
async (client, host, req) => {
|
||||
const { pid, signal } = req.body as {
|
||||
pid?: number;
|
||||
signal?: string;
|
||||
};
|
||||
if (!isValidPid(pid)) throw new ManagerInputError("Invalid pid");
|
||||
if (!isValidSignal(signal))
|
||||
throw new ManagerInputError("Invalid signal");
|
||||
const cmd = buildKillCommand(Number(pid), signal);
|
||||
// Try unprivileged first; elevate only if the process isn't owned.
|
||||
const result = await execElevated(client, cmd, host.sudoPassword);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
usedSudo: result.usedSudo,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ElevationError } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Request, Response } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
|
||||
import { errorMessage } from "../util.js";
|
||||
import type { MetricsLogger } from "../log.js";
|
||||
import type { ManagerHost, RunOnHost } from "./types.js";
|
||||
|
||||
export class AccessDeniedError extends Error {
|
||||
constructor(message = "No access to this host") {
|
||||
super(message);
|
||||
this.name = "AccessDeniedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagerInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ManagerInputError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a manager handler: parses hostId, runs `fn` on a pooled connection at the
|
||||
* given access level, and maps known errors to clean HTTP responses.
|
||||
*/
|
||||
export function managerHandler(
|
||||
{ runOnHost, log }: { runOnHost: RunOnHost; log: MetricsLogger },
|
||||
level: PluginHostShareLevel,
|
||||
operation: string,
|
||||
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
|
||||
) {
|
||||
return async (req: Request, res: Response) => {
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
const result = await runOnHost(hostId, level, (client, host) =>
|
||||
fn(client, host, req),
|
||||
);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagerInputError) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
if (error instanceof AccessDeniedError) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
if (error instanceof ElevationError) {
|
||||
return res.status(403).json({ error: error.message, code: error.code });
|
||||
}
|
||||
log.error(`Manager operation failed: ${operation}`, {
|
||||
operation,
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: errorMessage(error, "Operation failed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidSystemdUnit,
|
||||
isValidServiceAction,
|
||||
type ServiceAction,
|
||||
} from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface SystemdService {
|
||||
unit: string;
|
||||
load: string;
|
||||
active: string;
|
||||
sub: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const LIST_SERVICES_CMD =
|
||||
"systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null";
|
||||
|
||||
/** Parse `systemctl list-units --plain` output into structured rows. */
|
||||
export function parseServiceList(output: string): SystemdService[] {
|
||||
const services: SystemdService[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
// Columns: UNIT LOAD ACTIVE SUB DESCRIPTION (description may contain spaces)
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
const [unit, load, active, sub, ...rest] = parts;
|
||||
if (!unit.endsWith(".service")) continue;
|
||||
services.push({
|
||||
unit,
|
||||
load,
|
||||
active,
|
||||
sub,
|
||||
description: rest.join(" "),
|
||||
});
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
export function buildServiceActionCommand(
|
||||
unit: string,
|
||||
action: ServiceAction,
|
||||
): string {
|
||||
return `systemctl ${action} ${unit}`;
|
||||
}
|
||||
|
||||
export function registerServiceRoutes(
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/services/{id}:
|
||||
* get:
|
||||
* summary: List systemd services
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: List of services. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/services/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "services_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
|
||||
return { services: parseServiceList(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/services/{id}/action:
|
||||
* post:
|
||||
* summary: Start/stop/restart/enable/disable a systemd service
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* unit: { type: string }
|
||||
* action: { type: string }
|
||||
* responses:
|
||||
* 200: { description: Action result. }
|
||||
* 400: { description: Invalid unit or action. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/services/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
deps,
|
||||
"connect",
|
||||
"services_action",
|
||||
async (client, host, req) => {
|
||||
const { unit, action } = req.body as {
|
||||
unit?: string;
|
||||
action?: string;
|
||||
};
|
||||
if (!isValidSystemdUnit(unit)) {
|
||||
throw new ManagerInputError("Invalid unit name");
|
||||
}
|
||||
if (!isValidServiceAction(action)) {
|
||||
throw new ManagerInputError("Invalid action");
|
||||
}
|
||||
const result = await execElevated(
|
||||
client,
|
||||
buildServiceActionCommand(unit, action),
|
||||
host.sudoPassword,
|
||||
{ forceSudo: true, timeoutMs: 30000 },
|
||||
);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
// ─── Top by memory ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface MemProcessRow {
|
||||
pid: number;
|
||||
user: string;
|
||||
mem: number;
|
||||
rss: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
const TOP_MEM_CMD =
|
||||
"ps -eo pid,user:20,pmem,rss,comm --sort=-pmem --no-headers 2>/dev/null | head -n 20";
|
||||
|
||||
export function parseTopMemory(output: string): MemProcessRow[] {
|
||||
const rows: MemProcessRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(/^(\d+)\s+(\S+)\s+([\d.]+)\s+(\d+)\s+(.*)$/);
|
||||
if (!m) continue;
|
||||
rows.push({
|
||||
pid: Number(m[1]),
|
||||
user: m[2],
|
||||
mem: Number(m[3]),
|
||||
rss: Number(m[4]),
|
||||
command: m[5],
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Systemd timers ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface TimerRow {
|
||||
next: string;
|
||||
left: string;
|
||||
last: string;
|
||||
unit: string;
|
||||
activates: string;
|
||||
}
|
||||
|
||||
const TIMERS_CMD =
|
||||
"systemctl list-timers --all --no-legend --no-pager 2>/dev/null";
|
||||
|
||||
export function parseTimers(output: string): TimerRow[] {
|
||||
const rows: TimerRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("NEXT")) continue;
|
||||
// NEXT(3) LEFT(2) LAST(3) PASSED(2) UNIT ACTIVATES -> columns vary; grab
|
||||
// the trailing UNIT + ACTIVATES which always end the line.
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
const activates = parts[parts.length - 1];
|
||||
const unit = parts[parts.length - 2];
|
||||
if (!unit.endsWith(".timer")) continue;
|
||||
rows.push({
|
||||
next: parts.slice(0, 3).join(" "),
|
||||
left: "",
|
||||
last: "",
|
||||
unit,
|
||||
activates,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Disk breakdown (per-mount) ─────────────────────────────────────────────
|
||||
|
||||
export interface MountUsage {
|
||||
filesystem: string;
|
||||
sizeKb: number;
|
||||
usedKb: number;
|
||||
availKb: number;
|
||||
usePct: number;
|
||||
mount: string;
|
||||
}
|
||||
|
||||
const DF_CMD = "df -Pk 2>/dev/null | tail -n +2";
|
||||
|
||||
export function parseDfMounts(output: string): MountUsage[] {
|
||||
const mounts: MountUsage[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$/);
|
||||
if (!m) continue;
|
||||
const fs = m[1];
|
||||
// Skip pseudo/virtual filesystems that clutter the view.
|
||||
if (/^(tmpfs|devtmpfs|overlay|udev|none|shm)$/.test(fs)) continue;
|
||||
mounts.push({
|
||||
filesystem: fs,
|
||||
sizeKb: Number(m[2]),
|
||||
usedKb: Number(m[3]),
|
||||
availKb: Number(m[4]),
|
||||
usePct: Number(m[5]),
|
||||
mount: m[6],
|
||||
});
|
||||
}
|
||||
return mounts;
|
||||
}
|
||||
|
||||
export function registerSimpleReadRoutes(
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/top-memory/{id}:
|
||||
* get:
|
||||
* summary: List the processes using the most memory
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The top processes by memory. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/top-memory/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "top_memory", async (client) => {
|
||||
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
|
||||
return { processes: parseTopMemory(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/timers/{id}:
|
||||
* get:
|
||||
* summary: List systemd timers
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The timers with their next and last runs. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/timers/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "systemd_timers", async (client) => {
|
||||
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
|
||||
return { timers: parseTimers(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/disk-breakdown/{id}:
|
||||
* get:
|
||||
* summary: List mounted filesystems and their usage
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: Size, used and free space per mount. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/disk-breakdown/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "disk_breakdown", async (client) => {
|
||||
const { stdout } = await execCommand(client, DF_CMD, 15000);
|
||||
return { mounts: parseDfMounts(stdout) };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import {
|
||||
execCommand,
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
detectPlatform,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidDomain,
|
||||
isValidDnsProvider,
|
||||
isAllowedPath,
|
||||
} from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export type AcmeClient = "certbot" | "acme.sh";
|
||||
export type ChallengeType = "http-standalone" | "http-webroot" | "dns";
|
||||
|
||||
export interface CertInfo {
|
||||
client: AcmeClient | "other";
|
||||
name: string;
|
||||
domains: string[];
|
||||
expiry: string | null;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const CERTBOT_LIST_CMD = "certbot certificates 2>/dev/null";
|
||||
const ACMESH_BIN = '"$(command -v acme.sh || echo "$HOME/.acme.sh/acme.sh")"';
|
||||
const ACMESH_LIST_CMD = `${ACMESH_BIN} --list 2>/dev/null`;
|
||||
|
||||
/** Parse `certbot certificates` output. */
|
||||
export function parseCertbotCertificates(output: string): CertInfo[] {
|
||||
const certs: CertInfo[] = [];
|
||||
let current: CertInfo | null = null;
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
const nameM = line.match(/^Certificate Name:\s+(.+)$/);
|
||||
if (nameM) {
|
||||
if (current) certs.push(current);
|
||||
current = {
|
||||
client: "certbot",
|
||||
name: nameM[1],
|
||||
domains: [],
|
||||
expiry: null,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const domM = line.match(/^Domains:\s+(.+)$/);
|
||||
if (domM) current.domains = domM[1].split(/\s+/).filter(Boolean);
|
||||
const expM = line.match(/^Expiry Date:\s+(\S+\s+\S+)/);
|
||||
if (expM) current.expiry = expM[1];
|
||||
const pathM = line.match(/^Certificate Path:\s+(.+)$/);
|
||||
if (pathM) current.path = pathM[1];
|
||||
}
|
||||
if (current) certs.push(current);
|
||||
return certs;
|
||||
}
|
||||
|
||||
/** Parse `acme.sh --list` (tab/space separated columns with a header). */
|
||||
export function parseAcmeShList(output: string): CertInfo[] {
|
||||
const certs: CertInfo[] = [];
|
||||
const lines = output.split("\n").filter((l) => l.trim());
|
||||
if (lines.length < 2) return certs;
|
||||
for (const line of lines.slice(1)) {
|
||||
const cols = line
|
||||
.split(/\s{2,}|\t/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
if (cols.length < 1) continue;
|
||||
certs.push({
|
||||
client: "acme.sh",
|
||||
name: cols[0],
|
||||
domains: [cols[0]],
|
||||
expiry: cols[cols.length - 1] || null,
|
||||
});
|
||||
}
|
||||
return certs;
|
||||
}
|
||||
|
||||
/**
|
||||
* A certbot cert name or acme.sh primary domain. Allows letters, digits, dots,
|
||||
* hyphens, underscores and the wildcard `*` (acme.sh), but no shell metachars.
|
||||
*/
|
||||
const CERT_NAME_RE = /^[A-Za-z0-9._*-]+$/;
|
||||
export function isValidCertName(name: unknown): name is string {
|
||||
return typeof name === "string" && name.length > 0 && CERT_NAME_RE.test(name);
|
||||
}
|
||||
|
||||
export interface IssueRequest {
|
||||
client: AcmeClient;
|
||||
domains: string[];
|
||||
challenge: ChallengeType;
|
||||
webroot?: string;
|
||||
dnsProvider?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the certificate issuance command for the chosen client/challenge.
|
||||
* DNS-01 provider credentials are expected to already be present in the
|
||||
* environment / provider config file; we never put secrets in argv.
|
||||
*/
|
||||
export function buildIssueCommand(req: IssueRequest): string {
|
||||
const domains = req.domains;
|
||||
if (req.client === "certbot") {
|
||||
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
|
||||
if (req.challenge === "dns") {
|
||||
return `certbot certonly --non-interactive --agree-tos --dns-${req.dnsProvider} ${dFlags}`;
|
||||
}
|
||||
if (req.challenge === "http-webroot") {
|
||||
return `certbot certonly --non-interactive --agree-tos --webroot -w ${shellSingleQuote(
|
||||
req.webroot as string,
|
||||
)} ${dFlags}`;
|
||||
}
|
||||
return `certbot certonly --non-interactive --agree-tos --standalone ${dFlags}`;
|
||||
}
|
||||
// acme.sh
|
||||
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
|
||||
if (req.challenge === "dns") {
|
||||
return `${ACMESH_BIN} --issue --dns dns_${req.dnsProvider} ${dFlags}`;
|
||||
}
|
||||
if (req.challenge === "http-webroot") {
|
||||
return `${ACMESH_BIN} --issue -w ${shellSingleQuote(
|
||||
req.webroot as string,
|
||||
)} ${dFlags}`;
|
||||
}
|
||||
return `${ACMESH_BIN} --issue --standalone ${dFlags}`;
|
||||
}
|
||||
|
||||
export function buildRenewCommand(client: AcmeClient, dryRun: boolean): string {
|
||||
if (client === "certbot") {
|
||||
return `certbot renew${dryRun ? " --dry-run" : ""}`;
|
||||
}
|
||||
return `${ACMESH_BIN} --renew-all${dryRun ? " --staging" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (and remove) a certificate. certbot revokes by its certificate name and
|
||||
* deletes the lineage afterwards; acme.sh revokes by primary domain then removes
|
||||
* it from management. `name` is the certbot cert name or the acme.sh domain.
|
||||
*/
|
||||
export function buildRevokeCommand(client: AcmeClient, name: string): string {
|
||||
if (client === "certbot") {
|
||||
return `certbot revoke --non-interactive --cert-name ${shellSingleQuote(
|
||||
name,
|
||||
)} --delete-after-revoke`;
|
||||
}
|
||||
const d = shellSingleQuote(name);
|
||||
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
|
||||
}
|
||||
|
||||
export function registerSslRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/ssl/{id}:
|
||||
* get:
|
||||
* summary: List certificates from certbot and acme.sh
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The installed ACME clients and certificates. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/ssl/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "ssl_list", async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const certs: CertInfo[] = [];
|
||||
if (platform.hasCertbot) {
|
||||
const r = await execElevated(
|
||||
client,
|
||||
CERTBOT_LIST_CMD,
|
||||
host.sudoPassword,
|
||||
).catch(() => null);
|
||||
if (r) certs.push(...parseCertbotCertificates(r.stdout));
|
||||
}
|
||||
if (platform.hasAcmeSh) {
|
||||
const { stdout } = await execCommand(
|
||||
client,
|
||||
ACMESH_LIST_CMD,
|
||||
15000,
|
||||
).catch(() => ({ stdout: "" }) as { stdout: string });
|
||||
certs.push(...parseAcmeShList(stdout));
|
||||
}
|
||||
return {
|
||||
clients: {
|
||||
certbot: platform.hasCertbot,
|
||||
acmeSh: platform.hasAcmeSh,
|
||||
},
|
||||
certs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/ssl/{id}/issue:
|
||||
* post:
|
||||
* summary: Issue a certificate with certbot or acme.sh
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [client, domains, challenge]
|
||||
* properties:
|
||||
* client: { type: string, enum: [certbot, acme.sh] }
|
||||
* domains: { type: array, items: { type: string } }
|
||||
* challenge: { type: string, enum: [http-standalone, http-webroot, dns] }
|
||||
* webroot: { type: string }
|
||||
* dnsProvider: { type: string }
|
||||
* responses:
|
||||
* 200: { description: The issue result. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/issue",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "ssl_issue", async (client, host, req) => {
|
||||
const body = req.body as Partial<IssueRequest>;
|
||||
if (body.client !== "certbot" && body.client !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
if (!Array.isArray(body.domains) || body.domains.length === 0) {
|
||||
throw new ManagerInputError("At least one domain is required");
|
||||
}
|
||||
for (const d of body.domains) {
|
||||
if (!isValidDomain(d))
|
||||
throw new ManagerInputError(`Invalid domain: ${d}`);
|
||||
}
|
||||
const challenge = body.challenge;
|
||||
if (
|
||||
challenge !== "http-standalone" &&
|
||||
challenge !== "http-webroot" &&
|
||||
challenge !== "dns"
|
||||
) {
|
||||
throw new ManagerInputError("Invalid challenge type");
|
||||
}
|
||||
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
|
||||
throw new ManagerInputError("Invalid DNS provider");
|
||||
}
|
||||
if (
|
||||
challenge === "http-webroot" &&
|
||||
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
|
||||
) {
|
||||
throw new ManagerInputError("Invalid or disallowed webroot path");
|
||||
}
|
||||
const cmd = buildIssueCommand(body as IssueRequest);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 300000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/ssl/{id}/renew:
|
||||
* post:
|
||||
* summary: Renew certificates
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [client]
|
||||
* properties:
|
||||
* client: { type: string, enum: [certbot, acme.sh] }
|
||||
* dryRun: { type: boolean }
|
||||
* responses:
|
||||
* 200: { description: The renew result. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/renew",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "ssl_renew", async (client, host, req) => {
|
||||
const { client: acmeClient, dryRun } = req.body as {
|
||||
client?: AcmeClient;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
const cmd = buildRenewCommand(acmeClient, !!dryRun);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 300000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/ssl/{id}/revoke:
|
||||
* post:
|
||||
* summary: Revoke and remove an issued certificate (certbot or acme.sh)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* client: { type: string, enum: [certbot, acme.sh] }
|
||||
* name: { type: string, description: certbot cert name or acme.sh domain }
|
||||
* responses:
|
||||
* 200: { description: Revoke result. }
|
||||
* 400: { description: Invalid client or certificate name. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/revoke",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "ssl_revoke", async (client, host, req) => {
|
||||
const { client: acmeClient, name } = req.body as {
|
||||
client?: AcmeClient;
|
||||
name?: string;
|
||||
};
|
||||
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
if (!isValidCertName(name)) {
|
||||
throw new ManagerInputError("Invalid certificate name");
|
||||
}
|
||||
const cmd = buildRevokeCommand(acmeClient, name);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { RequestHandler } from "express";
|
||||
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
|
||||
import type { HostMetricsRepository } from "../repository.js";
|
||||
import type { MetricsLogger } from "../log.js";
|
||||
|
||||
/** Minimal host shape managers need (includes the decrypted sudo password). */
|
||||
export interface ManagerHost {
|
||||
id: number;
|
||||
userId: string;
|
||||
/** The user making the request, who may not own the host. */
|
||||
actorId: string;
|
||||
sudoPassword?: string;
|
||||
enableDocker?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` against a pooled SSH connection for the host, after verifying the
|
||||
* acting user has at least `level` access. Resolves the host (with
|
||||
* sudoPassword) so managers can elevate. Rejects with an access error if not
|
||||
* permitted.
|
||||
*/
|
||||
export type RunOnHost = <T>(
|
||||
hostId: number,
|
||||
level: PluginHostShareLevel,
|
||||
fn: (client: Client, host: ManagerHost) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
|
||||
export interface HealthCheckEvent {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ManagerRoutesDeps {
|
||||
validateHostId: RequestHandler;
|
||||
runOnHost: RunOnHost;
|
||||
log: MetricsLogger;
|
||||
repository: HostMetricsRepository;
|
||||
onHealthCheck: (event: HealthCheckEvent) => void;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import { isValidUsername, isValidGroupName } from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface SystemUser {
|
||||
name: string;
|
||||
uid: number;
|
||||
gid: number;
|
||||
home: string;
|
||||
shell: string;
|
||||
}
|
||||
|
||||
export interface SystemGroup {
|
||||
name: string;
|
||||
gid: number;
|
||||
members: string[];
|
||||
}
|
||||
|
||||
// Human users only (uid >= 1000, excluding nobody at 65534).
|
||||
const READ_USERS_CMD = "getent passwd 2>/dev/null";
|
||||
const READ_GROUPS_CMD = "getent group 2>/dev/null";
|
||||
const READ_SUDOERS_CMD = "getent group sudo wheel 2>/dev/null";
|
||||
|
||||
export function parsePasswd(output: string): SystemUser[] {
|
||||
const users: SystemUser[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 7) continue;
|
||||
const uid = Number(parts[2]);
|
||||
if (!Number.isFinite(uid)) continue;
|
||||
if (uid < 1000 || uid === 65534) continue;
|
||||
users.push({
|
||||
name: parts[0],
|
||||
uid,
|
||||
gid: Number(parts[3]),
|
||||
home: parts[5],
|
||||
shell: parts[6],
|
||||
});
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
export function parseGroups(output: string): SystemGroup[] {
|
||||
const groups: SystemGroup[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 4) continue;
|
||||
groups.push({
|
||||
name: parts[0],
|
||||
gid: Number(parts[2]),
|
||||
members: parts[3].split(",").filter(Boolean),
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function parseSudoers(output: string): string[] {
|
||||
const members = new Set<string>();
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 4) continue;
|
||||
parts[3]
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.forEach((m) => members.add(m));
|
||||
}
|
||||
return [...members];
|
||||
}
|
||||
|
||||
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
|
||||
|
||||
export function registerUserRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/users/{id}:
|
||||
* get:
|
||||
* summary: List local users and groups
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: The users and groups. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/users/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "users_list", async (client) => {
|
||||
const [passwd, groups, sudoers] = await Promise.all([
|
||||
execCommand(client, READ_USERS_CMD, 15000),
|
||||
execCommand(client, READ_GROUPS_CMD, 15000),
|
||||
execCommand(client, READ_SUDOERS_CMD, 15000),
|
||||
]);
|
||||
return {
|
||||
users: parsePasswd(passwd.stdout),
|
||||
groups: parseGroups(groups.stdout),
|
||||
sudoers: parseSudoers(sudoers.stdout),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/managers/users/{id}/action:
|
||||
* post:
|
||||
* summary: Create or delete a user, or change a user's groups
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [action, username]
|
||||
* properties:
|
||||
* action: { type: string, enum: [create, delete, addToGroup, removeFromGroup] }
|
||||
* username: { type: string }
|
||||
* group: { type: string, description: Needed for the group actions. }
|
||||
* responses:
|
||||
* 200: { description: The command result. }
|
||||
* 400: { description: Invalid input. }
|
||||
* 403: { description: No access to the host, or elevation denied. }
|
||||
* 500: { description: The command failed on the host. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/users/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
deps,
|
||||
"connect",
|
||||
"users_action",
|
||||
async (client, host, req) => {
|
||||
const { action, username, group } = req.body as {
|
||||
action?: UserAction;
|
||||
username?: string;
|
||||
group?: string;
|
||||
};
|
||||
if (!isValidUsername(username))
|
||||
throw new ManagerInputError("Invalid username");
|
||||
|
||||
// Never modify/delete the user we're connected as, or root.
|
||||
const who = (await execCommand(client, "id -un", 8000)).stdout.trim();
|
||||
if (username === who || username === "root") {
|
||||
throw new ManagerInputError(
|
||||
"Refusing to modify the connected user or root",
|
||||
);
|
||||
}
|
||||
|
||||
let cmd: string;
|
||||
switch (action) {
|
||||
case "create":
|
||||
cmd = `useradd -m ${username}`;
|
||||
break;
|
||||
case "delete":
|
||||
cmd = `userdel -r ${username}`;
|
||||
break;
|
||||
case "addToGroup":
|
||||
case "removeFromGroup":
|
||||
if (!isValidGroupName(group))
|
||||
throw new ManagerInputError("Invalid group");
|
||||
cmd =
|
||||
action === "addToGroup"
|
||||
? `usermod -aG ${group} ${username}`
|
||||
: `gpasswd -d ${username} ${group}`;
|
||||
break;
|
||||
default:
|
||||
throw new ManagerInputError("Invalid action");
|
||||
}
|
||||
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Strict allowlist validators for every dynamic value that reaches a shell
|
||||
* command. Managers MUST validate inputs through these before interpolation;
|
||||
* never pass raw user text to the shell.
|
||||
*/
|
||||
|
||||
const SYSTEMD_UNIT_RE =
|
||||
/^[A-Za-z0-9@._:\\-]+\.(service|socket|timer|target|path|mount)$/;
|
||||
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
|
||||
const USERNAME_RE = /^[a-z_][a-z0-9_-]*\$?$/;
|
||||
const GROUP_RE = /^[a-z_][a-z0-9_-]*$/;
|
||||
const DOMAIN_RE =
|
||||
/^(\*\.)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
|
||||
const PROVIDER_RE = /^[a-z0-9_]+$/;
|
||||
|
||||
export function isValidSystemdUnit(unit: unknown): unit is string {
|
||||
return (
|
||||
typeof unit === "string" && unit.length <= 256 && SYSTEMD_UNIT_RE.test(unit)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidPid(pid: unknown): pid is number {
|
||||
const n = typeof pid === "string" ? Number(pid) : pid;
|
||||
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31;
|
||||
}
|
||||
|
||||
export function isValidPort(port: unknown): port is number {
|
||||
const n = typeof port === "string" ? Number(port) : port;
|
||||
return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
}
|
||||
|
||||
export function isValidPackageName(pkg: unknown): pkg is string {
|
||||
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
|
||||
}
|
||||
|
||||
export function isValidUsername(name: unknown): name is string {
|
||||
return (
|
||||
typeof name === "string" && name.length <= 32 && USERNAME_RE.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidGroupName(name: unknown): name is string {
|
||||
return typeof name === "string" && name.length <= 32 && GROUP_RE.test(name);
|
||||
}
|
||||
|
||||
export function isValidDomain(domain: unknown): domain is string {
|
||||
return (
|
||||
typeof domain === "string" && domain.length <= 253 && DOMAIN_RE.test(domain)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidDnsProvider(provider: unknown): provider is string {
|
||||
return (
|
||||
typeof provider === "string" &&
|
||||
provider.length <= 64 &&
|
||||
PROVIDER_RE.test(provider)
|
||||
);
|
||||
}
|
||||
|
||||
export type Signal = "TERM" | "KILL" | "HUP" | "INT";
|
||||
const SIGNALS: Signal[] = ["TERM", "KILL", "HUP", "INT"];
|
||||
export function isValidSignal(sig: unknown): sig is Signal {
|
||||
return typeof sig === "string" && (SIGNALS as string[]).includes(sig);
|
||||
}
|
||||
|
||||
export type ServiceAction =
|
||||
"start" | "stop" | "restart" | "reload" | "enable" | "disable";
|
||||
const SERVICE_ACTIONS: ServiceAction[] = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"reload",
|
||||
"enable",
|
||||
"disable",
|
||||
];
|
||||
export function isValidServiceAction(a: unknown): a is ServiceAction {
|
||||
return typeof a === "string" && (SERVICE_ACTIONS as string[]).includes(a);
|
||||
}
|
||||
|
||||
export type IpProtocol = "tcp" | "udp";
|
||||
export function isValidIpProtocol(p: unknown): p is IpProtocol {
|
||||
return p === "tcp" || p === "udp";
|
||||
}
|
||||
|
||||
export type FirewallTarget = "ACCEPT" | "DROP" | "REJECT";
|
||||
const FW_TARGETS: FirewallTarget[] = ["ACCEPT", "DROP", "REJECT"];
|
||||
export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
|
||||
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
|
||||
}
|
||||
|
||||
const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/;
|
||||
export function isValidWireGuardInterface(name: unknown): name is string {
|
||||
return typeof name === "string" && WG_IFACE_RE.test(name);
|
||||
}
|
||||
|
||||
export type WireGuardAction = "up" | "down";
|
||||
const WG_ACTIONS: WireGuardAction[] = ["up", "down"];
|
||||
export function isValidWireGuardAction(a: unknown): a is WireGuardAction {
|
||||
return typeof a === "string" && (WG_ACTIONS as string[]).includes(a);
|
||||
}
|
||||
|
||||
export type TailscaleAction = "up" | "down";
|
||||
const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"];
|
||||
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
|
||||
return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an absolute file path against an allowlist of permitted prefixes and
|
||||
* reject traversal. Used by the log viewer (e.g. only under /var/log).
|
||||
*/
|
||||
export function isAllowedPath(
|
||||
path: unknown,
|
||||
allowedPrefixes: string[],
|
||||
): path is string {
|
||||
if (typeof path !== "string" || path.length === 0 || path.length > 4096) {
|
||||
return false;
|
||||
}
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (path.includes("\0")) return false;
|
||||
if (path.split("/").some((seg) => seg === "..")) return false;
|
||||
return allowedPrefixes.some(
|
||||
(prefix) =>
|
||||
path === prefix ||
|
||||
path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidWireGuardInterface,
|
||||
isValidWireGuardAction,
|
||||
} from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface WireGuardPeer {
|
||||
publicKey: string;
|
||||
endpoint: string | null;
|
||||
allowedIPs: string[];
|
||||
latestHandshake: number | null;
|
||||
rxBytes: number;
|
||||
txBytes: number;
|
||||
}
|
||||
|
||||
export interface WireGuardInterface {
|
||||
name: string;
|
||||
publicKey: string | null;
|
||||
listenPort: number | null;
|
||||
up: boolean;
|
||||
peers: WireGuardPeer[];
|
||||
}
|
||||
|
||||
export interface WireGuardData {
|
||||
installed: boolean;
|
||||
interfaces: WireGuardInterface[];
|
||||
}
|
||||
|
||||
const PROBE_CMD = [
|
||||
"command -v wg >/dev/null 2>&1 && echo wg_installed=1 || echo wg_installed=0",
|
||||
"ip link show type wireguard 2>/dev/null",
|
||||
"echo __DUMP__",
|
||||
"wg show all dump 2>/dev/null",
|
||||
].join("; ");
|
||||
|
||||
export function parseWireGuardData(output: string): WireGuardData {
|
||||
if (output.includes("wg_installed=0")) {
|
||||
return { installed: false, interfaces: [] };
|
||||
}
|
||||
|
||||
const dumpIdx = output.indexOf("__DUMP__");
|
||||
const ipLinkPart = dumpIdx >= 0 ? output.slice(0, dumpIdx) : "";
|
||||
const dumpPart =
|
||||
dumpIdx >= 0 ? output.slice(dumpIdx + "__DUMP__".length) : "";
|
||||
|
||||
// Collect up interface names from `ip link show type wireguard`
|
||||
const upSet = new Set<string>();
|
||||
for (const line of ipLinkPart.split("\n")) {
|
||||
const m = line.match(/^\d+:\s+([A-Za-z0-9_-]+):/);
|
||||
if (m) upSet.add(m[1]);
|
||||
}
|
||||
|
||||
const ifaceMap = new Map<string, WireGuardInterface>();
|
||||
|
||||
for (const raw of dumpPart.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const cols = line.split("\t");
|
||||
|
||||
if (cols.length === 5) {
|
||||
// Interface row: iface private_key public_key listen_port fwmark
|
||||
const [name, , publicKey, listenPortStr] = cols;
|
||||
if (!name) continue;
|
||||
ifaceMap.set(name, {
|
||||
name,
|
||||
publicKey: publicKey && publicKey !== "(none)" ? publicKey : null,
|
||||
listenPort:
|
||||
listenPortStr && listenPortStr !== "(none)"
|
||||
? Number(listenPortStr)
|
||||
: null,
|
||||
up: upSet.has(name),
|
||||
peers: [],
|
||||
});
|
||||
} else if (cols.length === 9) {
|
||||
// Peer row: iface public_key preshared_key endpoint allowed_ips latest_handshake rx_bytes tx_bytes persistent_keepalive
|
||||
const [
|
||||
name,
|
||||
publicKey,
|
||||
,
|
||||
endpoint,
|
||||
allowedIPsStr,
|
||||
handshakeStr,
|
||||
rxStr,
|
||||
txStr,
|
||||
] = cols;
|
||||
if (!name) continue;
|
||||
|
||||
if (!ifaceMap.has(name)) {
|
||||
ifaceMap.set(name, {
|
||||
name,
|
||||
publicKey: null,
|
||||
listenPort: null,
|
||||
up: upSet.has(name),
|
||||
peers: [],
|
||||
});
|
||||
}
|
||||
|
||||
const handshake = Number(handshakeStr);
|
||||
ifaceMap.get(name)!.peers.push({
|
||||
publicKey: publicKey ?? "",
|
||||
endpoint: endpoint && endpoint !== "(none)" ? endpoint : null,
|
||||
allowedIPs:
|
||||
allowedIPsStr && allowedIPsStr !== "(none)"
|
||||
? allowedIPsStr.split(",").map((s) => s.trim())
|
||||
: [],
|
||||
latestHandshake: handshake > 0 ? handshake : null,
|
||||
rxBytes: Number(rxStr) || 0,
|
||||
txBytes: Number(txStr) || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { installed: true, interfaces: Array.from(ifaceMap.values()) };
|
||||
}
|
||||
|
||||
export function registerWireGuardRoutes(
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/wireguard/{id}:
|
||||
* get:
|
||||
* summary: Get WireGuard interfaces and peers
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: WireGuard installation status, interfaces, and peer details.
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/wireguard/:id",
|
||||
validateHostId,
|
||||
managerHandler(deps, "connect", "wireguard_read", async (client, host) => {
|
||||
const result = await execElevated(client, PROBE_CMD, host.sudoPassword, {
|
||||
forceSudo: false,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
return parseWireGuardData(result.stdout);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/wireguard/{id}/action:
|
||||
* post:
|
||||
* summary: Bring a WireGuard interface up or down
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* interface:
|
||||
* type: string
|
||||
* action:
|
||||
* type: string
|
||||
* enum: [up, down]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Action result.
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/wireguard/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
deps,
|
||||
"connect",
|
||||
"wireguard_action",
|
||||
async (client, host, req) => {
|
||||
const { interface: iface, action } = req.body as {
|
||||
interface: unknown;
|
||||
action: unknown;
|
||||
};
|
||||
if (!isValidWireGuardInterface(iface)) {
|
||||
throw new ManagerInputError("Invalid WireGuard interface name");
|
||||
}
|
||||
if (!isValidWireGuardAction(action)) {
|
||||
throw new ManagerInputError("Invalid action, must be 'up' or 'down'");
|
||||
}
|
||||
const result = await execElevated(
|
||||
client,
|
||||
`wg-quick ${action} ${iface}`,
|
||||
host.sudoPassword,
|
||||
{ forceSudo: true, timeoutMs: 30000 },
|
||||
);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout + result.stderr).trim(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginHostStatusEntry,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
|
||||
import {
|
||||
collectMetrics,
|
||||
isHostKeyVerificationError,
|
||||
type CollectDeps,
|
||||
type CollectedMetrics,
|
||||
} from "./collect.js";
|
||||
import { supportsMetrics, type MetricsHost } from "./helpers.js";
|
||||
import type { MetricsLogger } from "./log.js";
|
||||
import type { HostMetricsRepository } from "./repository.js";
|
||||
import type { MetricsViewer } from "./sessions.js";
|
||||
import {
|
||||
canStartInitialMetrics,
|
||||
metricsConcurrencyFor,
|
||||
type MetricsState,
|
||||
} from "./state.js";
|
||||
|
||||
export const DEFAULT_METRICS_INTERVAL = 30;
|
||||
export const DEFAULT_RETENTION_DAYS = 7;
|
||||
/** Viewers that stop sending heartbeats are dropped after this long. */
|
||||
const VIEWER_TIMEOUT_MS = 120_000;
|
||||
|
||||
export const TOPIC_SNAPSHOT = "plugin.host-metrics.snapshot";
|
||||
export const TOPIC_STATUS = "plugin.host-metrics.status";
|
||||
export const TOPIC_HEALTH_CHECK = "plugin.host-metrics.health-check";
|
||||
|
||||
/** Where collection stands for a host, sent when it changes. */
|
||||
export type CollectionState =
|
||||
"collecting" | "auth_failed" | "host_key_changed" | "unreachable";
|
||||
|
||||
export interface SnapshotPayload {
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
metrics: CollectedMetrics;
|
||||
}
|
||||
|
||||
export interface CollectionStatusPayload {
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
state: CollectionState;
|
||||
previous: CollectionState | null;
|
||||
}
|
||||
|
||||
interface Polled {
|
||||
host: MetricsHost;
|
||||
viewerUserId: string;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export interface PollerDeps extends CollectDeps {
|
||||
ctx: PluginContext;
|
||||
log: MetricsLogger;
|
||||
repository: HostMetricsRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heavy SSH metrics for hosts somebody is looking at. Polling starts with the
|
||||
* first viewer and stops with the last one; a viewer is a tab, a dashboard
|
||||
* card, or another plugin through the "host-metrics.viewers" service.
|
||||
*/
|
||||
export class MetricsPoller {
|
||||
private readonly polled = new Map<number, Polled>();
|
||||
private readonly metrics = new Map<
|
||||
number,
|
||||
{ data: CollectedMetrics; timestamp: number }
|
||||
>();
|
||||
private readonly viewers = new Map<number, Set<string>>();
|
||||
private readonly viewerDetails = new Map<string, MetricsViewer>();
|
||||
private readonly inFlight = new Set<number>();
|
||||
private readonly initialRequested = new Set<number>();
|
||||
private readonly collectionState = new Map<number, CollectionState>();
|
||||
|
||||
constructor(private readonly deps: PollerDeps) {}
|
||||
|
||||
private get ctx() {
|
||||
return this.deps.ctx;
|
||||
}
|
||||
|
||||
private get state(): MetricsState {
|
||||
return this.deps.state;
|
||||
}
|
||||
|
||||
getMetrics(hostId: number) {
|
||||
return this.metrics.get(hostId);
|
||||
}
|
||||
|
||||
isPolling(hostId: number): boolean {
|
||||
return this.polled.has(hostId);
|
||||
}
|
||||
|
||||
hasViewers(hostId: number): boolean {
|
||||
return (this.viewers.get(hostId)?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
registerViewer(hostId: number, sessionId: string, userId: string): void {
|
||||
let set = this.viewers.get(hostId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.viewers.set(hostId, set);
|
||||
}
|
||||
set.add(sessionId);
|
||||
this.viewerDetails.set(sessionId, {
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
lastHeartbeat: Date.now(),
|
||||
});
|
||||
|
||||
if (set.size === 1) {
|
||||
// Never let a failed start reach the request that registered it.
|
||||
void this.start(hostId, userId).catch((error) => {
|
||||
this.deps.log.warn("Could not start metrics polling", {
|
||||
operation: "start_metrics_unhandled",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** False when the viewer is gone, so the caller registers again. */
|
||||
updateHeartbeat(sessionId: string, userId?: string): boolean {
|
||||
const viewer = this.viewerDetails.get(sessionId);
|
||||
if (!viewer || (userId && viewer.userId !== userId)) return false;
|
||||
viewer.lastHeartbeat = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
unregisterViewer(hostId: number, sessionId: string, userId?: string): void {
|
||||
const viewer = this.viewerDetails.get(sessionId);
|
||||
if (viewer && userId && viewer.userId !== userId) return;
|
||||
const set = this.viewers.get(hostId);
|
||||
if (set) {
|
||||
set.delete(sessionId);
|
||||
if (set.size === 0) {
|
||||
this.viewers.delete(hostId);
|
||||
this.stop(hostId, false);
|
||||
}
|
||||
}
|
||||
this.viewerDetails.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Stops a host's polling without dropping its viewers. */
|
||||
pause(hostId: number): void {
|
||||
this.stop(hostId, false);
|
||||
}
|
||||
|
||||
cleanupInactiveViewers(now = Date.now()): void {
|
||||
for (const [sessionId, viewer] of [...this.viewerDetails.entries()]) {
|
||||
if (now - viewer.lastHeartbeat > VIEWER_TIMEOUT_MS) {
|
||||
this.unregisterViewer(viewer.hostId, sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A host's details changed: drop what was cached and start over. */
|
||||
async hostUpdated(hostId: number): Promise<void> {
|
||||
this.state.hostCache.invalidate(hostId);
|
||||
this.state.metricsCache.clear(hostId);
|
||||
this.ctx.ssh.dropPooled("stats", hostId);
|
||||
const polled = this.polled.get(hostId);
|
||||
if (polled) await this.start(hostId, polled.viewerUserId);
|
||||
}
|
||||
|
||||
hostDeleted(hostId: number): void {
|
||||
this.stop(hostId, true);
|
||||
for (const sessionId of this.viewers.get(hostId) ?? []) {
|
||||
this.viewerDetails.delete(sessionId);
|
||||
}
|
||||
this.viewers.delete(hostId);
|
||||
this.collectionState.delete(hostId);
|
||||
this.state.authFailures.reset(hostId);
|
||||
this.state.backoff.reset(hostId);
|
||||
}
|
||||
|
||||
/** Core's status check says the host answers again. */
|
||||
hostStatusChanged(hostId: number, status: string): void {
|
||||
if (status === "offline") return;
|
||||
const polled = this.polled.get(hostId);
|
||||
if (polled) this.scheduleInitialPoll(polled.host);
|
||||
}
|
||||
|
||||
hostKeyAccepted(hostId: number): void {
|
||||
this.state.authFailures.resetHostKeyFailure(hostId);
|
||||
}
|
||||
|
||||
/** Re-times every running host, after the admin interval changed. */
|
||||
async retimeAll(): Promise<void> {
|
||||
for (const [hostId, polled] of [...this.polled.entries()]) {
|
||||
await this.start(hostId, polled.viewerUserId);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const hostId of [...this.polled.keys()]) this.stop(hostId, true);
|
||||
this.viewers.clear();
|
||||
this.viewerDetails.clear();
|
||||
this.state.rateSamples.clear();
|
||||
}
|
||||
|
||||
/** The host as `userId` may reach it, cached for a few minutes. */
|
||||
async resolve(hostId: number, userId: string): Promise<MetricsHost | null> {
|
||||
const cached = this.state.hostCache.get(
|
||||
hostId,
|
||||
userId,
|
||||
) as MetricsHost | null;
|
||||
if (cached) return cached;
|
||||
const host = (await this.ctx.asUser(userId, () =>
|
||||
this.ctx.ssh.resolveHost(hostId),
|
||||
)) as MetricsHost | null;
|
||||
if (!host) return null;
|
||||
this.state.hostCache.set(hostId, userId, host);
|
||||
return host;
|
||||
}
|
||||
|
||||
async settingsFor(hostId: number) {
|
||||
const settings = readHostMetricsSettings(
|
||||
await this.ctx.settings.getAll("host", hostId),
|
||||
);
|
||||
const adminInterval = Number(
|
||||
await this.ctx.settings.get<number>("metricsInterval"),
|
||||
);
|
||||
return {
|
||||
...settings,
|
||||
intervalSeconds:
|
||||
settings.metricsInterval ??
|
||||
(Number.isInteger(adminInterval) && adminInterval >= 5
|
||||
? adminInterval
|
||||
: DEFAULT_METRICS_INTERVAL),
|
||||
};
|
||||
}
|
||||
|
||||
private async start(hostId: number, userId: string): Promise<void> {
|
||||
const host = await this.resolve(hostId, userId);
|
||||
this.stop(hostId, false);
|
||||
if (!host || !supportsMetrics(host, this.ctx.ssh)) return;
|
||||
|
||||
const settings = await this.settingsFor(hostId);
|
||||
if (!settings.metricsEnabled) {
|
||||
this.metrics.delete(hostId);
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = settings.intervalSeconds * 1000;
|
||||
const stop = this.ctx.schedule.every(
|
||||
intervalMs,
|
||||
() => {
|
||||
const latest = this.polled.get(hostId);
|
||||
if (latest) this.schedulePoll(latest.host);
|
||||
},
|
||||
// Spread timers so a fleet does not fire on the same second.
|
||||
{ jitterMs: Math.min(intervalMs * 0.2, 15_000) },
|
||||
);
|
||||
this.polled.set(hostId, { host, viewerUserId: userId, stop });
|
||||
this.syncConcurrency();
|
||||
this.scheduleInitialPoll(host);
|
||||
}
|
||||
|
||||
private stop(hostId: number, clearData: boolean): void {
|
||||
const polled = this.polled.get(hostId);
|
||||
if (polled) {
|
||||
polled.stop();
|
||||
this.polled.delete(hostId);
|
||||
this.syncConcurrency();
|
||||
}
|
||||
this.inFlight.delete(hostId);
|
||||
if (clearData) {
|
||||
this.metrics.delete(hostId);
|
||||
this.state.hostCache.invalidate(hostId);
|
||||
this.state.rateSamples.clear(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps poll concurrency matched to how many hosts are being polled. */
|
||||
private syncConcurrency(): void {
|
||||
const target = metricsConcurrencyFor(this.polled.size);
|
||||
const limiter = this.state.metricsLimiter;
|
||||
if (target === limiter.limit) return;
|
||||
const previous = limiter.limit;
|
||||
limiter.setLimit(target);
|
||||
this.deps.log.info(
|
||||
`Metrics poll concurrency ${previous} -> ${target} for ${this.polled.size} host(s)`,
|
||||
{ operation: "metrics_concurrency_resize" },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first heavy sample waits for core's cheap status check to say the
|
||||
* host answers, so a whole fleet registering at once does not open a
|
||||
* burst of SSH connections to hosts that are down.
|
||||
*/
|
||||
private scheduleInitialPoll(host: MetricsHost): void {
|
||||
if (this.metrics.has(host.id) || this.initialRequested.has(host.id)) {
|
||||
return;
|
||||
}
|
||||
this.initialRequested.add(host.id);
|
||||
void this.state.initialLimiter
|
||||
.run(async () => {
|
||||
let status: PluginHostStatusEntry | null =
|
||||
await this.ctx.hosts.status.get(host.id);
|
||||
if (!status) status = await this.ctx.hosts.status.check(host.id);
|
||||
if (!canStartInitialMetrics(status?.status, this.hasViewers(host.id))) {
|
||||
return;
|
||||
}
|
||||
if (this.inFlight.has(host.id)) return;
|
||||
this.inFlight.add(host.id);
|
||||
try {
|
||||
await this.state.metricsLimiter.run(() => this.poll(host));
|
||||
} finally {
|
||||
this.inFlight.delete(host.id);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.deps.log.error("Initial metrics polling failed", error, {
|
||||
operation: "initial_metrics_poll_unhandled",
|
||||
hostId: host.id,
|
||||
});
|
||||
})
|
||||
.finally(() => this.initialRequested.delete(host.id));
|
||||
}
|
||||
|
||||
private schedulePoll(host: MetricsHost): void {
|
||||
if (this.inFlight.has(host.id)) return;
|
||||
this.inFlight.add(host.id);
|
||||
void this.state.metricsLimiter
|
||||
.run(() => this.poll(host))
|
||||
.catch((error) => {
|
||||
this.deps.log.error("Metrics polling failed", error, {
|
||||
operation: "metrics_poll_unhandled",
|
||||
hostId: host.id,
|
||||
});
|
||||
})
|
||||
.finally(() => this.inFlight.delete(host.id));
|
||||
}
|
||||
|
||||
/** One sample for one host. Public for tests. */
|
||||
async poll(host: MetricsHost): Promise<void> {
|
||||
const { state, log } = this.deps;
|
||||
if (!this.polled.has(host.id)) return;
|
||||
if (state.authFailures.shouldSkip(host.id)) return;
|
||||
if (state.backoff.shouldSkip(host.id)) return;
|
||||
|
||||
const settings = await this.settingsFor(host.id);
|
||||
if (!settings.metricsEnabled) return;
|
||||
|
||||
let authenticated = false;
|
||||
try {
|
||||
const metrics = await collectMetrics(this.deps, host, settings, () => {
|
||||
authenticated = true;
|
||||
});
|
||||
this.metrics.set(host.id, { data: metrics, timestamp: Date.now() });
|
||||
this.ctx.hosts.status.reportLogin(host.id, { ok: true });
|
||||
this.setCollectionState(host, "collecting");
|
||||
await this.recordSample(host.id, metrics);
|
||||
this.ctx.events.emit(TOPIC_SNAPSHOT, {
|
||||
hostId: host.id,
|
||||
ownerUserId: host.userId,
|
||||
metrics,
|
||||
} satisfies SnapshotPayload);
|
||||
state.backoff.reset(host.id);
|
||||
state.authFailures.reset(host.id);
|
||||
} catch (error) {
|
||||
const hostKeyChanged = isHostKeyVerificationError(error);
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const isAuthError = /authentication|permission denied/i.test(message);
|
||||
|
||||
// A timeout or a dropped connection says nothing about the login.
|
||||
if (!authenticated && (isAuthError || hostKeyChanged)) {
|
||||
this.ctx.hosts.status.reportLogin(host.id, {
|
||||
ok: false,
|
||||
hostKeyChanged,
|
||||
});
|
||||
}
|
||||
this.setCollectionState(
|
||||
host,
|
||||
hostKeyChanged
|
||||
? "host_key_changed"
|
||||
: isAuthError
|
||||
? "auth_failed"
|
||||
: "unreachable",
|
||||
);
|
||||
|
||||
if (isAuthError) {
|
||||
// collectMetrics already recorded it; log the first occurrence only.
|
||||
if (!state.authFailures.shouldSkip(host.id)) {
|
||||
log.error("Stats collector connection failed", error, {
|
||||
operation: "stats_connect_failed",
|
||||
hostId: host.id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hostKeyChanged) {
|
||||
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
|
||||
log.error("Stats collector host key verification failed", error, {
|
||||
operation: "stats_host_key_verification_failed",
|
||||
hostId: host.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.backoff.recordFailure(host.id);
|
||||
// Only log when a new retry window opens, not on every skipped poll.
|
||||
const backoff = state.backoff.getBackoffInfo(host.id);
|
||||
if (backoff !== null && !backoff.includes("polling suspended")) {
|
||||
log.error("Stats collector connection failed", error, {
|
||||
operation: "stats_connect_failed",
|
||||
hostId: host.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setCollectionState(host: MetricsHost, next: CollectionState): void {
|
||||
const previous = this.collectionState.get(host.id) ?? null;
|
||||
if (previous === next) return;
|
||||
this.collectionState.set(host.id, next);
|
||||
this.ctx.events.emit(TOPIC_STATUS, {
|
||||
hostId: host.id,
|
||||
ownerUserId: host.userId,
|
||||
state: next,
|
||||
previous,
|
||||
} satisfies CollectionStatusPayload);
|
||||
}
|
||||
|
||||
private async recordSample(
|
||||
hostId: number,
|
||||
metrics: CollectedMetrics,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const iface = metrics.network?.interfaces?.[0];
|
||||
const bytes = (value: string | null | undefined) => {
|
||||
const parsed = value ? parseInt(value, 10) : NaN;
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
const retention = Number(
|
||||
await this.ctx.settings.get<number>("historyRetentionDays"),
|
||||
);
|
||||
await this.deps.repository.recordSample(
|
||||
{
|
||||
hostId,
|
||||
cpuPercent: metrics.cpu?.percent ?? null,
|
||||
memPercent: metrics.memory?.percent ?? null,
|
||||
diskPercent: metrics.disk?.percent ?? null,
|
||||
netRxBytes: bytes(iface?.rxBytes),
|
||||
netTxBytes: bytes(iface?.txBytes),
|
||||
},
|
||||
Number.isInteger(retention) && retention >= 1
|
||||
? Math.min(retention, 90)
|
||||
: DEFAULT_RETENTION_DAYS,
|
||||
);
|
||||
} catch (error) {
|
||||
this.deps.log.warn("Failed to write metrics history", {
|
||||
operation: "insert_metrics_history",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { and, asc, desc, eq, gte, lt, lte, notInArray } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
hostHealthChecks,
|
||||
hostHealthHistory,
|
||||
hostMetricsHistory,
|
||||
hostMetricsPreferences,
|
||||
} from "./tables.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands tables back untyped, and the drizzle handle is the
|
||||
// server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface HealthResultInput {
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface HealthHistoryRow {
|
||||
checkId: string;
|
||||
ts: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface MetricsHistoryInput {
|
||||
hostId: number;
|
||||
cpuPercent: number | null;
|
||||
memPercent: number | null;
|
||||
diskPercent: number | null;
|
||||
netRxBytes: number | null;
|
||||
netTxBytes: number | null;
|
||||
}
|
||||
|
||||
export interface MetricsHistoryRow {
|
||||
ts: string;
|
||||
cpuPercent: number | null;
|
||||
memPercent: number | null;
|
||||
diskPercent: number | null;
|
||||
netRxBytes: number | null;
|
||||
netTxBytes: number | null;
|
||||
}
|
||||
|
||||
/** Timestamps are text in `YYYY-MM-DD HH:MM:SS` UTC, which sorts in time order. */
|
||||
export function sqlTimestamp(date: Date): string {
|
||||
return date.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
export type HostMetricsRepository = Awaited<
|
||||
ReturnType<typeof createHostMetricsRepository>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Written without RETURNING so it runs the same on all three engines: an
|
||||
* upsert reads the row by its unique (user, host) pair first.
|
||||
*/
|
||||
export async function createHostMetricsRepository(db: PluginDatabase) {
|
||||
const preferences: Table = await db.define(hostMetricsPreferences);
|
||||
const checks: Table = await db.define(hostHealthChecks);
|
||||
const health: Table = await db.define(hostHealthHistory);
|
||||
const history: Table = await db.define(hostMetricsHistory);
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
async function findOne(table: Table, userId: string, hostId: number) {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function upsert(
|
||||
table: Table,
|
||||
userId: string,
|
||||
hostId: number,
|
||||
values: Record<string, unknown>,
|
||||
) {
|
||||
const drizzle = await client();
|
||||
const now = sqlTimestamp(new Date());
|
||||
const existing = await findOne(table, userId, hostId);
|
||||
if (existing) {
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({ ...values, updatedAt: now })
|
||||
.where(eq(table.id, existing.id));
|
||||
} else {
|
||||
await drizzle.insert(table).values({
|
||||
...values,
|
||||
userId,
|
||||
hostId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
await db.persist();
|
||||
}
|
||||
|
||||
return {
|
||||
async findLayout(userId: string, hostId: number): Promise<string | null> {
|
||||
return (await findOne(preferences, userId, hostId))?.layout ?? null;
|
||||
},
|
||||
|
||||
saveLayout(userId: string, hostId: number, layout: string) {
|
||||
return upsert(preferences, userId, hostId, { layout });
|
||||
},
|
||||
|
||||
async findChecks(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<{ checks: string; intervalSeconds: number } | null> {
|
||||
return findOne(checks, userId, hostId);
|
||||
},
|
||||
|
||||
saveChecks(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
json: string,
|
||||
intervalSeconds: number,
|
||||
) {
|
||||
return upsert(checks, userId, hostId, { checks: json, intervalSeconds });
|
||||
},
|
||||
|
||||
/** Adds results and keeps only the newest `keep` rows for the host. */
|
||||
async recordHealth(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
results: HealthResultInput[],
|
||||
keep: number,
|
||||
): Promise<void> {
|
||||
if (results.length === 0) return;
|
||||
const drizzle = await client();
|
||||
const ts = sqlTimestamp(new Date());
|
||||
await drizzle
|
||||
.insert(health)
|
||||
.values(results.map((result) => ({ ...result, userId, hostId, ts })));
|
||||
const scope = and(eq(health.userId, userId), eq(health.hostId, hostId));
|
||||
const retained = await drizzle
|
||||
.select({ id: health.id })
|
||||
.from(health)
|
||||
.where(scope)
|
||||
.orderBy(desc(health.ts), desc(health.id))
|
||||
.limit(keep);
|
||||
await drizzle.delete(health).where(
|
||||
retained.length
|
||||
? and(
|
||||
scope,
|
||||
notInArray(
|
||||
health.id,
|
||||
retained.map((row: { id: number }) => row.id),
|
||||
),
|
||||
)
|
||||
: scope,
|
||||
);
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async listHealth(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit: number,
|
||||
): Promise<HealthHistoryRow[]> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(health)
|
||||
.where(and(eq(health.userId, userId), eq(health.hostId, hostId)))
|
||||
.orderBy(desc(health.ts), desc(health.id))
|
||||
.limit(limit);
|
||||
return rows.map((row: Record<string, unknown>) => ({
|
||||
checkId: row.checkId as string,
|
||||
ts: row.ts as string,
|
||||
ok: row.ok === true || row.ok === 1,
|
||||
latencyMs: (row.latencyMs as number | null) ?? null,
|
||||
detail: (row.detail as string | null) ?? null,
|
||||
}));
|
||||
},
|
||||
|
||||
/** One sample, then drops the host's samples older than the retention. */
|
||||
async recordSample(
|
||||
input: MetricsHistoryInput,
|
||||
retentionDays: number,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
const now = new Date();
|
||||
await drizzle.insert(history).values({ ...input, ts: sqlTimestamp(now) });
|
||||
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000);
|
||||
await drizzle
|
||||
.delete(history)
|
||||
.where(
|
||||
and(
|
||||
eq(history.hostId, input.hostId),
|
||||
lt(history.ts, sqlTimestamp(cutoff)),
|
||||
),
|
||||
);
|
||||
// A sample every few seconds per host: let the debounced save pick it up.
|
||||
await db.persist({ lazy: true });
|
||||
},
|
||||
|
||||
async listSamples(
|
||||
hostId: number,
|
||||
fromTs: string,
|
||||
toTs: string,
|
||||
): Promise<MetricsHistoryRow[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select({
|
||||
ts: history.ts,
|
||||
cpuPercent: history.cpuPercent,
|
||||
memPercent: history.memPercent,
|
||||
diskPercent: history.diskPercent,
|
||||
netRxBytes: history.netRxBytes,
|
||||
netTxBytes: history.netTxBytes,
|
||||
})
|
||||
.from(history)
|
||||
.where(
|
||||
and(
|
||||
eq(history.hostId, hostId),
|
||||
gte(history.ts, fromTs),
|
||||
lte(history.ts, toTs),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(history.ts));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
import type {
|
||||
NextFunction,
|
||||
Request,
|
||||
RequestHandler,
|
||||
Response,
|
||||
Router,
|
||||
} from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
defaultLayoutFromWidgets,
|
||||
deriveEnabledWidgets,
|
||||
type HostMetricsLayout,
|
||||
} from "../shared/host-metrics.js";
|
||||
import { newSessionId, supportsMetrics } from "./helpers.js";
|
||||
import {
|
||||
startInteractive,
|
||||
submitTotp,
|
||||
type InteractiveDeps,
|
||||
} from "./interactive.js";
|
||||
import type { MetricsLogger } from "./log.js";
|
||||
import { registerManagerRoutes } from "./managers/index.js";
|
||||
import { AccessDeniedError } from "./managers/route-helpers.js";
|
||||
import type { HealthCheckEvent } from "./managers/types.js";
|
||||
import type { MetricsPoller } from "./poller.js";
|
||||
import { sqlTimestamp, type HostMetricsRepository } from "./repository.js";
|
||||
import { sessionKey, type MetricsSessions } from "./sessions.js";
|
||||
import { sudoPasswordOf } from "./helpers.js";
|
||||
|
||||
export interface RouteDeps {
|
||||
ctx: PluginContext;
|
||||
log: MetricsLogger;
|
||||
poller: MetricsPoller;
|
||||
sessions: MetricsSessions;
|
||||
repository: HostMetricsRepository;
|
||||
onHealthCheck: (event: HealthCheckEvent) => void;
|
||||
}
|
||||
|
||||
const RANGE_OFFSETS: Record<string, number> = {
|
||||
"1h": 60 * 60 * 1000,
|
||||
"6h": 6 * 60 * 60 * 1000,
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
function validateHostId(req: Request, res: Response, next: NextFunction) {
|
||||
const id = Number(req.params.id);
|
||||
if (!id || !Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function sanitizeLayout(input: unknown): HostMetricsLayout | null {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
const obj = input as Record<string, unknown>;
|
||||
if (!Array.isArray(obj.slots)) return null;
|
||||
const columns =
|
||||
typeof obj.columns === "number" && obj.columns >= 1 && obj.columns <= 4
|
||||
? Math.round(obj.columns)
|
||||
: 3;
|
||||
const slots = obj.slots
|
||||
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
|
||||
.map((s, i) => ({
|
||||
id: String(s.id),
|
||||
order: typeof s.order === "number" ? s.order : i,
|
||||
colSpan:
|
||||
s.colSpan === 1 || s.colSpan === 2 || s.colSpan === 3 ? s.colSpan : 1,
|
||||
height:
|
||||
typeof s.height === "number" && s.height > 0
|
||||
? Math.round(s.height)
|
||||
: null,
|
||||
}))
|
||||
.filter((s) => s.id && s.id !== "undefined");
|
||||
return { slots, columns } as HostMetricsLayout;
|
||||
}
|
||||
|
||||
/** Everything under /plugin-api/host-metrics. Core authenticates first. */
|
||||
export function registerRoutes(router: Router, deps: RouteDeps): void {
|
||||
const { ctx, log, poller, sessions, repository } = deps;
|
||||
|
||||
router.use(ctx.rbac.require("use") as unknown as RequestHandler);
|
||||
router.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
const actor = (res: Response): string | null => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) res.status(401).json({ error: "Authentication required" });
|
||||
return userId ?? null;
|
||||
};
|
||||
|
||||
const canConnect = async (hostId: number) =>
|
||||
(await ctx.hosts.checkAccess(hostId, "connect")).hasAccess;
|
||||
|
||||
const interactive: InteractiveDeps = {
|
||||
ssh: ctx.ssh,
|
||||
sessions,
|
||||
log,
|
||||
registerViewer: (hostId, sessionId, userId) =>
|
||||
poller.registerViewer(hostId, sessionId, userId),
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/metrics/{id}:
|
||||
* get:
|
||||
* summary: Get host metrics
|
||||
* description: Returns the latest sample for a host, including CPU, memory, disk, network, processes and system information.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Host metrics data.
|
||||
* 403:
|
||||
* description: No access to this host.
|
||||
* 404:
|
||||
* description: Metrics not available.
|
||||
*/
|
||||
router.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!actor(res)) return;
|
||||
if (!(await canConnect(id))) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
const sample = poller.getMetrics(id);
|
||||
if (!sample) {
|
||||
return res.status(404).json({
|
||||
error: "Metrics not available",
|
||||
cpu: { percent: null, cores: null, load: null },
|
||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||
disk: {
|
||||
percent: null,
|
||||
usedHuman: null,
|
||||
totalHuman: null,
|
||||
availableHuman: null,
|
||||
mount: null,
|
||||
filesystems: [],
|
||||
},
|
||||
network: { interfaces: [] },
|
||||
uptime: { seconds: null, formatted: null },
|
||||
processes: { total: null, running: null, top: [] },
|
||||
system: { hostname: null, kernel: null, os: null },
|
||||
lastChecked: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
...sample.data,
|
||||
lastChecked: new Date(sample.timestamp).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/metrics/start/{id}:
|
||||
* post:
|
||||
* summary: Start metrics collection
|
||||
* description: Opens an SSH connection from the tab, where a TOTP prompt can be answered, and starts collecting metrics for the host.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Metrics collection started, or a TOTP code is required.
|
||||
* 401:
|
||||
* description: The host needs a sign-in step first.
|
||||
* 404:
|
||||
* description: Host not found.
|
||||
* 500:
|
||||
* description: Failed to start metrics collection.
|
||||
*/
|
||||
router.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = actor(res);
|
||||
if (!userId) return;
|
||||
try {
|
||||
const host = await poller.resolve(id, userId);
|
||||
if (!host) {
|
||||
return res.status(404).json({
|
||||
error: "Host not found",
|
||||
connectionLogs: [
|
||||
{
|
||||
type: "error",
|
||||
stage: "stats_connecting",
|
||||
message: "Host not found",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
const result = await startInteractive(interactive, host, userId);
|
||||
res.status(result.status).json(result.body);
|
||||
} catch (error) {
|
||||
log.error("Failed to start metrics collection", error, {
|
||||
operation: "metrics_start_error",
|
||||
hostId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to start metrics collection" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/metrics/stop/{id}:
|
||||
* post:
|
||||
* summary: Stop metrics collection
|
||||
* description: Closes the tab's connection and drops its viewer, which stops polling when it was the last one.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* viewerSessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Metrics collection stopped.
|
||||
*/
|
||||
router.post("/metrics/stop/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = actor(res);
|
||||
if (!userId) return;
|
||||
sessions.close(sessionKey(id, userId));
|
||||
const { viewerSessionId } = (req.body ?? {}) as {
|
||||
viewerSessionId?: unknown;
|
||||
};
|
||||
if (typeof viewerSessionId === "string" && viewerSessionId) {
|
||||
poller.unregisterViewer(id, viewerSessionId, userId);
|
||||
} else if (await canConnect(id)) {
|
||||
poller.pause(id);
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/metrics/connect-totp:
|
||||
* post:
|
||||
* summary: Submit a TOTP code
|
||||
* description: Answers the TOTP prompt a metrics connection is waiting on.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* totpCode:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connected.
|
||||
* 400:
|
||||
* description: Missing sessionId or totpCode.
|
||||
* 401:
|
||||
* description: TOTP verification failed.
|
||||
* 404:
|
||||
* description: TOTP session not found or expired.
|
||||
* 429:
|
||||
* description: Too many TOTP attempts.
|
||||
*/
|
||||
router.post("/metrics/connect-totp", async (req, res) => {
|
||||
const userId = actor(res);
|
||||
if (!userId) return;
|
||||
const { sessionId, totpCode } = (req.body ?? {}) as {
|
||||
sessionId?: unknown;
|
||||
totpCode?: unknown;
|
||||
};
|
||||
if (typeof sessionId !== "string" || typeof totpCode !== "string") {
|
||||
return res.status(400).json({ error: "Missing sessionId or totpCode" });
|
||||
}
|
||||
const result = await submitTotp(interactive, userId, sessionId, totpCode);
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/metrics/heartbeat:
|
||||
* post:
|
||||
* summary: Keep a metrics viewer alive
|
||||
* description: A viewer without a heartbeat for two minutes is dropped.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* viewerSessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Heartbeat updated.
|
||||
* 400:
|
||||
* description: Invalid viewerSessionId.
|
||||
* 404:
|
||||
* description: Viewer session not found.
|
||||
*/
|
||||
router.post("/metrics/heartbeat", (req, res) => {
|
||||
const userId = actor(res);
|
||||
if (!userId) return;
|
||||
const { viewerSessionId } = (req.body ?? {}) as {
|
||||
viewerSessionId?: unknown;
|
||||
};
|
||||
if (typeof viewerSessionId !== "string" || !viewerSessionId) {
|
||||
return res.status(400).json({ error: "Invalid viewerSessionId" });
|
||||
}
|
||||
if (poller.updateHeartbeat(viewerSessionId, userId)) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
res.status(404).json({ error: "Viewer session not found" });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/metrics/register-viewer:
|
||||
* post:
|
||||
* summary: Register a metrics viewer
|
||||
* description: Starts polling a host while something shows its metrics. Answers skipped instead of failing when the host has no metrics.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Viewer registered, or skipped with a reason.
|
||||
* 400:
|
||||
* description: Invalid hostId.
|
||||
*/
|
||||
router.post("/metrics/register-viewer", async (req, res) => {
|
||||
const userId = actor(res);
|
||||
if (!userId) return;
|
||||
const { hostId } = (req.body ?? {}) as { hostId?: unknown };
|
||||
if (
|
||||
typeof hostId !== "number" ||
|
||||
!Number.isInteger(hostId) ||
|
||||
hostId <= 0
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid hostId" });
|
||||
}
|
||||
const skipped = (reason: string) =>
|
||||
res.json({ success: true, skipped: true, reason });
|
||||
try {
|
||||
const host = await poller.resolve(hostId, userId);
|
||||
if (!host) return skipped("host_not_found");
|
||||
if (!supportsMetrics(host, ctx.ssh))
|
||||
return skipped("metrics_unsupported");
|
||||
if (!(await poller.settingsFor(hostId)).metricsEnabled) {
|
||||
return skipped("metrics_disabled");
|
||||
}
|
||||
const viewerSessionId = newSessionId("viewer");
|
||||
poller.registerViewer(hostId, viewerSessionId, userId);
|
||||
res.json({ success: true, viewerSessionId });
|
||||
} catch (error) {
|
||||
log.warn("Failed to register viewer", {
|
||||
operation: "register_viewer_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.json({ success: false, skipped: true, reason: "internal_error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/metrics/unregister-viewer:
|
||||
* post:
|
||||
* summary: Unregister a metrics viewer
|
||||
* description: Drops a viewer. Polling stops with the last one.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* viewerSessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Viewer unregistered.
|
||||
* 400:
|
||||
* description: Invalid hostId or viewerSessionId.
|
||||
*/
|
||||
router.post("/metrics/unregister-viewer", (req, res) => {
|
||||
const userId = actor(res);
|
||||
if (!userId) return;
|
||||
const { hostId, viewerSessionId } = (req.body ?? {}) as {
|
||||
hostId?: unknown;
|
||||
viewerSessionId?: unknown;
|
||||
};
|
||||
if (typeof hostId !== "number" || !hostId) {
|
||||
return res.status(400).json({ error: "Invalid hostId" });
|
||||
}
|
||||
if (typeof viewerSessionId !== "string" || !viewerSessionId) {
|
||||
return res.status(400).json({ error: "Invalid viewerSessionId" });
|
||||
}
|
||||
poller.unregisterViewer(hostId, viewerSessionId, userId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/metrics/history/{id}:
|
||||
* get:
|
||||
* summary: Get metrics history
|
||||
* description: Returns stored CPU, memory, disk and network samples for a host over a range.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: range
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [1h, 6h, 24h, 7d, 30d]
|
||||
* - in: query
|
||||
* name: from
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: to
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The samples in the range.
|
||||
* 400:
|
||||
* description: Invalid range or dates.
|
||||
* 403:
|
||||
* description: No access to this host.
|
||||
*/
|
||||
router.get("/metrics/history/:id", validateHostId, async (req, res) => {
|
||||
const hostId = Number(req.params.id);
|
||||
if (!actor(res)) return;
|
||||
try {
|
||||
if (!(await canConnect(hostId))) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
const { range, from, to } = req.query as Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
let fromDate: Date;
|
||||
let toDate = new Date();
|
||||
if (range) {
|
||||
const offsetMs = RANGE_OFFSETS[range];
|
||||
if (!offsetMs) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
|
||||
}
|
||||
fromDate = new Date(Date.now() - offsetMs);
|
||||
} else if (from && to) {
|
||||
fromDate = new Date(from);
|
||||
toDate = new Date(to);
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return res.status(400).json({ error: "Invalid from/to date format" });
|
||||
}
|
||||
} else {
|
||||
fromDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const rows = (
|
||||
await repository.listSamples(
|
||||
hostId,
|
||||
sqlTimestamp(fromDate),
|
||||
sqlTimestamp(toDate),
|
||||
)
|
||||
).map((row) => ({
|
||||
ts: row.ts,
|
||||
cpu_percent: row.cpuPercent,
|
||||
mem_percent: row.memPercent,
|
||||
disk_percent: row.diskPercent,
|
||||
net_rx_bytes: row.netRxBytes,
|
||||
net_tx_bytes: row.netTxBytes,
|
||||
}));
|
||||
res.json({
|
||||
rows,
|
||||
fromTs: fromDate.toISOString(),
|
||||
toTs: toDate.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch metrics history", error, {
|
||||
operation: "metrics_history_fetch_error",
|
||||
hostId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch metrics history" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/host-metrics/preferences/{id}:
|
||||
* get:
|
||||
* summary: Get the Host Metrics layout for a host
|
||||
* description: Returns the current user's saved card layout for the host, or a default layout from the host's enabled widgets when none is saved.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The layout for this host.
|
||||
* 403:
|
||||
* description: No access to this host.
|
||||
*/
|
||||
router.get(
|
||||
"/host-metrics/preferences/:id",
|
||||
validateHostId,
|
||||
async (req, res) => {
|
||||
const userId = actor(res);
|
||||
if (!userId) return;
|
||||
const hostId = Number(req.params.id);
|
||||
try {
|
||||
if (!(await canConnect(hostId))) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
const saved = await repository.findLayout(userId, hostId);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = sanitizeLayout(JSON.parse(saved));
|
||||
if (parsed) return res.json({ layout: parsed });
|
||||
} catch {
|
||||
// fall through to the default
|
||||
}
|
||||
}
|
||||
const settings = await poller.settingsFor(hostId);
|
||||
res.json({ layout: defaultLayoutFromWidgets(settings.enabledWidgets) });
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch host metrics preferences", error, {
|
||||
operation: "host_metrics_prefs_fetch_error",
|
||||
hostId,
|
||||
});
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to fetch host metrics preferences" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/host-metrics/host-metrics/preferences/{id}:
|
||||
* post:
|
||||
* summary: Save the Host Metrics layout for a host
|
||||
* description: Saves the current user's card layout for the host. For the host's owner it also updates the host's enabled widgets.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* slots:
|
||||
* type: array
|
||||
* columns:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout saved.
|
||||
* 400:
|
||||
* description: Invalid layout.
|
||||
* 403:
|
||||
* description: No access to this host.
|
||||
*/
|
||||
router.post(
|
||||
"/host-metrics/preferences/:id",
|
||||
validateHostId,
|
||||
async (req, res) => {
|
||||
const userId = actor(res);
|
||||
if (!userId) return;
|
||||
const hostId = Number(req.params.id);
|
||||
try {
|
||||
const access = await ctx.hosts.checkAccess(hostId, "connect");
|
||||
if (!access.hasAccess) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
const layout = sanitizeLayout(req.body);
|
||||
if (!layout) return res.status(400).json({ error: "Invalid layout" });
|
||||
|
||||
await repository.saveLayout(userId, hostId, JSON.stringify(layout));
|
||||
if (access.isOwner) {
|
||||
await ctx.settings.setHost(
|
||||
hostId,
|
||||
"enabledWidgets",
|
||||
deriveEnabledWidgets(layout.slots),
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
log.error("Failed to save host metrics preferences", error, {
|
||||
operation: "host_metrics_prefs_save_error",
|
||||
hostId,
|
||||
});
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to save host metrics preferences" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
registerManagerRoutes(router, {
|
||||
validateHostId,
|
||||
log,
|
||||
repository,
|
||||
onHealthCheck: deps.onHealthCheck,
|
||||
runOnHost: async (hostId, level, fn) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) throw new AccessDeniedError("Authentication required");
|
||||
const access = await ctx.hosts.checkAccess(hostId, level);
|
||||
if (!access.hasAccess) throw new AccessDeniedError();
|
||||
const host = await poller.resolve(hostId, userId);
|
||||
if (!host) throw new AccessDeniedError("Host not found");
|
||||
return ctx.ssh.withConnection(
|
||||
host,
|
||||
{
|
||||
pool: "stats",
|
||||
purpose: "metrics",
|
||||
overrides: { readyTimeout: 60000 },
|
||||
},
|
||||
(client) =>
|
||||
fn(client as never, {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
actorId: userId,
|
||||
sudoPassword: sudoPasswordOf(host),
|
||||
enableDocker: !!host.enableDocker,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
|
||||
import type { MetricsLogger } from "./log.js";
|
||||
|
||||
/** A connection a person opened from the tab, reused by polling. */
|
||||
export interface MetricsSession {
|
||||
client: Client;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
activeOperations: number;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
cancelCleanup?: () => void;
|
||||
}
|
||||
|
||||
/** A connection waiting for the TOTP code the person is typing. */
|
||||
export interface PendingTOTPSession {
|
||||
client: Client;
|
||||
finish: (responses: string[]) => void;
|
||||
config: ConnectConfig;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
totpAttempts: number;
|
||||
}
|
||||
|
||||
export interface MetricsViewer {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
lastHeartbeat: number;
|
||||
}
|
||||
|
||||
const IDLE_MS = 30 * 60 * 1000;
|
||||
|
||||
export function sessionKey(hostId: number, userId: string): string {
|
||||
return `${userId}:${hostId}`;
|
||||
}
|
||||
|
||||
function endQuietly(client: Client): void {
|
||||
try {
|
||||
client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
/** Interactive connections, owned by one activation of the plugin. */
|
||||
export class MetricsSessions {
|
||||
private readonly sessions = new Map<string, MetricsSession>();
|
||||
private readonly pending = new Map<string, PendingTOTPSession>();
|
||||
|
||||
constructor(
|
||||
private readonly schedule: PluginSchedule,
|
||||
private readonly log: MetricsLogger,
|
||||
) {}
|
||||
|
||||
get(key: string): MetricsSession | undefined {
|
||||
return this.sessions.get(key);
|
||||
}
|
||||
|
||||
/** Stores a ready connection and closes it after 30 idle minutes. */
|
||||
open(key: string, session: Omit<MetricsSession, "cancelCleanup">): void {
|
||||
const existing = this.sessions.get(key);
|
||||
if (existing && existing.client !== session.client) this.close(key);
|
||||
this.sessions.set(key, { ...session });
|
||||
this.scheduleCleanup(key);
|
||||
session.client.once("close", () => {
|
||||
const current = this.sessions.get(key);
|
||||
if (current?.client === session.client) {
|
||||
current.isConnected = false;
|
||||
current.cancelCleanup?.();
|
||||
this.sessions.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
touch(key: string): void {
|
||||
const session = this.sessions.get(key);
|
||||
if (!session) return;
|
||||
session.lastActive = Date.now();
|
||||
this.scheduleCleanup(key);
|
||||
}
|
||||
|
||||
/** Closes a session, or waits for the operations still using it. */
|
||||
close(key: string): void {
|
||||
const session = this.sessions.get(key);
|
||||
if (!session) return;
|
||||
if (session.activeOperations > 0) {
|
||||
this.log.warn(
|
||||
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
|
||||
{ operation: "cleanup_deferred", sessionKey: key },
|
||||
);
|
||||
this.scheduleCleanup(key);
|
||||
return;
|
||||
}
|
||||
session.cancelCleanup?.();
|
||||
this.sessions.delete(key);
|
||||
endQuietly(session.client);
|
||||
}
|
||||
|
||||
addPending(session: PendingTOTPSession): void {
|
||||
this.pending.set(session.sessionId, session);
|
||||
}
|
||||
|
||||
getPending(sessionId: string): PendingTOTPSession | undefined {
|
||||
return this.pending.get(sessionId);
|
||||
}
|
||||
|
||||
dropPending(sessionId: string, endClient = false): void {
|
||||
const session = this.pending.get(sessionId);
|
||||
this.pending.delete(sessionId);
|
||||
if (session && endClient) endQuietly(session.client);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const key of [...this.sessions.keys()]) {
|
||||
const session = this.sessions.get(key)!;
|
||||
session.cancelCleanup?.();
|
||||
endQuietly(session.client);
|
||||
}
|
||||
this.sessions.clear();
|
||||
for (const session of this.pending.values()) endQuietly(session.client);
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private scheduleCleanup(key: string): void {
|
||||
const session = this.sessions.get(key);
|
||||
if (!session) return;
|
||||
session.cancelCleanup?.();
|
||||
session.cancelCleanup = this.schedule.after(IDLE_MS, () => {
|
||||
session.cancelCleanup = undefined;
|
||||
this.close(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import type { CpuSamples } from "./widgets/cpu-collector.js";
|
||||
import { createNetworkSamples } from "./widgets/network-collector.js";
|
||||
|
||||
export type HostStatus = "online" | "reachable" | "offline";
|
||||
|
||||
export class RequestQueue {
|
||||
private queues = new Map<number, Array<() => Promise<unknown>>>();
|
||||
private processing = new Set<number>();
|
||||
private requestTimeout = 60000;
|
||||
|
||||
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const wrappedRequest = async () => {
|
||||
try {
|
||||
const result = await Promise.race<T>([
|
||||
request(),
|
||||
new Promise<never>((_, rej) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
rej(
|
||||
new Error(
|
||||
`Request timeout after ${this.requestTimeout}ms for host ${hostId}`,
|
||||
),
|
||||
),
|
||||
this.requestTimeout,
|
||||
),
|
||||
),
|
||||
]);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const queue = this.queues.get(hostId) || [];
|
||||
queue.push(wrappedRequest);
|
||||
this.queues.set(hostId, queue);
|
||||
this.processQueue(hostId);
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue(hostId: number): Promise<void> {
|
||||
if (this.processing.has(hostId)) return;
|
||||
|
||||
this.processing.add(hostId);
|
||||
const queue = this.queues.get(hostId) || [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const request = queue.shift();
|
||||
if (request) {
|
||||
try {
|
||||
await request();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.processing.delete(hostId);
|
||||
const currentQueue = this.queues.get(hostId);
|
||||
if (currentQueue && currentQueue.length > 0) {
|
||||
this.processQueue(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CachedMetrics {
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
export class MetricsCache {
|
||||
private cache = new Map<number, CachedMetrics>();
|
||||
private ttl = 30000;
|
||||
|
||||
get(hostId: number): unknown | null {
|
||||
const cached = this.cache.get(hostId);
|
||||
if (cached && Date.now() - cached.timestamp < this.ttl) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
set(hostId: number, data: unknown): void {
|
||||
this.cache.set(hostId, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
hostId,
|
||||
});
|
||||
}
|
||||
|
||||
clear(hostId?: number): void {
|
||||
if (hostId) {
|
||||
this.cache.delete(hostId);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthFailureRecord {
|
||||
count: number;
|
||||
lastFailure: number;
|
||||
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY";
|
||||
permanent: boolean;
|
||||
}
|
||||
|
||||
export class AuthFailureTracker {
|
||||
private failures = new Map<number, AuthFailureRecord>();
|
||||
private maxRetries = 3;
|
||||
private backoffBase = 5000;
|
||||
|
||||
recordFailure(
|
||||
hostId: number,
|
||||
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY",
|
||||
permanent = false,
|
||||
): void {
|
||||
const existing = this.failures.get(hostId);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
existing.lastFailure = Date.now();
|
||||
existing.reason = reason;
|
||||
if (permanent) existing.permanent = true;
|
||||
} else {
|
||||
this.failures.set(hostId, {
|
||||
count: 1,
|
||||
lastFailure: Date.now(),
|
||||
reason,
|
||||
permanent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
shouldSkip(hostId: number): boolean {
|
||||
const record = this.failures.get(hostId);
|
||||
if (!record) return false;
|
||||
|
||||
if (record.reason === "TOTP" || record.permanent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (record.count >= this.maxRetries) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
|
||||
const timeSinceFailure = Date.now() - record.lastFailure;
|
||||
|
||||
return timeSinceFailure < backoffTime;
|
||||
}
|
||||
|
||||
getSkipReason(hostId: number): string | null {
|
||||
const record = this.failures.get(hostId);
|
||||
if (!record) return null;
|
||||
|
||||
if (record.reason === "TOTP") {
|
||||
return "TOTP authentication required (metrics unavailable)";
|
||||
}
|
||||
|
||||
if (record.reason === "HOST_KEY") {
|
||||
return "SSH host key changed (verification required)";
|
||||
}
|
||||
|
||||
if (record.permanent) {
|
||||
return "Authentication permanently failed";
|
||||
}
|
||||
|
||||
if (record.count >= this.maxRetries) {
|
||||
return `Too many authentication failures (${record.count} attempts)`;
|
||||
}
|
||||
|
||||
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
|
||||
const timeSinceFailure = Date.now() - record.lastFailure;
|
||||
const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000);
|
||||
|
||||
if (timeSinceFailure < backoffTime) {
|
||||
return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
resetHostKeyFailure(hostId: number): void {
|
||||
if (this.failures.get(hostId)?.reason === "HOST_KEY") this.reset(hostId);
|
||||
}
|
||||
|
||||
reset(hostId: number): void {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const maxAge = 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [hostId, record] of this.failures.entries()) {
|
||||
if (!record.permanent && now - record.lastFailure > maxAge) {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PollingBackoff {
|
||||
private failures = new Map<number, { count: number; nextRetry: number }>();
|
||||
private baseDelay = 30000;
|
||||
private maxDelay = 600000;
|
||||
private maxRetries = 5;
|
||||
|
||||
recordFailure(hostId: number): void {
|
||||
const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 };
|
||||
const delay = Math.min(
|
||||
this.baseDelay * Math.pow(2, existing.count),
|
||||
this.maxDelay,
|
||||
);
|
||||
this.failures.set(hostId, {
|
||||
count: existing.count + 1,
|
||||
nextRetry: Date.now() + delay,
|
||||
});
|
||||
}
|
||||
|
||||
shouldSkip(hostId: number): boolean {
|
||||
const backoff = this.failures.get(hostId);
|
||||
if (!backoff) return false;
|
||||
|
||||
if (backoff.count >= this.maxRetries) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Date.now() < backoff.nextRetry;
|
||||
}
|
||||
|
||||
getBackoffInfo(hostId: number): string | null {
|
||||
const backoff = this.failures.get(hostId);
|
||||
if (!backoff) return null;
|
||||
|
||||
if (backoff.count >= this.maxRetries) {
|
||||
return `Max retries exceeded (${backoff.count} failures) - polling suspended`;
|
||||
}
|
||||
|
||||
const remainingMs = backoff.nextRetry - Date.now();
|
||||
if (remainingMs > 0) {
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
return `Retry in ${remainingSec}s (attempt ${backoff.count}/${this.maxRetries})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
reset(hostId: number): void {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const maxAge = 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [hostId, backoff] of this.failures.entries()) {
|
||||
if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
|
||||
* Used to stop metrics polls from stampeding under load.
|
||||
*/
|
||||
export class ConcurrentLimiter {
|
||||
private active = 0;
|
||||
private readonly waiters: Array<() => void> = [];
|
||||
|
||||
constructor(private maxConcurrent: number) {
|
||||
if (maxConcurrent < 1) {
|
||||
throw new Error("maxConcurrent must be >= 1");
|
||||
}
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
get pendingCount(): number {
|
||||
return this.waiters.length;
|
||||
}
|
||||
|
||||
get limit(): number {
|
||||
return this.maxConcurrent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the ceiling at runtime.
|
||||
*
|
||||
* Raising it wakes the waiters the new headroom allows, so a queue that built
|
||||
* up under the old limit drains at once instead of one job at a time as
|
||||
* running work finishes. Lowering it never interrupts work already running;
|
||||
* the new limit simply applies from the next release onward.
|
||||
*/
|
||||
setLimit(maxConcurrent: number): void {
|
||||
if (maxConcurrent < 1) {
|
||||
throw new Error("maxConcurrent must be >= 1");
|
||||
}
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
this.releaseWaiters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wakes as many queued callers as there is now room for.
|
||||
*
|
||||
* `woken` counts callers that have been resumed but have not yet reached the
|
||||
* `active += 1` on the far side of their await. Without it the occupancy
|
||||
* looks lower than it really is for a microtask, and the loop would release
|
||||
* past the ceiling.
|
||||
*/
|
||||
private releaseWaiters(): void {
|
||||
while (
|
||||
this.active + this.woken < this.maxConcurrent &&
|
||||
this.waiters.length > 0
|
||||
) {
|
||||
this.woken += 1;
|
||||
this.waiters.shift()!();
|
||||
}
|
||||
}
|
||||
|
||||
private woken = 0;
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// Queue behind anything already woken, so a caller arriving mid-handoff
|
||||
// cannot jump the queue and push occupancy past the ceiling.
|
||||
if (this.active + this.woken >= this.maxConcurrent) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
// Resumed by a slot handoff; that reservation is now consumed.
|
||||
this.woken = Math.max(0, this.woken - 1);
|
||||
}
|
||||
|
||||
this.active += 1;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.active -= 1;
|
||||
this.releaseWaiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many metrics polls may run at once for a given number of polled hosts.
|
||||
*
|
||||
* A metrics poll is an SSH exec, so this cannot simply be unbounded — but the
|
||||
* old fixed ceiling of 5 meant a sweep of 500 hosts took ~40s against a 30s
|
||||
* interval, so polling fell permanently behind and a host could wait over a
|
||||
* minute for a "30 second" metric. Scaling with the fleet keeps a sweep inside
|
||||
* its interval; the cap keeps file descriptors and CPU bounded.
|
||||
*/
|
||||
export const METRICS_CONCURRENCY_ENV = "METRICS_POLL_CONCURRENCY";
|
||||
const MIN_METRICS_CONCURRENCY = 5;
|
||||
const MAX_METRICS_CONCURRENCY = 50;
|
||||
/** Aim to spend about a twentieth of the interval per sweep wave. */
|
||||
const HOSTS_PER_WORKER = 20;
|
||||
|
||||
export function metricsConcurrencyFor(
|
||||
hostCount: number,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): number {
|
||||
const override = Number(env[METRICS_CONCURRENCY_ENV]);
|
||||
if (Number.isFinite(override) && override >= 1) {
|
||||
return Math.min(Math.floor(override), MAX_METRICS_CONCURRENCY);
|
||||
}
|
||||
|
||||
const scaled = Math.ceil(Math.max(0, hostCount) / HOSTS_PER_WORKER);
|
||||
return Math.min(
|
||||
Math.max(scaled, MIN_METRICS_CONCURRENCY),
|
||||
MAX_METRICS_CONCURRENCY,
|
||||
);
|
||||
}
|
||||
|
||||
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
|
||||
export class HostPollCache<THost extends { id: number } = { id: number }> {
|
||||
private cache = new Map<
|
||||
number,
|
||||
{ host: THost; userId: string; expiresAt: number }
|
||||
>();
|
||||
|
||||
constructor(private readonly ttlMs = 30_000) {}
|
||||
|
||||
get(hostId: number, userId: string): THost | null {
|
||||
const entry = this.cache.get(hostId);
|
||||
if (!entry) return null;
|
||||
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
|
||||
this.cache.delete(hostId);
|
||||
return null;
|
||||
}
|
||||
return entry.host;
|
||||
}
|
||||
|
||||
set(hostId: number, userId: string, host: THost): void {
|
||||
this.cache.set(hostId, {
|
||||
host,
|
||||
userId,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(hostId?: number): void {
|
||||
if (hostId === undefined) {
|
||||
this.cache.clear();
|
||||
return;
|
||||
}
|
||||
this.cache.delete(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first heavy SSH sample waits until core's cheap status check says the
|
||||
* host answers. A host core does not check (status checks off) is tried.
|
||||
*/
|
||||
export function canStartInitialMetrics(
|
||||
status: HostStatus | null | undefined,
|
||||
hasViewers: boolean,
|
||||
): boolean {
|
||||
return hasViewers && status !== "offline";
|
||||
}
|
||||
|
||||
/** Everything one activation of the plugin polls with. */
|
||||
/** The last CPU and network sample per host, for rates between polls. */
|
||||
export function createRateSamples() {
|
||||
const cpu: CpuSamples = new Map();
|
||||
const network = createNetworkSamples();
|
||||
return {
|
||||
cpu,
|
||||
network,
|
||||
clear(hostId?: number) {
|
||||
if (hostId === undefined) {
|
||||
cpu.clear();
|
||||
network.counters.clear();
|
||||
network.windows.clear();
|
||||
return;
|
||||
}
|
||||
cpu.delete(hostId);
|
||||
network.counters.delete(hostId);
|
||||
network.windows.delete(hostId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMetricsState() {
|
||||
return {
|
||||
rateSamples: createRateSamples(),
|
||||
/** SSH metrics execs are expensive; resized with the fleet. */
|
||||
metricsLimiter: new ConcurrentLimiter(5),
|
||||
/** Viewer registration is bursty; admit only two first samples at a time. */
|
||||
initialLimiter: new ConcurrentLimiter(2),
|
||||
hostCache: new HostPollCache(30_000),
|
||||
requestQueue: new RequestQueue(),
|
||||
metricsCache: new MetricsCache(),
|
||||
authFailures: new AuthFailureTracker(),
|
||||
backoff: new PollingBackoff(),
|
||||
};
|
||||
}
|
||||
|
||||
export type MetricsState = ReturnType<typeof createMetricsState>;
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
id,
|
||||
integer,
|
||||
real,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/** One card layout per user per host, as JSON. */
|
||||
export const hostMetricsPreferences = adoptLegacyTable(
|
||||
"host_metrics_preferences",
|
||||
defineTable(
|
||||
"host_metrics_preferences",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
layout: text().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
uniques: [
|
||||
{
|
||||
name: "idx_host_metrics_prefs_user_host",
|
||||
columns: ["userId", "hostId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** A user's health checks for a host, as a JSON array. */
|
||||
export const hostHealthChecks = adoptLegacyTable(
|
||||
"host_health_checks",
|
||||
defineTable(
|
||||
"host_health_checks",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
checks: text().notNull(),
|
||||
intervalSeconds: integer().notNull().default(300),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
uniques: [
|
||||
{
|
||||
name: "idx_host_health_checks_user_host",
|
||||
columns: ["userId", "hostId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const hostHealthHistory = adoptLegacyTable(
|
||||
"host_health_history",
|
||||
defineTable(
|
||||
"host_health_history",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
checkId: varchar().notNull(),
|
||||
ts: timestamp().notNull().defaultNow(),
|
||||
ok: boolean().notNull(),
|
||||
latencyMs: integer(),
|
||||
detail: text(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_host_health_history_lookup",
|
||||
columns: ["userId", "hostId", "checkId", "ts"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** One row per metrics sample, pruned to the retention setting. */
|
||||
export const hostMetricsHistory = adoptLegacyTable(
|
||||
"host_metrics_history",
|
||||
defineTable(
|
||||
"host_metrics_history",
|
||||
{
|
||||
id: id(),
|
||||
hostId: refHost(),
|
||||
ts: timestamp().notNull().defaultNow(),
|
||||
cpuPercent: real(),
|
||||
memPercent: real(),
|
||||
diskPercent: real(),
|
||||
netRxBytes: integer(),
|
||||
netTxBytes: integer(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_host_metrics_history_host_ts",
|
||||
columns: ["hostId", "ts"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [
|
||||
hostMetricsPreferences,
|
||||
hostHealthChecks,
|
||||
hostHealthHistory,
|
||||
hostMetricsHistory,
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
export function toFixedNum(
|
||||
n: number | null | undefined,
|
||||
digits = 2,
|
||||
): number | null {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
}
|
||||
|
||||
export function kibToGiB(kib: number): number {
|
||||
return kib / (1024 * 1024);
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown, fallback = "Unknown error") {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { toFixedNum } from "../util.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
/**
|
||||
* CPU percent is derived from the delta against the previous poll's
|
||||
* /proc/stat sample rather than a same-poll double-read, so a poll no longer
|
||||
* has to block for a fixed settle window. Keyed by host, and owned by the
|
||||
* poller so it goes away with the plugin.
|
||||
*/
|
||||
export type CpuSamples = Map<
|
||||
number,
|
||||
{ total: number; idle: number; timestamp: number }
|
||||
>;
|
||||
|
||||
export function parseCpuLine(
|
||||
cpuLine: string,
|
||||
): { total: number; idle: number } | undefined {
|
||||
const parts = cpuLine.trim().split(/\s+/);
|
||||
if (parts[0] !== "cpu") return undefined;
|
||||
const nums = parts
|
||||
.slice(1)
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
if (nums.length < 4) return undefined;
|
||||
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
||||
const total = nums.reduce((a, b) => a + b, 0);
|
||||
return { total, idle };
|
||||
}
|
||||
|
||||
// Parses macOS `top -l 1 -n 0` output, e.g.
|
||||
// "CPU usage: 12.34% user, 5.67% sys, 81.99% idle"
|
||||
export function parseDarwinCpuUsageLine(
|
||||
line: string,
|
||||
): { percent: number } | undefined {
|
||||
const match = line.match(
|
||||
/([\d.]+)%\s*user,\s*([\d.]+)%\s*sys,\s*([\d.]+)%\s*idle/i,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
const user = Number(match[1]);
|
||||
const sys = Number(match[2]);
|
||||
if (!Number.isFinite(user) || !Number.isFinite(sys)) return undefined;
|
||||
return { percent: Math.max(0, Math.min(100, user + sys)) };
|
||||
}
|
||||
|
||||
async function collectDarwinCpuMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
load: [number, number, number] | null;
|
||||
}> {
|
||||
let cpuPercent: number | null = null;
|
||||
let cores: number | null = null;
|
||||
let loadTriplet: [number, number, number] | null = null;
|
||||
|
||||
try {
|
||||
const [topOut, coresOut, loadOut] = await Promise.race([
|
||||
Promise.all([
|
||||
execCommand(client, "top -l 1 -n 0"),
|
||||
execCommand(client, "sysctl -n hw.ncpu"),
|
||||
execCommand(client, "sysctl -n vm.loadavg"),
|
||||
]),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("CPU metrics collection timeout")),
|
||||
25000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const usageLine = topOut.stdout
|
||||
.split("\n")
|
||||
.find((l) => l.includes("CPU usage:"));
|
||||
const parsed = usageLine ? parseDarwinCpuUsageLine(usageLine) : undefined;
|
||||
if (parsed) cpuPercent = parsed.percent;
|
||||
|
||||
const coresNum = Number((coresOut.stdout || "").trim());
|
||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||
|
||||
// sysctl -n vm.loadavg -> "{ 1.23 1.45 1.67 }"
|
||||
const loadMatch = loadOut.stdout.match(/([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
||||
if (loadMatch) {
|
||||
loadTriplet = [
|
||||
Number(loadMatch[1]),
|
||||
Number(loadMatch[2]),
|
||||
Number(loadMatch[3]),
|
||||
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
} catch {
|
||||
cpuPercent = null;
|
||||
loadTriplet = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(cpuPercent, 0),
|
||||
cores,
|
||||
load: loadTriplet,
|
||||
};
|
||||
}
|
||||
|
||||
// No POSIX-style load average exists on Windows, so `load` always stays null.
|
||||
async function collectWindowsCpuMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
load: [number, number, number] | null;
|
||||
}> {
|
||||
let cpuPercent: number | null = null;
|
||||
let cores: number | null = null;
|
||||
|
||||
try {
|
||||
const { stdout } = await execPowerShell(
|
||||
client,
|
||||
"$cpu=(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; $cores=(Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors; [PSCustomObject]@{cpu=$cpu; cores=$cores} | ConvertTo-Json -Compress",
|
||||
25000,
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
const cpuNum = Number(parsed?.cpu);
|
||||
const coresNum = Number(parsed?.cores);
|
||||
cpuPercent = Number.isFinite(cpuNum)
|
||||
? Math.max(0, Math.min(100, cpuNum))
|
||||
: null;
|
||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||
} catch {
|
||||
cpuPercent = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(cpuPercent, 0),
|
||||
cores,
|
||||
load: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectCpuMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
hostId?: number,
|
||||
previousStat: CpuSamples = new Map(),
|
||||
): Promise<{
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
load: [number, number, number] | null;
|
||||
}> {
|
||||
if (platform === "darwin") {
|
||||
return collectDarwinCpuMetrics(client);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsCpuMetrics(client);
|
||||
}
|
||||
|
||||
let cpuPercent: number | null = null;
|
||||
let cores: number | null = null;
|
||||
let loadTriplet: [number, number, number] | null = null;
|
||||
|
||||
try {
|
||||
const readAt = Date.now();
|
||||
const [statOut, loadAvgOut, coresOut] = await Promise.race([
|
||||
Promise.all([
|
||||
execCommand(client, "cat /proc/stat"),
|
||||
execCommand(client, "cat /proc/loadavg"),
|
||||
execCommand(
|
||||
client,
|
||||
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
|
||||
),
|
||||
]),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("CPU metrics collection timeout")),
|
||||
25000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const cpuLine = (
|
||||
statOut.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
||||
).trim();
|
||||
const current = parseCpuLine(cpuLine);
|
||||
const previous =
|
||||
hostId !== undefined ? previousStat.get(hostId) : undefined;
|
||||
if (current && previous) {
|
||||
const totalDiff = current.total - previous.total;
|
||||
const idleDiff = current.idle - previous.idle;
|
||||
const used = totalDiff - idleDiff;
|
||||
if (totalDiff > 0)
|
||||
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
|
||||
}
|
||||
if (current && hostId !== undefined) {
|
||||
previousStat.set(hostId, { ...current, timestamp: readAt });
|
||||
}
|
||||
|
||||
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
|
||||
if (laParts.length >= 3) {
|
||||
loadTriplet = [
|
||||
Number(laParts[0]),
|
||||
Number(laParts[1]),
|
||||
Number(laParts[2]),
|
||||
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
|
||||
const coresNum = Number((coresOut.stdout || "").trim());
|
||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||
} catch {
|
||||
cpuPercent = null;
|
||||
loadTriplet = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(cpuPercent, 0),
|
||||
cores,
|
||||
load: loadTriplet,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { toFixedNum } from "../util.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const PSEUDO_FS_RE =
|
||||
/^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
|
||||
|
||||
export interface DfRow {
|
||||
filesystem: string;
|
||||
type: string;
|
||||
mount: string;
|
||||
parts: string[];
|
||||
}
|
||||
|
||||
export interface DiskFilesystem {
|
||||
filesystem: string;
|
||||
type: string;
|
||||
mount: string;
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
totalHuman: string | null;
|
||||
availableHuman: string | null;
|
||||
usedBytes: number | null;
|
||||
totalBytes: number | null;
|
||||
availableBytes: number | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface MonitoredMount {
|
||||
path: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
// Parses `df -T -P`-style output: Filesystem, Type, then the size columns,
|
||||
// with Mounted-on last. The Type column (e.g. ext4, nfs4, cifs) is what lets
|
||||
// callers filter network shares out by filesystem type rather than guessing
|
||||
// from the source path.
|
||||
export function parseDfLines(output: string): DfRow[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split(/\s+/);
|
||||
return {
|
||||
filesystem: parts[0] || "",
|
||||
type: parts[1] || "",
|
||||
mount: parts[6] || "",
|
||||
parts,
|
||||
};
|
||||
})
|
||||
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
|
||||
}
|
||||
|
||||
// Finds the index of the most-utilized real filesystem in a GNU `df -TkP`-style
|
||||
// row set (parts[2] = total KB, parts[3] = used KB), so a nearly-full
|
||||
// secondary mount (e.g. /data) isn't hidden behind a healthy root filesystem.
|
||||
export function findWorstMountIndex(bytesRows: DfRow[]): {
|
||||
index: number;
|
||||
usedBytes: number;
|
||||
totalBytes: number;
|
||||
} {
|
||||
let worstIndex = -1;
|
||||
let worstUsedBytes = -1;
|
||||
let worstTotalBytes = 0;
|
||||
|
||||
bytesRows.forEach((row, index) => {
|
||||
const totalBytes = Number(row.parts[2]);
|
||||
const usedBytes = Number(row.parts[3]);
|
||||
if (
|
||||
!Number.isFinite(totalBytes) ||
|
||||
!Number.isFinite(usedBytes) ||
|
||||
totalBytes <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const usedRatio = usedBytes / totalBytes;
|
||||
const worstRatio =
|
||||
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
|
||||
if (usedRatio > worstRatio) {
|
||||
worstIndex = index;
|
||||
worstUsedBytes = usedBytes;
|
||||
worstTotalBytes = totalBytes;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
index: worstIndex,
|
||||
usedBytes: worstUsedBytes,
|
||||
totalBytes: worstTotalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
// Merges a GNU `df -TkP` size-row set (parts[2..4] in `blockSizeBytes` units)
|
||||
// and a `df -hT -P` human-row set into one filesystem list. Byte rows drive
|
||||
// the maths; human rows only supply the display strings, matched by mount
|
||||
// point so a mismatched row count can't shift the columns.
|
||||
export function buildFilesystemList(
|
||||
bytesRows: DfRow[],
|
||||
humanRows: DfRow[],
|
||||
blockSizeBytes = 1,
|
||||
): DiskFilesystem[] {
|
||||
const aligned = humanRows.length === bytesRows.length;
|
||||
|
||||
return bytesRows
|
||||
.map((row, index) => {
|
||||
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
|
||||
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
|
||||
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
|
||||
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
|
||||
|
||||
const humanRow = aligned
|
||||
? humanRows[index]
|
||||
: humanRows.find((h) => h.mount === row.mount);
|
||||
|
||||
const percent = Number.isFinite(usedBytes)
|
||||
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
|
||||
: null;
|
||||
|
||||
return {
|
||||
filesystem: row.filesystem,
|
||||
type: row.type,
|
||||
mount: row.mount,
|
||||
percent: toFixedNum(percent, 0),
|
||||
usedHuman: humanRow?.parts[3] || null,
|
||||
totalHuman: humanRow?.parts[2] || null,
|
||||
availableHuman: humanRow?.parts[4] || null,
|
||||
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
|
||||
totalBytes,
|
||||
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
|
||||
};
|
||||
})
|
||||
.filter((fs): fs is DiskFilesystem => fs !== null);
|
||||
}
|
||||
|
||||
// The headline disk figure should be the root filesystem - that is what users
|
||||
// mean by "the server's disk". Only when there is no root mount (containers,
|
||||
// chroots) do we fall back to the most-utilized mount.
|
||||
export function selectPrimaryFilesystem(
|
||||
filesystems: DiskFilesystem[],
|
||||
): DiskFilesystem | null {
|
||||
if (filesystems.length === 0) return null;
|
||||
|
||||
const root = filesystems.find((fs) => fs.mount === "/");
|
||||
if (root) return root;
|
||||
|
||||
let best = filesystems[0];
|
||||
for (const fs of filesystems) {
|
||||
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
|
||||
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
|
||||
if (ratio > bestRatio) best = fs;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Excluded mounts are user-configured per host: an exact mount-path match
|
||||
// (e.g. "/mnt/nas") or a filesystem-type substring match (e.g. "nfs" matches
|
||||
// nfs/nfs4, "cifs" matches cifs/smb3), so network shares can be dropped from
|
||||
// the headline percent and the filesystem list without hiding local disks.
|
||||
export function filterExcludedFilesystems(
|
||||
filesystems: DiskFilesystem[],
|
||||
excludedMounts?: string[] | null,
|
||||
): DiskFilesystem[] {
|
||||
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
|
||||
|
||||
const normalized = excludedMounts
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (normalized.length === 0) return filesystems;
|
||||
|
||||
return filesystems.filter((fs) => {
|
||||
const mount = fs.mount.toLowerCase();
|
||||
const type = fs.type.toLowerCase();
|
||||
return !normalized.some(
|
||||
(entry) => mount === entry || (type && type.includes(entry)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeMonitoredFilesystems(
|
||||
filesystems: DiskFilesystem[],
|
||||
monitored: MonitoredMount[],
|
||||
customFilesystems: Array<DiskFilesystem | null>,
|
||||
): DiskFilesystem[] {
|
||||
const result = [...filesystems];
|
||||
monitored.forEach((entry, index) => {
|
||||
const path = entry.path.trim();
|
||||
const custom = customFilesystems[index];
|
||||
if (!path || !custom) return;
|
||||
|
||||
const existing = result.find((fs) => fs.mount === path);
|
||||
if (existing) {
|
||||
existing.label = entry.label?.trim() || undefined;
|
||||
return;
|
||||
}
|
||||
result.push({
|
||||
...custom,
|
||||
mount: path,
|
||||
label: entry.label?.trim() || undefined,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function humanizeBytes(bytes: number): string {
|
||||
const units = ["B", "K", "M", "G", "T", "P"];
|
||||
let value = Math.max(0, bytes);
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
const rounded =
|
||||
unitIndex > 0 && value < 10 ? value.toFixed(1) : Math.round(value);
|
||||
return `${rounded}${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
// BSD/macOS `df -T` takes a filesystem-type argument to *filter* by, unlike
|
||||
// GNU `df -T` which *prints* a type column - so macOS needs its own command
|
||||
// (`df -Pk`, no type column) and gets the type separately from `mount`.
|
||||
const DARWIN_SKIP_MOUNT_RE =
|
||||
/^\/System\/Volumes\/(VM|Preboot|Update|xarts|iSCPreboot|Hardware)(\/|$)/;
|
||||
const DARWIN_SKIP_TYPE_RE = /^(devfs|autofs)$/;
|
||||
|
||||
export function parseDarwinMountTypes(output: string): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const line of output.split("\n")) {
|
||||
// Device names can contain spaces (e.g. autofs's "map auto_home"), so
|
||||
// match the literal " on " / " (" separators rather than a single token.
|
||||
const match = line.match(/^.+? on (.+?) \(([^,)]+)/);
|
||||
if (match) map.set(match[1], match[2].trim());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function parseDarwinDfRows(
|
||||
output: string,
|
||||
typeByMount: Map<string, string>,
|
||||
): DiskFilesystem[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 6) return null;
|
||||
|
||||
const filesystem = parts[0];
|
||||
const totalKb = Number(parts[1]);
|
||||
const usedKb = Number(parts[2]);
|
||||
const availableKb = Number(parts[3]);
|
||||
const mount = parts.slice(5).join(" ");
|
||||
if (!Number.isFinite(totalKb) || totalKb <= 0) return null;
|
||||
if (DARWIN_SKIP_MOUNT_RE.test(mount)) return null;
|
||||
|
||||
const type = typeByMount.get(mount) || "";
|
||||
if (DARWIN_SKIP_TYPE_RE.test(type)) return null;
|
||||
|
||||
const totalBytes = totalKb * 1024;
|
||||
const usedBytes = Number.isFinite(usedKb) ? usedKb * 1024 : null;
|
||||
const availableBytes = Number.isFinite(availableKb)
|
||||
? availableKb * 1024
|
||||
: null;
|
||||
const percent =
|
||||
usedBytes !== null
|
||||
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
|
||||
: null;
|
||||
|
||||
return {
|
||||
filesystem,
|
||||
type,
|
||||
mount,
|
||||
percent: toFixedNum(percent, 0),
|
||||
usedHuman: usedBytes !== null ? humanizeBytes(usedBytes) : null,
|
||||
totalHuman: humanizeBytes(totalBytes),
|
||||
availableHuman:
|
||||
availableBytes !== null ? humanizeBytes(availableBytes) : null,
|
||||
usedBytes,
|
||||
totalBytes,
|
||||
availableBytes,
|
||||
};
|
||||
})
|
||||
.filter((fs): fs is DiskFilesystem => fs !== null);
|
||||
}
|
||||
|
||||
async function collectDarwinDiskMetrics(
|
||||
client: Client,
|
||||
excludedMounts?: string[] | null,
|
||||
monitoredMounts?: MonitoredMount[] | null,
|
||||
): Promise<{
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
totalHuman: string | null;
|
||||
availableHuman: string | null;
|
||||
mount: string | null;
|
||||
filesystems: DiskFilesystem[];
|
||||
}> {
|
||||
try {
|
||||
const [dfOut, mountOut] = await Promise.all([
|
||||
execCommand(client, "df -Pk | tail -n +2"),
|
||||
execCommand(client, "mount"),
|
||||
]);
|
||||
|
||||
const typeByMount = parseDarwinMountTypes(mountOut.stdout);
|
||||
let detected = parseDarwinDfRows(dfOut.stdout, typeByMount);
|
||||
|
||||
const monitored = (monitoredMounts ?? []).filter((entry) =>
|
||||
Boolean(entry.path.trim()),
|
||||
);
|
||||
if (monitored.length > 0) {
|
||||
const customFilesystems = await Promise.all(
|
||||
monitored.map(async (entry) => {
|
||||
const path = shellQuote(entry.path.trim());
|
||||
try {
|
||||
const custom = await execCommand(
|
||||
client,
|
||||
`df -Pk -- ${path} | tail -n +2`,
|
||||
);
|
||||
return parseDarwinDfRows(custom.stdout, typeByMount)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
detected = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
monitored,
|
||||
customFilesystems,
|
||||
);
|
||||
}
|
||||
|
||||
const filesystems = filterExcludedFilesystems(detected, excludedMounts);
|
||||
const primary = selectPrimaryFilesystem(filesystems);
|
||||
|
||||
return {
|
||||
percent: primary?.percent ?? null,
|
||||
usedHuman: primary?.usedHuman ?? null,
|
||||
totalHuman: primary?.totalHuman ?? null,
|
||||
availableHuman: primary?.availableHuman ?? null,
|
||||
mount: primary?.mount ?? null,
|
||||
filesystems,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
percent: null,
|
||||
usedHuman: null,
|
||||
totalHuman: null,
|
||||
availableHuman: null,
|
||||
mount: null,
|
||||
filesystems: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface WindowsDiskRow {
|
||||
drive: string;
|
||||
total: number;
|
||||
free: number;
|
||||
}
|
||||
|
||||
const WINDOWS_DISK_SCRIPT =
|
||||
'Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {' +
|
||||
" [PSCustomObject]@{drive=$_.DeviceID; total=$_.Size; free=$_.FreeSpace}" +
|
||||
" } | ConvertTo-Json -Compress";
|
||||
|
||||
export function parseWindowsDiskJson(output: string): WindowsDiskRow[] {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return rows
|
||||
.filter((row): row is Record<string, unknown> => Boolean(row?.drive))
|
||||
.map((row) => ({
|
||||
drive: String(row.drive),
|
||||
total: Number(row.total),
|
||||
free: Number(row.free),
|
||||
}))
|
||||
.filter((row) => Number.isFinite(row.total) && row.total > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function collectWindowsDiskMetrics(
|
||||
client: Client,
|
||||
excludedMounts?: string[] | null,
|
||||
): Promise<{
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
totalHuman: string | null;
|
||||
availableHuman: string | null;
|
||||
mount: string | null;
|
||||
filesystems: DiskFilesystem[];
|
||||
}> {
|
||||
try {
|
||||
const { stdout } = await execPowerShell(client, WINDOWS_DISK_SCRIPT);
|
||||
const rows = parseWindowsDiskJson(stdout);
|
||||
|
||||
const filesystems: DiskFilesystem[] = rows.map((row) => {
|
||||
const usedBytes = row.total - row.free;
|
||||
const percent = Math.max(0, Math.min(100, (usedBytes / row.total) * 100));
|
||||
return {
|
||||
filesystem: row.drive,
|
||||
type: "NTFS",
|
||||
mount: row.drive,
|
||||
percent: toFixedNum(percent, 0),
|
||||
usedHuman: humanizeBytes(usedBytes),
|
||||
totalHuman: humanizeBytes(row.total),
|
||||
availableHuman: humanizeBytes(row.free),
|
||||
usedBytes,
|
||||
totalBytes: row.total,
|
||||
availableBytes: row.free,
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = filterExcludedFilesystems(filesystems, excludedMounts);
|
||||
const primary =
|
||||
filtered.find((fs) => fs.mount.toUpperCase().startsWith("C")) ??
|
||||
filtered[0] ??
|
||||
null;
|
||||
|
||||
return {
|
||||
percent: primary?.percent ?? null,
|
||||
usedHuman: primary?.usedHuman ?? null,
|
||||
totalHuman: primary?.totalHuman ?? null,
|
||||
availableHuman: primary?.availableHuman ?? null,
|
||||
mount: primary?.mount ?? null,
|
||||
filesystems: filtered,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
percent: null,
|
||||
usedHuman: null,
|
||||
totalHuman: null,
|
||||
availableHuman: null,
|
||||
mount: null,
|
||||
filesystems: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectDiskMetrics(
|
||||
client: Client,
|
||||
excludedMounts?: string[] | null,
|
||||
monitoredMounts?: MonitoredMount[] | null,
|
||||
platform?: HostPlatform,
|
||||
): Promise<{
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
totalHuman: string | null;
|
||||
availableHuman: string | null;
|
||||
mount: string | null;
|
||||
filesystems: DiskFilesystem[];
|
||||
}> {
|
||||
if (platform === "windows") {
|
||||
return collectWindowsDiskMetrics(client, excludedMounts);
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
return collectDarwinDiskMetrics(client, excludedMounts, monitoredMounts);
|
||||
}
|
||||
|
||||
try {
|
||||
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
||||
execCommand(client, "df -hT -P | tail -n +2"),
|
||||
execCommand(client, "df -TkP | tail -n +2"),
|
||||
]);
|
||||
|
||||
const humanRows = parseDfLines(diskOutHuman.stdout);
|
||||
const bytesRows = parseDfLines(diskOutBytes.stdout);
|
||||
let detected = buildFilesystemList(bytesRows, humanRows, 1024);
|
||||
const monitored = (monitoredMounts ?? []).filter((entry) =>
|
||||
Boolean(entry.path.trim()),
|
||||
);
|
||||
if (monitored.length > 0) {
|
||||
const customFilesystems = await Promise.all(
|
||||
monitored.map(async (entry) => {
|
||||
const path = shellQuote(entry.path.trim());
|
||||
try {
|
||||
const [customHuman, customBytes] = await Promise.all([
|
||||
execCommand(client, `df -hT -P -- ${path} | tail -n +2`),
|
||||
execCommand(client, `df -TkP -- ${path} | tail -n +2`),
|
||||
]);
|
||||
return (
|
||||
buildFilesystemList(
|
||||
parseDfLines(customBytes.stdout),
|
||||
parseDfLines(customHuman.stdout),
|
||||
1024,
|
||||
)[0] ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
detected = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
monitored,
|
||||
customFilesystems,
|
||||
);
|
||||
}
|
||||
const filesystems = filterExcludedFilesystems(detected, excludedMounts);
|
||||
const primary = selectPrimaryFilesystem(filesystems);
|
||||
|
||||
return {
|
||||
percent: primary?.percent ?? null,
|
||||
usedHuman: primary?.usedHuman ?? null,
|
||||
totalHuman: primary?.totalHuman ?? null,
|
||||
availableHuman: primary?.availableHuman ?? null,
|
||||
mount: primary?.mount ?? null,
|
||||
filesystems,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
percent: null,
|
||||
usedHuman: null,
|
||||
totalHuman: null,
|
||||
availableHuman: null,
|
||||
mount: null,
|
||||
filesystems: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
FirewallMetrics,
|
||||
FirewallChain,
|
||||
FirewallRule,
|
||||
} from "../../shared/stats-widgets.js";
|
||||
|
||||
function parseIptablesRule(line: string): FirewallRule | null {
|
||||
if (!line.startsWith("-A ")) return null;
|
||||
|
||||
const rule: FirewallRule = {
|
||||
chain: "",
|
||||
target: "",
|
||||
protocol: "all",
|
||||
source: "0.0.0.0/0",
|
||||
destination: "0.0.0.0/0",
|
||||
};
|
||||
|
||||
const chainMatch = line.match(/^-A\s+(\S+)/);
|
||||
if (chainMatch) {
|
||||
rule.chain = chainMatch[1];
|
||||
}
|
||||
|
||||
const targetMatch = line.match(/-j\s+(\S+)/);
|
||||
if (targetMatch) {
|
||||
rule.target = targetMatch[1];
|
||||
}
|
||||
|
||||
const protocolMatch = line.match(/-p\s+(\S+)/);
|
||||
if (protocolMatch) {
|
||||
rule.protocol = protocolMatch[1];
|
||||
}
|
||||
|
||||
const sourceMatch = line.match(/-s\s+(\S+)/);
|
||||
if (sourceMatch) {
|
||||
rule.source = sourceMatch[1];
|
||||
}
|
||||
|
||||
const destMatch = line.match(/-d\s+(\S+)/);
|
||||
if (destMatch) {
|
||||
rule.destination = destMatch[1];
|
||||
}
|
||||
|
||||
const dportMatch = line.match(/--dport\s+(\S+)/);
|
||||
if (dportMatch) {
|
||||
rule.dport = dportMatch[1];
|
||||
}
|
||||
|
||||
const sportMatch = line.match(/--sport\s+(\S+)/);
|
||||
if (sportMatch) {
|
||||
rule.sport = sportMatch[1];
|
||||
}
|
||||
|
||||
const stateMatch = line.match(/--state\s+(\S+)/);
|
||||
if (stateMatch) {
|
||||
rule.state = stateMatch[1];
|
||||
}
|
||||
|
||||
const interfaceMatch = line.match(/-i\s+(\S+)/);
|
||||
if (interfaceMatch) {
|
||||
rule.interface = interfaceMatch[1];
|
||||
}
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
function parseIptablesOutput(output: string): FirewallChain[] {
|
||||
const chains: Map<string, FirewallChain> = new Map();
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
const policyMatch = trimmed.match(/^:(\S+)\s+(\S+)/);
|
||||
if (policyMatch) {
|
||||
const [, chainName, policy] = policyMatch;
|
||||
chains.set(chainName, {
|
||||
name: chainName,
|
||||
policy: policy,
|
||||
rules: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const rule = parseIptablesRule(trimmed);
|
||||
if (rule) {
|
||||
let chain = chains.get(rule.chain);
|
||||
if (!chain) {
|
||||
chain = {
|
||||
name: rule.chain,
|
||||
policy: "ACCEPT",
|
||||
rules: [],
|
||||
};
|
||||
chains.set(rule.chain, chain);
|
||||
}
|
||||
chain.rules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(chains.values());
|
||||
}
|
||||
|
||||
function parseNftablesOutput(output: string): FirewallChain[] {
|
||||
const chains: FirewallChain[] = [];
|
||||
let currentChain: FirewallChain | null = null;
|
||||
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
const chainMatch = trimmed.match(
|
||||
/chain\s+(\S+)\s*\{?\s*(?:type\s+\S+\s+hook\s+(\S+))?/,
|
||||
);
|
||||
if (chainMatch) {
|
||||
if (currentChain) {
|
||||
chains.push(currentChain);
|
||||
}
|
||||
currentChain = {
|
||||
name: chainMatch[1].toUpperCase(),
|
||||
policy: "ACCEPT",
|
||||
rules: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChain && trimmed.startsWith("policy ")) {
|
||||
const policyMatch = trimmed.match(/policy\s+(\S+)/);
|
||||
if (policyMatch) {
|
||||
currentChain.policy = policyMatch[1].toUpperCase();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChain && trimmed && !trimmed.startsWith("}")) {
|
||||
const rule: FirewallRule = {
|
||||
chain: currentChain.name,
|
||||
target: "",
|
||||
protocol: "all",
|
||||
source: "0.0.0.0/0",
|
||||
destination: "0.0.0.0/0",
|
||||
};
|
||||
|
||||
if (trimmed.includes("accept")) rule.target = "ACCEPT";
|
||||
else if (trimmed.includes("drop")) rule.target = "DROP";
|
||||
else if (trimmed.includes("reject")) rule.target = "REJECT";
|
||||
|
||||
const tcpMatch = trimmed.match(/tcp\s+dport\s+(\S+)/);
|
||||
if (tcpMatch) {
|
||||
rule.protocol = "tcp";
|
||||
rule.dport = tcpMatch[1];
|
||||
}
|
||||
|
||||
const udpMatch = trimmed.match(/udp\s+dport\s+(\S+)/);
|
||||
if (udpMatch) {
|
||||
rule.protocol = "udp";
|
||||
rule.dport = udpMatch[1];
|
||||
}
|
||||
|
||||
const saddrMatch = trimmed.match(/saddr\s+(\S+)/);
|
||||
if (saddrMatch) {
|
||||
rule.source = saddrMatch[1];
|
||||
}
|
||||
|
||||
const daddrMatch = trimmed.match(/daddr\s+(\S+)/);
|
||||
if (daddrMatch) {
|
||||
rule.destination = daddrMatch[1];
|
||||
}
|
||||
|
||||
const iifMatch = trimmed.match(/iif\s+"?(\S+)"?/);
|
||||
if (iifMatch) {
|
||||
rule.interface = iifMatch[1].replace(/"/g, "");
|
||||
}
|
||||
|
||||
const ctStateMatch = trimmed.match(/ct\s+state\s+(\S+)/);
|
||||
if (ctStateMatch) {
|
||||
rule.state = ctStateMatch[1].toUpperCase();
|
||||
}
|
||||
|
||||
if (rule.target) {
|
||||
currentChain.rules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed === "}") {
|
||||
if (currentChain) {
|
||||
chains.push(currentChain);
|
||||
currentChain = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChain) {
|
||||
chains.push(currentChain);
|
||||
}
|
||||
|
||||
return chains;
|
||||
}
|
||||
|
||||
export async function collectFirewallMetrics(
|
||||
client: Client,
|
||||
): Promise<FirewallMetrics> {
|
||||
try {
|
||||
const iptablesResult = await execCommand(
|
||||
client,
|
||||
"iptables-save 2>/dev/null",
|
||||
15000,
|
||||
);
|
||||
|
||||
if (iptablesResult.stdout && iptablesResult.stdout.includes("*filter")) {
|
||||
const chains = parseIptablesOutput(iptablesResult.stdout);
|
||||
const hasRules = chains.some((c) => c.rules.length > 0);
|
||||
|
||||
return {
|
||||
type: "iptables",
|
||||
status: hasRules ? "active" : "inactive",
|
||||
chains: chains.filter(
|
||||
(c) =>
|
||||
c.name === "INPUT" || c.name === "OUTPUT" || c.name === "FORWARD",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const nftResult = await execCommand(
|
||||
client,
|
||||
"nft list ruleset 2>/dev/null",
|
||||
15000,
|
||||
);
|
||||
|
||||
if (nftResult.stdout && nftResult.stdout.trim()) {
|
||||
const chains = parseNftablesOutput(nftResult.stdout);
|
||||
const hasRules = chains.some((c) => c.rules.length > 0);
|
||||
|
||||
return {
|
||||
type: "nftables",
|
||||
status: hasRules ? "active" : "inactive",
|
||||
chains,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "none",
|
||||
status: "unknown",
|
||||
chains: [],
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
type: "none",
|
||||
status: "unknown",
|
||||
chains: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import { toFixedNum } from "../util.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
export interface GpuDevice {
|
||||
index: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
driverVersion: string | null;
|
||||
utilizationPercent: number | null;
|
||||
memoryUsedMiB: number | null;
|
||||
memoryTotalMiB: number | null;
|
||||
memoryPercent: number | null;
|
||||
temperatureCelsius: number | null;
|
||||
powerDrawWatts: number | null;
|
||||
powerLimitWatts: number | null;
|
||||
fanPercent: number | null;
|
||||
}
|
||||
|
||||
export interface GpuProcess {
|
||||
/** Null when nvidia-smi reports a uuid that matches none of the listed GPUs. */
|
||||
gpuIndex: number | null;
|
||||
pid: number;
|
||||
name: string;
|
||||
memoryUsedMiB: number | null;
|
||||
}
|
||||
|
||||
export interface GpuMetrics {
|
||||
source: "nvidia-smi" | "none";
|
||||
gpus: GpuDevice[];
|
||||
processes: GpuProcess[];
|
||||
}
|
||||
|
||||
const SECTION_SEPARATOR = "---";
|
||||
|
||||
// The free-text name is queried last so commas inside it cannot shift the
|
||||
// numeric columns; the parsers rejoin everything past the fixed fields.
|
||||
const GPU_QUERY_FIELDS = [
|
||||
"index",
|
||||
"uuid",
|
||||
"driver_version",
|
||||
"utilization.gpu",
|
||||
"memory.used",
|
||||
"memory.total",
|
||||
"temperature.gpu",
|
||||
"power.draw",
|
||||
"power.limit",
|
||||
"fan.speed",
|
||||
"name",
|
||||
];
|
||||
const APPS_QUERY_FIELDS = ["gpu_uuid", "pid", "used_memory", "process_name"];
|
||||
|
||||
const NVIDIA_SMI_COMMAND = [
|
||||
"command -v nvidia-smi >/dev/null 2>&1 || exit 0;",
|
||||
`nvidia-smi --query-gpu=${GPU_QUERY_FIELDS.join(",")} --format=csv,noheader,nounits 2>/dev/null;`,
|
||||
`echo ${SECTION_SEPARATOR};`,
|
||||
`nvidia-smi --query-compute-apps=${APPS_QUERY_FIELDS.join(",")} --format=csv,noheader,nounits 2>/dev/null`,
|
||||
].join(" ");
|
||||
|
||||
const NO_GPU: GpuMetrics = { source: "none", gpus: [], processes: [] };
|
||||
|
||||
/** Reads a numeric field; nvidia-smi writes "[N/A]", "[Not Supported]" etc. for missing ones. */
|
||||
function parseReading(raw: string | undefined, digits = 1): number | null {
|
||||
const value = raw?.trim();
|
||||
if (!value || value.startsWith("[")) return null;
|
||||
return toFixedNum(Number(value), digits);
|
||||
}
|
||||
|
||||
function parseInteger(raw: string | undefined): number | null {
|
||||
const value = raw?.trim();
|
||||
return value && /^\d+$/.test(value) ? Number(value) : null;
|
||||
}
|
||||
|
||||
function splitCsvRow(line: string, fixedFields: number): string[] | null {
|
||||
const parts = line.split(",").map((part) => part.trim());
|
||||
if (parts.length <= fixedFields) return null;
|
||||
return [...parts.slice(0, fixedFields), parts.slice(fixedFields).join(", ")];
|
||||
}
|
||||
|
||||
export function parseNvidiaSmiGpus(output: string): GpuDevice[] {
|
||||
const fixedFields = GPU_QUERY_FIELDS.length - 1;
|
||||
const gpus: GpuDevice[] = [];
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const row = splitCsvRow(line, fixedFields);
|
||||
if (!row) continue;
|
||||
|
||||
const [
|
||||
rawIndex,
|
||||
uuid,
|
||||
driverVersion,
|
||||
utilization,
|
||||
memoryUsed,
|
||||
memoryTotal,
|
||||
temperature,
|
||||
powerDraw,
|
||||
powerLimit,
|
||||
fan,
|
||||
name,
|
||||
] = row;
|
||||
const index = parseInteger(rawIndex);
|
||||
if (index === null || !uuid) continue;
|
||||
|
||||
const memoryUsedMiB = parseReading(memoryUsed, 0);
|
||||
const memoryTotalMiB = parseReading(memoryTotal, 0);
|
||||
|
||||
gpus.push({
|
||||
index,
|
||||
uuid,
|
||||
name,
|
||||
driverVersion:
|
||||
driverVersion && !driverVersion.startsWith("[") ? driverVersion : null,
|
||||
utilizationPercent: parseReading(utilization),
|
||||
memoryUsedMiB,
|
||||
memoryTotalMiB,
|
||||
memoryPercent:
|
||||
memoryUsedMiB !== null &&
|
||||
memoryTotalMiB !== null &&
|
||||
memoryTotalMiB !== 0
|
||||
? toFixedNum((memoryUsedMiB / memoryTotalMiB) * 100, 1)
|
||||
: null,
|
||||
temperatureCelsius: parseReading(temperature),
|
||||
powerDrawWatts: parseReading(powerDraw),
|
||||
powerLimitWatts: parseReading(powerLimit),
|
||||
fanPercent: parseReading(fan),
|
||||
});
|
||||
}
|
||||
|
||||
return gpus;
|
||||
}
|
||||
|
||||
export function parseNvidiaSmiProcesses(
|
||||
output: string,
|
||||
gpus: GpuDevice[],
|
||||
): GpuProcess[] {
|
||||
const indexByUuid = new Map(gpus.map((gpu) => [gpu.uuid, gpu.index]));
|
||||
const processes: GpuProcess[] = [];
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const row = splitCsvRow(line, APPS_QUERY_FIELDS.length - 1);
|
||||
if (!row) continue;
|
||||
|
||||
const [uuid, rawPid, memoryUsed, name] = row;
|
||||
const pid = parseInteger(rawPid);
|
||||
if (pid === null) continue;
|
||||
|
||||
processes.push({
|
||||
gpuIndex: indexByUuid.get(uuid) ?? null,
|
||||
pid,
|
||||
name,
|
||||
memoryUsedMiB: parseReading(memoryUsed, 0),
|
||||
});
|
||||
}
|
||||
|
||||
return processes;
|
||||
}
|
||||
|
||||
export function parseNvidiaSmiOutput(stdout: string): GpuMetrics {
|
||||
const lines = stdout.split("\n");
|
||||
const separator = lines.findIndex(
|
||||
(line) => line.trim() === SECTION_SEPARATOR,
|
||||
);
|
||||
const gpuSection =
|
||||
separator === -1 ? stdout : lines.slice(0, separator).join("\n");
|
||||
const appsSection =
|
||||
separator === -1 ? "" : lines.slice(separator + 1).join("\n");
|
||||
|
||||
const gpus = parseNvidiaSmiGpus(gpuSection);
|
||||
if (gpus.length === 0) return NO_GPU;
|
||||
|
||||
return {
|
||||
source: "nvidia-smi",
|
||||
gpus,
|
||||
processes: parseNvidiaSmiProcesses(appsSection, gpus),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectGpuMetrics(client: Client): Promise<GpuMetrics> {
|
||||
try {
|
||||
// nvidia-smi can take a few seconds on hosts without persistence mode.
|
||||
const result = await execCommand(client, NVIDIA_SMI_COMMAND, 15000);
|
||||
return parseNvidiaSmiOutput(result.stdout);
|
||||
} catch {
|
||||
// expected when the host has no NVIDIA driver or the query times out
|
||||
return NO_GPU;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
export interface LoginRecord {
|
||||
user: string;
|
||||
ip: string;
|
||||
time: string;
|
||||
status: "success" | "failed";
|
||||
}
|
||||
|
||||
export interface LoginStats {
|
||||
recentLogins: LoginRecord[];
|
||||
failedLogins: LoginRecord[];
|
||||
totalLogins: number;
|
||||
uniqueIPs: number;
|
||||
}
|
||||
|
||||
export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
const recentLogins: LoginRecord[] = [];
|
||||
const failedLogins: LoginRecord[] = [];
|
||||
const ipSet = new Set<string>();
|
||||
|
||||
try {
|
||||
const lastOut = await execCommand(
|
||||
client,
|
||||
"last -n 20 -F -w | grep -v 'reboot' | grep -v 'wtmp' | head -20",
|
||||
);
|
||||
|
||||
const lastLines = lastOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lastLines) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 10) {
|
||||
const user = parts[0];
|
||||
const tty = parts[1];
|
||||
const ip =
|
||||
parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2];
|
||||
|
||||
const timeStart = parts.indexOf(
|
||||
parts.find((p) => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "",
|
||||
);
|
||||
if (timeStart > 0 && parts.length > timeStart + 4) {
|
||||
const timeStr = parts.slice(timeStart, timeStart + 5).join(" ");
|
||||
|
||||
if (user && user !== "wtmp" && tty !== "system") {
|
||||
let parsedTime: string;
|
||||
try {
|
||||
const date = new Date(timeStr);
|
||||
parsedTime = isNaN(date.getTime())
|
||||
? timeStr || "unknown"
|
||||
: date.toISOString();
|
||||
} catch {
|
||||
parsedTime = timeStr || "unknown";
|
||||
}
|
||||
|
||||
recentLogins.push({
|
||||
user,
|
||||
ip,
|
||||
time: parsedTime,
|
||||
status: "success",
|
||||
});
|
||||
if (ip !== "local") {
|
||||
ipSet.add(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
try {
|
||||
const failedOut = await execCommand(
|
||||
client,
|
||||
"grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || echo ''",
|
||||
);
|
||||
|
||||
const failedLines = failedOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of failedLines) {
|
||||
let user = "unknown";
|
||||
let ip = "unknown";
|
||||
let timeStr = "";
|
||||
|
||||
const userMatch = line.match(/for (?:invalid user )?(\S+)/);
|
||||
if (userMatch) {
|
||||
user = userMatch[1];
|
||||
}
|
||||
|
||||
const ipMatch = line.match(/from (\d+\.\d+\.\d+\.\d+)/);
|
||||
if (ipMatch) {
|
||||
ip = ipMatch[1];
|
||||
}
|
||||
|
||||
const dateMatch = line.match(/^(\w+)\s+(\d+)\s+(\d+:\d+:\d+)/);
|
||||
if (dateMatch) {
|
||||
const [, month, day, time] = dateMatch;
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const candidate = new Date(`${month} ${day}, ${currentYear} ${time}`);
|
||||
if (!isNaN(candidate.getTime()) && candidate > now) {
|
||||
// If parsed date is in the future, it's from last year
|
||||
timeStr = `${month} ${day}, ${currentYear - 1} ${time}`;
|
||||
} else {
|
||||
timeStr = `${month} ${day}, ${currentYear} ${time}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (user && ip) {
|
||||
let parsedTime: string;
|
||||
try {
|
||||
const date = timeStr ? new Date(timeStr) : null;
|
||||
parsedTime =
|
||||
date && !isNaN(date.getTime())
|
||||
? date.toISOString()
|
||||
: timeStr || "unknown";
|
||||
} catch {
|
||||
parsedTime = timeStr || "unknown";
|
||||
}
|
||||
|
||||
failedLogins.push({
|
||||
user,
|
||||
ip,
|
||||
time: parsedTime,
|
||||
status: "failed",
|
||||
});
|
||||
if (ip !== "unknown") {
|
||||
ipSet.add(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
recentLogins: recentLogins.slice(0, 10),
|
||||
failedLogins: failedLogins.slice(0, 10),
|
||||
totalLogins: recentLogins.length,
|
||||
uniqueIPs: ipSet.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { toFixedNum, kibToGiB } from "../util.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
async function collectDarwinMemoryMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
usedGiB: number | null;
|
||||
totalGiB: number | null;
|
||||
}> {
|
||||
let memPercent: number | null = null;
|
||||
let usedGiB: number | null = null;
|
||||
let totalGiB: number | null = null;
|
||||
|
||||
try {
|
||||
const [vmStatOut, memSizeOut] = await Promise.all([
|
||||
execCommand(client, "vm_stat"),
|
||||
execCommand(client, "sysctl -n hw.memsize"),
|
||||
]);
|
||||
|
||||
const totalBytes = Number((memSizeOut.stdout || "").trim());
|
||||
const pageSizeMatch = vmStatOut.stdout.match(/page size of (\d+) bytes/);
|
||||
const pageSize = pageSizeMatch ? Number(pageSizeMatch[1]) : 4096;
|
||||
|
||||
const getPages = (key: string): number | null => {
|
||||
const line = vmStatOut.stdout
|
||||
.split("\n")
|
||||
.find((l) => l.trim().startsWith(key));
|
||||
if (!line) return null;
|
||||
const m = line.match(/(\d+)\./);
|
||||
return m ? Number(m[1]) : null;
|
||||
};
|
||||
|
||||
const active = getPages("Pages active");
|
||||
const wired = getPages("Pages wired down");
|
||||
const compressed = getPages("Pages occupied by compressor");
|
||||
|
||||
if (
|
||||
Number.isFinite(totalBytes) &&
|
||||
totalBytes > 0 &&
|
||||
active !== null &&
|
||||
wired !== null
|
||||
) {
|
||||
const usedPages = active + wired + (compressed ?? 0);
|
||||
const usedBytes = usedPages * pageSize;
|
||||
memPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
|
||||
usedGiB = kibToGiB(usedBytes / 1024);
|
||||
totalGiB = kibToGiB(totalBytes / 1024);
|
||||
}
|
||||
} catch {
|
||||
memPercent = null;
|
||||
usedGiB = null;
|
||||
totalGiB = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(memPercent, 0),
|
||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Win32_OperatingSystem reports TotalVisibleMemorySize/FreePhysicalMemory in KB.
|
||||
async function collectWindowsMemoryMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
usedGiB: number | null;
|
||||
totalGiB: number | null;
|
||||
}> {
|
||||
let memPercent: number | null = null;
|
||||
let usedGiB: number | null = null;
|
||||
let totalGiB: number | null = null;
|
||||
|
||||
try {
|
||||
const { stdout } = await execPowerShell(
|
||||
client,
|
||||
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{total=$os.TotalVisibleMemorySize; free=$os.FreePhysicalMemory} | ConvertTo-Json -Compress",
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
const totalKb = Number(parsed?.total);
|
||||
const freeKb = Number(parsed?.free);
|
||||
if (Number.isFinite(totalKb) && Number.isFinite(freeKb) && totalKb > 0) {
|
||||
const usedKb = totalKb - freeKb;
|
||||
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
||||
usedGiB = kibToGiB(usedKb);
|
||||
totalGiB = kibToGiB(totalKb);
|
||||
}
|
||||
} catch {
|
||||
memPercent = null;
|
||||
usedGiB = null;
|
||||
totalGiB = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(memPercent, 0),
|
||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectMemoryMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
): Promise<{
|
||||
percent: number | null;
|
||||
usedGiB: number | null;
|
||||
totalGiB: number | null;
|
||||
}> {
|
||||
if (platform === "darwin") {
|
||||
return collectDarwinMemoryMetrics(client);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsMemoryMetrics(client);
|
||||
}
|
||||
|
||||
let memPercent: number | null = null;
|
||||
let usedGiB: number | null = null;
|
||||
let totalGiB: number | null = null;
|
||||
|
||||
try {
|
||||
const memInfo = await execCommand(client, "cat /proc/meminfo");
|
||||
const lines = memInfo.stdout.split("\n");
|
||||
const getVal = (key: string) => {
|
||||
const line = lines.find((l) => l.startsWith(key));
|
||||
if (!line) return null;
|
||||
const m = line.match(/\d+/);
|
||||
return m ? Number(m[0]) : null;
|
||||
};
|
||||
const totalKb = getVal("MemTotal:");
|
||||
const availKb = getVal("MemAvailable:");
|
||||
if (totalKb && availKb && totalKb > 0) {
|
||||
const usedKb = totalKb - availKb;
|
||||
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
||||
usedGiB = kibToGiB(usedKb);
|
||||
totalGiB = kibToGiB(totalKb);
|
||||
}
|
||||
} catch {
|
||||
memPercent = null;
|
||||
usedGiB = null;
|
||||
totalGiB = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(memPercent, 0),
|
||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
export interface NetworkCounters {
|
||||
rx: string;
|
||||
tx: string;
|
||||
}
|
||||
|
||||
export function parseNetworkCounters(
|
||||
output: string,
|
||||
): Map<string, NetworkCounters> {
|
||||
const counters = new Map<string, NetworkCounters>();
|
||||
for (const line of output.split("\n").slice(2)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 10) {
|
||||
counters.set(parts[0].replace(":", ""), {
|
||||
rx: parts[1],
|
||||
tx: parts[9],
|
||||
});
|
||||
}
|
||||
}
|
||||
return counters;
|
||||
}
|
||||
|
||||
export function counterRate(
|
||||
before: string | undefined,
|
||||
after: string | undefined,
|
||||
elapsedSeconds: number,
|
||||
): number | null {
|
||||
const first = Number(before);
|
||||
const second = Number(after);
|
||||
if (
|
||||
!Number.isFinite(first) ||
|
||||
!Number.isFinite(second) ||
|
||||
second < first ||
|
||||
elapsedSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Math.round((second - first) / elapsedSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate is derived from the previous poll's counters rather than a same-poll
|
||||
* double-sample, so a poll no longer has to block for a fixed settle window.
|
||||
* Keyed by host, since counters from one host are meaningless against another.
|
||||
*/
|
||||
export interface NetworkSamples {
|
||||
counters: Map<
|
||||
number,
|
||||
{ counters: Map<string, NetworkCounters>; timestamp: number }
|
||||
>;
|
||||
windows: Map<number, { rows: WindowsAdapterRow[]; timestamp: number }>;
|
||||
}
|
||||
|
||||
export function createNetworkSamples(): NetworkSamples {
|
||||
return { counters: new Map(), windows: new Map() };
|
||||
}
|
||||
|
||||
export function parseDarwinIfconfig(
|
||||
output: string,
|
||||
): Map<string, { ip: string; state: string }> {
|
||||
const map = new Map<string, { ip: string; state: string }>();
|
||||
let current: string | null = null;
|
||||
for (const rawLine of output.split("\n")) {
|
||||
const ifaceMatch = rawLine.match(/^(\S+):\s*flags=\d+<([^>]*)>/);
|
||||
if (ifaceMatch) {
|
||||
current = ifaceMatch[1] === "lo0" ? null : ifaceMatch[1];
|
||||
if (current) {
|
||||
const state = ifaceMatch[2].includes("UP") ? "UP" : "DOWN";
|
||||
map.set(current, { ip: "", state });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const inetMatch = rawLine.match(/^\s+inet\s+(\d+\.\d+\.\d+\.\d+)/);
|
||||
if (inetMatch) {
|
||||
const existing = map.get(current);
|
||||
if (existing && !existing.ip) existing.ip = inetMatch[1];
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function parseDarwinNetstat(
|
||||
output: string,
|
||||
): Map<string, NetworkCounters> {
|
||||
const counters = new Map<string, NetworkCounters>();
|
||||
for (const line of output.split("\n").slice(1)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 10) continue;
|
||||
const name = parts[0];
|
||||
if (name === "lo0" || !parts[2]?.startsWith("<Link")) continue;
|
||||
if (!counters.has(name)) {
|
||||
counters.set(name, { rx: parts[6], tx: parts[9] });
|
||||
}
|
||||
}
|
||||
return counters;
|
||||
}
|
||||
|
||||
async function collectDarwinNetworkMetrics(
|
||||
client: Client,
|
||||
hostId: number | undefined,
|
||||
samples: NetworkSamples,
|
||||
): Promise<{
|
||||
interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}>;
|
||||
}> {
|
||||
const interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const ifconfigOut = await execCommand(client, "ifconfig -a 2>/dev/null");
|
||||
const ifMap = parseDarwinIfconfig(ifconfigOut.stdout);
|
||||
|
||||
try {
|
||||
const readAt = Date.now();
|
||||
const netstatOut = await execCommand(client, "netstat -ib 2>/dev/null");
|
||||
const current = parseDarwinNetstat(netstatOut.stdout);
|
||||
const previous =
|
||||
hostId !== undefined ? samples.counters.get(hostId) : undefined;
|
||||
const elapsedSeconds = previous
|
||||
? (readAt - previous.timestamp) / 1000
|
||||
: 0;
|
||||
if (ifMap.size === 0) {
|
||||
for (const name of current.keys()) {
|
||||
ifMap.set(name, { ip: "", state: "UNKNOWN" });
|
||||
}
|
||||
}
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
const c = current.get(name);
|
||||
const p = previous?.counters.get(name);
|
||||
interfaces.push({
|
||||
name,
|
||||
ip: data.ip,
|
||||
state: data.state,
|
||||
rxBytes: c?.rx ?? null,
|
||||
txBytes: c?.tx ?? null,
|
||||
rxRateBps: p ? counterRate(p.rx, c?.rx, elapsedSeconds) : null,
|
||||
txRateBps: p ? counterRate(p.tx, c?.tx, elapsedSeconds) : null,
|
||||
});
|
||||
}
|
||||
if (hostId !== undefined) {
|
||||
samples.counters.set(hostId, { counters: current, timestamp: readAt });
|
||||
}
|
||||
} catch {
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
interfaces.push({
|
||||
name,
|
||||
ip: data.ip,
|
||||
state: data.state,
|
||||
rxBytes: null,
|
||||
txBytes: null,
|
||||
rxRateBps: null,
|
||||
txRateBps: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return { interfaces };
|
||||
}
|
||||
|
||||
interface WindowsAdapterRow {
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rx: string;
|
||||
tx: string;
|
||||
}
|
||||
|
||||
const WINDOWS_ADAPTER_SCRIPT =
|
||||
"Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | ForEach-Object {" +
|
||||
" $stats = Get-NetAdapterStatistics -Name $_.Name -ErrorAction SilentlyContinue;" +
|
||||
" $addr = (Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -First 1).IPAddress;" +
|
||||
" [PSCustomObject]@{name=$_.Name; ip=$addr; state='UP'; rx=$stats.ReceivedBytes; tx=$stats.SentBytes}" +
|
||||
" } | ConvertTo-Json -Compress";
|
||||
|
||||
export function parseWindowsAdapterJson(output: string): WindowsAdapterRow[] {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return rows
|
||||
.filter((row): row is Record<string, unknown> => Boolean(row?.name))
|
||||
.map((row) => ({
|
||||
name: String(row.name),
|
||||
ip: row.ip ? String(row.ip) : "",
|
||||
state: String(row.state ?? "UNKNOWN"),
|
||||
rx: String(row.rx ?? ""),
|
||||
tx: String(row.tx ?? ""),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function collectWindowsNetworkMetrics(
|
||||
client: Client,
|
||||
hostId: number | undefined,
|
||||
samples: NetworkSamples,
|
||||
): Promise<{
|
||||
interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}>;
|
||||
}> {
|
||||
const interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const readAt = Date.now();
|
||||
const result = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
|
||||
const currentRows = parseWindowsAdapterJson(result.stdout);
|
||||
const previous =
|
||||
hostId !== undefined ? samples.windows.get(hostId) : undefined;
|
||||
const elapsedSeconds = previous ? (readAt - previous.timestamp) / 1000 : 0;
|
||||
const previousMap = new Map(
|
||||
(previous?.rows ?? []).map((row) => [row.name, row]),
|
||||
);
|
||||
|
||||
for (const row of currentRows) {
|
||||
const previousRow = previousMap.get(row.name);
|
||||
interfaces.push({
|
||||
name: row.name,
|
||||
ip: row.ip,
|
||||
state: row.state,
|
||||
rxBytes: row.rx || null,
|
||||
txBytes: row.tx || null,
|
||||
rxRateBps: previousRow
|
||||
? counterRate(previousRow.rx, row.rx, elapsedSeconds)
|
||||
: null,
|
||||
txRateBps: previousRow
|
||||
? counterRate(previousRow.tx, row.tx, elapsedSeconds)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (hostId !== undefined) {
|
||||
samples.windows.set(hostId, {
|
||||
rows: currentRows,
|
||||
timestamp: readAt,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return { interfaces };
|
||||
}
|
||||
|
||||
export async function collectNetworkMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
hostId?: number,
|
||||
samples: NetworkSamples = createNetworkSamples(),
|
||||
): Promise<{
|
||||
interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}>;
|
||||
}> {
|
||||
if (platform === "darwin") {
|
||||
return collectDarwinNetworkMetrics(client, hostId, samples);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsNetworkMetrics(client, hostId, samples);
|
||||
}
|
||||
|
||||
const interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const ifconfigOut = await execCommand(
|
||||
client,
|
||||
"ip -o addr show 2>/dev/null | awk '{print $2,$4}' | grep -v '^lo' || true",
|
||||
);
|
||||
const netStatOut = await execCommand(
|
||||
client,
|
||||
"ip -o link show 2>/dev/null | awk '{gsub(/:/, \"\", $2); print $2,$9}' || true",
|
||||
);
|
||||
|
||||
const addrs = ifconfigOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const states = netStatOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const ifMap = new Map<string, { ip: string; state: string }>();
|
||||
for (const line of addrs) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
const ip = parts[1].split("/")[0];
|
||||
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
|
||||
}
|
||||
}
|
||||
for (const line of states) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
if (name === "lo") continue;
|
||||
const state = parts[1];
|
||||
const existing = ifMap.get(name);
|
||||
if (existing) {
|
||||
existing.state = state;
|
||||
} else {
|
||||
ifMap.set(name, { ip: "", state });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const readAt = Date.now();
|
||||
const procNet = await execCommand(client, "cat /proc/net/dev");
|
||||
const rxTxMap = parseNetworkCounters(procNet.stdout);
|
||||
const previous =
|
||||
hostId !== undefined ? samples.counters.get(hostId) : undefined;
|
||||
const elapsedSeconds = previous
|
||||
? (readAt - previous.timestamp) / 1000
|
||||
: 0;
|
||||
if (ifMap.size === 0) {
|
||||
for (const name of rxTxMap.keys()) {
|
||||
if (name !== "lo") ifMap.set(name, { ip: "", state: "UNKNOWN" });
|
||||
}
|
||||
}
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
const rxTx = rxTxMap.get(name);
|
||||
const previousCounters = previous?.counters.get(name);
|
||||
interfaces.push({
|
||||
name,
|
||||
ip: data.ip,
|
||||
state: data.state,
|
||||
rxBytes: rxTx?.rx ?? null,
|
||||
txBytes: rxTx?.tx ?? null,
|
||||
rxRateBps: previousCounters
|
||||
? counterRate(previousCounters.rx, rxTx?.rx, elapsedSeconds)
|
||||
: null,
|
||||
txRateBps: previousCounters
|
||||
? counterRate(previousCounters.tx, rxTx?.tx, elapsedSeconds)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (hostId !== undefined) {
|
||||
samples.counters.set(hostId, {
|
||||
counters: rxTxMap,
|
||||
timestamp: readAt,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
interfaces.push({
|
||||
name,
|
||||
ip: data.ip,
|
||||
state: data.state,
|
||||
rxBytes: null,
|
||||
txBytes: null,
|
||||
rxRateBps: null,
|
||||
txRateBps: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return { interfaces };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
PortsMetrics,
|
||||
ListeningPort,
|
||||
} from "../../shared/stats-widgets.js";
|
||||
|
||||
function parseSsOutput(output: string): ListeningPort[] {
|
||||
const ports: ListeningPort[] = [];
|
||||
const lines = output.split("\n").slice(1);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length < 5) continue;
|
||||
|
||||
const protocol = parts[0]?.toLowerCase();
|
||||
if (protocol !== "tcp" && protocol !== "udp") continue;
|
||||
|
||||
const state = parts[1];
|
||||
const localAddr = parts[4];
|
||||
|
||||
if (!localAddr) continue;
|
||||
|
||||
const lastColon = localAddr.lastIndexOf(":");
|
||||
if (lastColon === -1) continue;
|
||||
|
||||
const address = localAddr.substring(0, lastColon);
|
||||
const portStr = localAddr.substring(lastColon + 1);
|
||||
const port = parseInt(portStr, 10);
|
||||
|
||||
if (isNaN(port)) continue;
|
||||
|
||||
const portEntry: ListeningPort = {
|
||||
protocol: protocol as "tcp" | "udp",
|
||||
localAddress: address.replace(/^\[|\]$/g, ""),
|
||||
localPort: port,
|
||||
state: protocol === "tcp" ? state : undefined,
|
||||
};
|
||||
|
||||
const processInfo = parts[6];
|
||||
if (processInfo && processInfo.startsWith("users:")) {
|
||||
const pidMatch = processInfo.match(/pid=(\d+)/);
|
||||
const nameMatch = processInfo.match(/\("([^"]+)"/);
|
||||
if (pidMatch) portEntry.pid = parseInt(pidMatch[1], 10);
|
||||
if (nameMatch) portEntry.process = nameMatch[1];
|
||||
}
|
||||
|
||||
ports.push(portEntry);
|
||||
}
|
||||
|
||||
return ports;
|
||||
}
|
||||
|
||||
function parseNetstatOutput(output: string): ListeningPort[] {
|
||||
const ports: ListeningPort[] = [];
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
|
||||
const proto = parts[0]?.toLowerCase();
|
||||
if (!proto) continue;
|
||||
|
||||
let protocol: "tcp" | "udp";
|
||||
if (proto.startsWith("tcp")) {
|
||||
protocol = "tcp";
|
||||
} else if (proto.startsWith("udp")) {
|
||||
protocol = "udp";
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const localAddr = parts[3];
|
||||
if (!localAddr) continue;
|
||||
|
||||
const lastColon = localAddr.lastIndexOf(":");
|
||||
if (lastColon === -1) continue;
|
||||
|
||||
const address = localAddr.substring(0, lastColon);
|
||||
const portStr = localAddr.substring(lastColon + 1);
|
||||
const port = parseInt(portStr, 10);
|
||||
|
||||
if (isNaN(port)) continue;
|
||||
|
||||
const portEntry: ListeningPort = {
|
||||
protocol,
|
||||
localAddress: address,
|
||||
localPort: port,
|
||||
};
|
||||
|
||||
if (protocol === "tcp" && parts.length >= 6) {
|
||||
portEntry.state = parts[5];
|
||||
}
|
||||
|
||||
const pidProgram = parts[parts.length - 1];
|
||||
if (pidProgram && pidProgram.includes("/")) {
|
||||
const [pidStr, process] = pidProgram.split("/");
|
||||
const pid = parseInt(pidStr, 10);
|
||||
if (!isNaN(pid)) portEntry.pid = pid;
|
||||
if (process) portEntry.process = process;
|
||||
}
|
||||
|
||||
ports.push(portEntry);
|
||||
}
|
||||
|
||||
return ports;
|
||||
}
|
||||
|
||||
export async function collectPortsMetrics(
|
||||
client: Client,
|
||||
): Promise<PortsMetrics> {
|
||||
try {
|
||||
const ssResult = await execCommand(client, "ss -tulnp 2>/dev/null", 15000);
|
||||
|
||||
if (ssResult.stdout && ssResult.stdout.includes("Local")) {
|
||||
const ports = parseSsOutput(ssResult.stdout);
|
||||
return {
|
||||
source: "ss",
|
||||
ports: ports.sort((a, b) => a.localPort - b.localPort),
|
||||
};
|
||||
}
|
||||
|
||||
const netstatResult = await execCommand(
|
||||
client,
|
||||
"netstat -tulnp 2>/dev/null",
|
||||
15000,
|
||||
);
|
||||
|
||||
if (netstatResult.stdout && netstatResult.stdout.includes("Local")) {
|
||||
const ports = parseNetstatOutput(netstatResult.stdout);
|
||||
return {
|
||||
source: "netstat",
|
||||
ports: ports.sort((a, b) => a.localPort - b.localPort),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: "none",
|
||||
ports: [],
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
source: "none",
|
||||
ports: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
export async function collectProcessesMetrics(client: Client): Promise<{
|
||||
total: number | null;
|
||||
running: number | null;
|
||||
top: Array<{
|
||||
pid: string;
|
||||
user: string;
|
||||
cpu: string;
|
||||
mem: string;
|
||||
command: string;
|
||||
}>;
|
||||
}> {
|
||||
let totalProcesses: number | null = null;
|
||||
let runningProcesses: number | null = null;
|
||||
const topProcesses: Array<{
|
||||
pid: string;
|
||||
user: string;
|
||||
cpu: string;
|
||||
mem: string;
|
||||
command: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const psOut = await execCommand(
|
||||
client,
|
||||
"(ps aux --sort=-%cpu 2>/dev/null || ps aux) | head -n 11",
|
||||
);
|
||||
const psLines = psOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (psLines.length > 1) {
|
||||
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
|
||||
const parts = psLines[i].split(/\s+/);
|
||||
if (parts.length >= 11) {
|
||||
const cpuVal = Number(parts[2]);
|
||||
const memVal = Number(parts[3]);
|
||||
topProcesses.push({
|
||||
pid: parts[1],
|
||||
user: parts[0],
|
||||
cpu: Number.isFinite(cpuVal) ? cpuVal.toString() : "0",
|
||||
mem: Number.isFinite(memVal) ? memVal.toString() : "0",
|
||||
command: parts.slice(10).join(" ").substring(0, 50),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const procCount = await execCommand(client, "ps aux | wc -l");
|
||||
const runningCount = await execCommand(client, "ps aux | grep -c ' R '");
|
||||
|
||||
const totalCount = Number(procCount.stdout.trim()) - 1;
|
||||
totalProcesses = Number.isFinite(totalCount) ? totalCount : null;
|
||||
|
||||
const runningCount2 = Number(runningCount.stdout.trim());
|
||||
runningProcesses = Number.isFinite(runningCount2) ? runningCount2 : null;
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
total: totalProcesses,
|
||||
running: runningProcesses,
|
||||
top: topProcesses,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
async function collectWindowsSystemMetrics(client: Client): Promise<{
|
||||
hostname: string | null;
|
||||
kernel: string | null;
|
||||
os: string | null;
|
||||
}> {
|
||||
let hostname: string | null = null;
|
||||
let kernel: string | null = null;
|
||||
let os: string | null = null;
|
||||
|
||||
try {
|
||||
const { stdout } = await execPowerShell(
|
||||
client,
|
||||
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{hostname=$env:COMPUTERNAME; kernel=[System.Environment]::OSVersion.Version.ToString(); os=$os.Caption} | ConvertTo-Json -Compress",
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
hostname = parsed?.hostname ? String(parsed.hostname) : null;
|
||||
kernel = parsed?.kernel ? String(parsed.kernel) : null;
|
||||
os = parsed?.os ? String(parsed.os).trim() : null;
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return { hostname, kernel, os };
|
||||
}
|
||||
|
||||
export async function collectSystemMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
): Promise<{
|
||||
hostname: string | null;
|
||||
kernel: string | null;
|
||||
os: string | null;
|
||||
}> {
|
||||
if (platform === "windows") {
|
||||
return collectWindowsSystemMetrics(client);
|
||||
}
|
||||
|
||||
let hostname: string | null = null;
|
||||
let kernel: string | null = null;
|
||||
let os: string | null = null;
|
||||
|
||||
try {
|
||||
const hostnameOut = await execCommand(client, "hostname");
|
||||
const kernelOut = await execCommand(client, "uname -r");
|
||||
const osOut = await execCommand(
|
||||
client,
|
||||
"command -v sw_vers >/dev/null 2>&1 && echo \"$(sw_vers -productName) $(sw_vers -productVersion)\" || (cat /etc/os-release 2>/dev/null | grep '^PRETTY_NAME=' | cut -d'\"' -f2)",
|
||||
);
|
||||
|
||||
hostname = hostnameOut.stdout.trim() || null;
|
||||
kernel = kernelOut.stdout.trim() || null;
|
||||
os = osOut.stdout.trim() || null;
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
hostname,
|
||||
kernel,
|
||||
os,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import { toFixedNum } from "../util.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
export interface TemperatureSensor {
|
||||
label: string;
|
||||
celsius: number;
|
||||
}
|
||||
|
||||
export interface TemperatureMetrics {
|
||||
source: "sysfs" | "sensors" | "none";
|
||||
highestCelsius: number | null;
|
||||
sensors: TemperatureSensor[];
|
||||
}
|
||||
|
||||
function normalizeSensor(
|
||||
label: string,
|
||||
celsius: number,
|
||||
): TemperatureSensor | null {
|
||||
const cleanLabel = label.trim().replace(/\s+/g, " ");
|
||||
if (!cleanLabel || !Number.isFinite(celsius)) return null;
|
||||
return {
|
||||
label: cleanLabel,
|
||||
celsius: toFixedNum(celsius, 1) ?? celsius,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeSensors(
|
||||
source: TemperatureMetrics["source"],
|
||||
sensors: TemperatureSensor[],
|
||||
): TemperatureMetrics {
|
||||
const highest = sensors.reduce<number | null>(
|
||||
(max, sensor) =>
|
||||
max === null ? sensor.celsius : Math.max(max, sensor.celsius),
|
||||
null,
|
||||
);
|
||||
|
||||
return {
|
||||
source: sensors.length > 0 ? source : "none",
|
||||
highestCelsius:
|
||||
highest === null ? null : (toFixedNum(highest, 1) ?? highest),
|
||||
sensors,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSysfsThermalOutput(output: string): TemperatureSensor[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const [label, rawValue] = line.split("\t");
|
||||
const value = Number(rawValue);
|
||||
if (!label || !Number.isFinite(value)) return null;
|
||||
const celsius = Math.abs(value) >= 1000 ? value / 1000 : value;
|
||||
return normalizeSensor(label, celsius);
|
||||
})
|
||||
.filter((sensor): sensor is TemperatureSensor => sensor !== null);
|
||||
}
|
||||
|
||||
export function parseSensorsOutput(output: string): TemperatureSensor[] {
|
||||
const seen = new Set<string>();
|
||||
const sensors: TemperatureSensor[] = [];
|
||||
let device = "";
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !line.startsWith(" ") && !trimmed.includes(":")) {
|
||||
device = trimmed;
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/^\s*([^:]+):\s*([+-]?\d+(?:\.\d+)?)\s*°?C\b/i);
|
||||
if (!match) continue;
|
||||
|
||||
const label = device ? `${device}: ${match[1]}` : match[1];
|
||||
const sensor = normalizeSensor(label, Number(match[2]));
|
||||
if (!sensor) continue;
|
||||
|
||||
const key = `${sensor.label.toLowerCase()}:${sensor.celsius}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
sensors.push(sensor);
|
||||
}
|
||||
|
||||
return sensors;
|
||||
}
|
||||
|
||||
export async function collectTemperatureMetrics(
|
||||
client: Client,
|
||||
): Promise<TemperatureMetrics> {
|
||||
try {
|
||||
const sysfs = await execCommand(
|
||||
client,
|
||||
[
|
||||
"for zone in /sys/class/thermal/thermal_zone*; do",
|
||||
'[ -r "$zone/temp" ] || continue;',
|
||||
'label="$(cat "$zone/type" 2>/dev/null || basename "$zone")";',
|
||||
'value="$(cat "$zone/temp" 2>/dev/null || true)";',
|
||||
'[ -n "$value" ] && printf "%s\\t%s\\n" "$label" "$value";',
|
||||
"done",
|
||||
].join(" "),
|
||||
10000,
|
||||
);
|
||||
const sensors = parseSysfsThermalOutput(sysfs.stdout);
|
||||
if (sensors.length > 0) {
|
||||
return summarizeSensors("sysfs", sensors);
|
||||
}
|
||||
} catch {
|
||||
// expected on systems without readable thermal zones
|
||||
}
|
||||
|
||||
try {
|
||||
const lmSensors = await execCommand(client, "sensors 2>/dev/null", 10000);
|
||||
const sensors = parseSensorsOutput(lmSensors.stdout);
|
||||
if (sensors.length > 0) {
|
||||
return summarizeSensors("sensors", sensors);
|
||||
}
|
||||
} catch {
|
||||
// expected when lm-sensors is not installed
|
||||
}
|
||||
|
||||
return {
|
||||
source: "none",
|
||||
highestCelsius: null,
|
||||
sensors: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
function formatUptime(uptimeSeconds: number): string {
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
async function collectDarwinUptimeMetrics(client: Client): Promise<{
|
||||
seconds: number | null;
|
||||
formatted: string | null;
|
||||
}> {
|
||||
let uptimeSeconds: number | null = null;
|
||||
let uptimeFormatted: string | null = null;
|
||||
|
||||
try {
|
||||
const bootTimeOut = await execCommand(client, "sysctl -n kern.boottime");
|
||||
// "{ sec = 1700000000, usec = 123456 } Thu Nov 16 ..."
|
||||
const match = bootTimeOut.stdout.match(/sec\s*=\s*(\d+)/);
|
||||
if (match) {
|
||||
const bootSeconds = Number(match[1]);
|
||||
if (Number.isFinite(bootSeconds)) {
|
||||
uptimeSeconds = Math.max(0, Date.now() / 1000 - bootSeconds);
|
||||
uptimeFormatted = formatUptime(uptimeSeconds);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
seconds: uptimeSeconds,
|
||||
formatted: uptimeFormatted,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectWindowsUptimeMetrics(client: Client): Promise<{
|
||||
seconds: number | null;
|
||||
formatted: string | null;
|
||||
}> {
|
||||
let uptimeSeconds: number | null = null;
|
||||
let uptimeFormatted: string | null = null;
|
||||
|
||||
try {
|
||||
const { stdout } = await execPowerShell(
|
||||
client,
|
||||
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{seconds=((Get-Date) - $os.LastBootUpTime).TotalSeconds} | ConvertTo-Json -Compress",
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
const seconds = Number(parsed?.seconds);
|
||||
if (Number.isFinite(seconds)) {
|
||||
uptimeSeconds = Math.max(0, seconds);
|
||||
uptimeFormatted = formatUptime(uptimeSeconds);
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
seconds: uptimeSeconds,
|
||||
formatted: uptimeFormatted,
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectUptimeMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
): Promise<{
|
||||
seconds: number | null;
|
||||
formatted: string | null;
|
||||
}> {
|
||||
if (platform === "darwin") {
|
||||
return collectDarwinUptimeMetrics(client);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsUptimeMetrics(client);
|
||||
}
|
||||
|
||||
let uptimeSeconds: number | null = null;
|
||||
let uptimeFormatted: string | null = null;
|
||||
|
||||
try {
|
||||
const uptimeOut = await execCommand(client, "cat /proc/uptime");
|
||||
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
|
||||
if (uptimeParts.length >= 1) {
|
||||
uptimeSeconds = Number(uptimeParts[0]);
|
||||
if (Number.isFinite(uptimeSeconds)) {
|
||||
uptimeFormatted = formatUptime(uptimeSeconds);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
seconds: uptimeSeconds,
|
||||
formatted: uptimeFormatted,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
Button,
|
||||
HostDefaultBadge,
|
||||
Input,
|
||||
SectionCard,
|
||||
SettingRow,
|
||||
FakeSwitch,
|
||||
useIsDefaultsEditor,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { HardDrive, LayoutDashboard, Plus, Server, Trash2 } from "lucide-react";
|
||||
|
||||
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
|
||||
|
||||
const PLUGIN_ID = "host-metrics";
|
||||
|
||||
type PluginSettingsBag = Record<string, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* The Host Metrics section of the host editor. Its values are this plugin's
|
||||
* host settings, carried on form.pluginSettings and saved by the editor after
|
||||
* the host itself.
|
||||
*/
|
||||
export function HostStatsTab({ form, updateForm }: HostEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [newMount, setNewMount] = useState("");
|
||||
const [newMonitoredPath, setNewMonitoredPath] = useState("");
|
||||
const [newMonitoredLabel, setNewMonitoredLabel] = useState("");
|
||||
const bag = (form.pluginSettings as PluginSettingsBag | undefined)?.[
|
||||
PLUGIN_ID
|
||||
];
|
||||
const settings = readHostMetricsSettings(bag);
|
||||
const { excludedMounts, monitoredMounts } = settings;
|
||||
const defaultsEditor = useIsDefaultsEditor();
|
||||
|
||||
const patch = (values: Record<string, unknown>) =>
|
||||
updateForm((current) => {
|
||||
const all =
|
||||
(current.pluginSettings as PluginSettingsBag | undefined) ?? {};
|
||||
return {
|
||||
pluginSettings: {
|
||||
...all,
|
||||
[PLUGIN_ID]: { ...(all[PLUGIN_ID] ?? {}), ...values },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const addExcludedMount = () => {
|
||||
const value = newMount.trim();
|
||||
if (!value || excludedMounts.includes(value)) {
|
||||
setNewMount("");
|
||||
return;
|
||||
}
|
||||
patch({ excludedMounts: [...excludedMounts, value] });
|
||||
setNewMount("");
|
||||
};
|
||||
|
||||
const addMonitoredMount = () => {
|
||||
const path = newMonitoredPath.trim();
|
||||
if (!path || monitoredMounts.some((entry) => entry.path === path)) return;
|
||||
patch({
|
||||
monitoredMounts: [
|
||||
...monitoredMounts,
|
||||
{ path, label: newMonitoredLabel.trim() || undefined },
|
||||
],
|
||||
});
|
||||
setNewMonitoredPath("");
|
||||
setNewMonitoredLabel("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.metricsCollectionLabel")}
|
||||
icon={<Server className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-0 py-1">
|
||||
<SettingRow
|
||||
label={t("hosts.enableMetricsLabel")}
|
||||
description={t("hosts.enableMetricsDesc")}
|
||||
defaultKey="metricsEnabled"
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={settings.metricsEnabled}
|
||||
onChange={(v) => patch({ metricsEnabled: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
{settings.metricsEnabled && (
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalMetrics")}
|
||||
description={t("hosts.useGlobalMetricsDesc")}
|
||||
defaultKey="metricsInterval"
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={settings.metricsInterval === null}
|
||||
onChange={(v) => patch({ metricsInterval: v ? null : 30 })}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{settings.metricsEnabled && settings.metricsInterval !== null && (
|
||||
<SettingRow
|
||||
label={t("hosts.metricsIntervalS")}
|
||||
description={t("hosts.metricsIntervalDesc2")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.metricsInterval ?? 30}
|
||||
onChange={(e) =>
|
||||
patch({ metricsInterval: Number(e.target.value) })
|
||||
}
|
||||
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.monitoredMountsLabel")}
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t("hosts.monitoredMountsDesc")}
|
||||
<HostDefaultBadge settingKey="monitoredMounts" />
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr_0.7fr_auto] gap-2">
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder={t("hosts.monitoredMountPathPlaceholder")}
|
||||
value={newMonitoredPath}
|
||||
onChange={(e) => setNewMonitoredPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addMonitoredMount();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder={t("hosts.monitoredMountLabelPlaceholder")}
|
||||
value={newMonitoredLabel}
|
||||
onChange={(e) => setNewMonitoredLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addMonitoredMount();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[10px]"
|
||||
onClick={addMonitoredMount}
|
||||
>
|
||||
<Plus className="mr-1 size-3" /> {t("hosts.addMonitoredMount")}
|
||||
</Button>
|
||||
</div>
|
||||
{monitoredMounts.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className="flex items-center gap-2 border border-border bg-muted/20 p-2 group"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{entry.path}
|
||||
</span>
|
||||
{entry.label && (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{entry.label}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||
onClick={() =>
|
||||
patch({
|
||||
monitoredMounts: monitoredMounts.filter(
|
||||
(mount) => mount.path !== entry.path,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.excludedMountsLabel")}
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t("hosts.excludedMountsDesc")}
|
||||
<HostDefaultBadge settingKey="excludedMounts" />
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder={t("hosts.excludedMountsPlaceholder")}
|
||||
value={newMount}
|
||||
onChange={(e) => setNewMount(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addExcludedMount();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
onClick={addExcludedMount}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.addExcludedMount")}
|
||||
</Button>
|
||||
</div>
|
||||
{excludedMounts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
|
||||
<HardDrive className="size-6" />
|
||||
<span className="text-xs">{t("hosts.excludedMountsEmpty")}</span>
|
||||
</div>
|
||||
)}
|
||||
{excludedMounts.map((mount, i) => (
|
||||
<div
|
||||
key={`${mount}-${i}`}
|
||||
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
|
||||
>
|
||||
<span className="text-xs flex-1 font-mono truncate">{mount}</span>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() =>
|
||||
patch({
|
||||
excludedMounts: excludedMounts.filter(
|
||||
(_, idx) => idx !== i,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
{!defaultsEditor && (
|
||||
<SectionCard
|
||||
title={t("hosts.visibleWidgets")}
|
||||
icon={<LayoutDashboard className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hosts.widgetsMovedToHostMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
|
||||
import React from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { HostMetricsTab } from "./HostMetricsTab.tsx";
|
||||
|
||||
interface HostMetricsAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const HostMetricsApp: React.FC<HostMetricsAppProps> = ({ 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 (
|
||||
<HostMetricsTab
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostMetricsApp;
|
||||
@@ -0,0 +1,655 @@
|
||||
import {
|
||||
Button,
|
||||
ComponentSlot,
|
||||
TOTPDialog,
|
||||
useTabsSafe,
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
ConnectionScreen,
|
||||
runAdaptivePolling,
|
||||
CardGridCanvas,
|
||||
ColumnCountStepper,
|
||||
type GridCardCatalogEntry,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
|
||||
import {
|
||||
defaultLayoutFromWidgets,
|
||||
type HostMetricsLayout,
|
||||
} from "../shared/host-metrics.js";
|
||||
import type { ServerMetrics } from "../shared/metrics.js";
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React from "react";
|
||||
import { useHostMetricsApi } from "./host-metrics-api";
|
||||
|
||||
import {
|
||||
useTranslation,
|
||||
useSlotContributions,
|
||||
useConnectionRetry,
|
||||
useHost,
|
||||
useHostStatus,
|
||||
logActivity,
|
||||
usePluginUiPreferences,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { RefreshCw, Server, LayoutDashboard } from "lucide-react";
|
||||
import { useHostMetricsPreferences } from "./hooks/useHostMetricsPreferences.ts";
|
||||
import {
|
||||
CARD_DEFINITIONS,
|
||||
IMPLEMENTED_CARD_IDS,
|
||||
getCardDefinition,
|
||||
definitionsFromSlot,
|
||||
defaultColSpanFor,
|
||||
defaultHeightFor,
|
||||
type MetricCardHistories,
|
||||
} from "./cards";
|
||||
import { appendGpuHistories } from "./cards/gpu-history";
|
||||
import { metricsChangeKey } from "./metrics-change-key";
|
||||
|
||||
const HISTORY_LEN = 30;
|
||||
|
||||
type ConnectionLogPayload = Parameters<
|
||||
ReturnType<typeof useConnectionLog>["addLog"]
|
||||
>[0];
|
||||
type ConnectionLogError = Error & {
|
||||
connectionLogs?: ConnectionLogPayload[];
|
||||
};
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
statusCheckEnabled?: boolean;
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
authType?: string;
|
||||
port?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface HostMetricsProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
function HostMetricsInner({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: HostMetricsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const metricsApi = useHostMetricsApi();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabsSafe();
|
||||
|
||||
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
||||
"offline",
|
||||
);
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
const [histories, setHistories] = React.useState<MetricCardHistories>({
|
||||
cpu: [],
|
||||
memory: [],
|
||||
disk: [],
|
||||
gpu: {},
|
||||
});
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
|
||||
const [totpVerified, setTotpVerified] = React.useState(false);
|
||||
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editMode, setEditMode] = React.useState(false);
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
|
||||
const statsConfig = React.useMemo(() => {
|
||||
const settings = readHostMetricsSettings(
|
||||
currentHostConfig?.pluginSettings?.["host-metrics"],
|
||||
);
|
||||
return { ...settings, metricsInterval: settings.metricsInterval ?? 30 };
|
||||
}, [currentHostConfig?.pluginSettings]);
|
||||
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||
const statusCheckEnabled = currentHostConfig?.statusCheckEnabled !== false;
|
||||
|
||||
const hostId = currentHostConfig?.id ?? null;
|
||||
const { layout, setLayout } = useHostMetricsPreferences(hostId);
|
||||
|
||||
const { values: metricsPrefs } = usePluginUiPreferences<{
|
||||
columns: number;
|
||||
}>();
|
||||
|
||||
const effectiveLayout: HostMetricsLayout = React.useMemo(() => {
|
||||
// A saved layout is user-authored, so the preset never rewrites it -- it
|
||||
// only decides the shape of the first layout a host gets.
|
||||
if (layout) return layout;
|
||||
return defaultLayoutFromWidgets(
|
||||
statsConfig.enabledWidgets ?? [],
|
||||
metricsPrefs.columns,
|
||||
);
|
||||
}, [layout, statsConfig.enabledWidgets, metricsPrefs.columns]);
|
||||
|
||||
// Manager cards other plugins contribute (tailscale's, say), merged in next
|
||||
// to the ones this plugin ships. A contribution for a slot that never
|
||||
// arrives (its plugin is off) just never appears here.
|
||||
const managerContributions = useSlotContributions("host-metrics.managers");
|
||||
const pluginManagerDefs = React.useMemo(
|
||||
() => definitionsFromSlot(managerContributions),
|
||||
[managerContributions],
|
||||
);
|
||||
|
||||
// Only render/keep cards that are implemented (metric cards in Phase A).
|
||||
const visibleSlots = React.useMemo(
|
||||
() =>
|
||||
effectiveLayout.slots.filter((s) =>
|
||||
getCardDefinition(s.id, pluginManagerDefs),
|
||||
),
|
||||
[effectiveLayout.slots, pluginManagerDefs],
|
||||
);
|
||||
|
||||
const cardCatalog: GridCardCatalogEntry[] = React.useMemo(
|
||||
() => [
|
||||
...IMPLEMENTED_CARD_IDS.map((id) => ({
|
||||
id,
|
||||
label: t(CARD_DEFINITIONS[id].labelKey),
|
||||
defaultColSpan: defaultColSpanFor(id),
|
||||
defaultHeight: defaultHeightFor(id),
|
||||
})),
|
||||
...Object.values(pluginManagerDefs).map((def) => ({
|
||||
id: def.id,
|
||||
label: t(def.labelKey),
|
||||
defaultColSpan: defaultColSpanFor(def.id),
|
||||
defaultHeight: defaultHeightFor(def.id),
|
||||
})),
|
||||
],
|
||||
[t, pluginManagerDefs],
|
||||
);
|
||||
|
||||
const cardLabel = React.useCallback(
|
||||
(id: string) => {
|
||||
const def = getCardDefinition(id, pluginManagerDefs);
|
||||
return def ? t(def.labelKey) : id;
|
||||
},
|
||||
[t, pluginManagerDefs],
|
||||
);
|
||||
|
||||
const renderCard = React.useCallback(
|
||||
(id: string) => {
|
||||
const def = getCardDefinition(id, pluginManagerDefs);
|
||||
if (!def) return null;
|
||||
return def.render({ metrics, histories, hostId });
|
||||
},
|
||||
[metrics, histories, hostId, pluginManagerDefs],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onVis = () => setIsPageVisible(!document.hidden);
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => document.removeEventListener("visibilitychange", onVis);
|
||||
}, []);
|
||||
|
||||
const isActuallyVisible = isVisible && isPageVisible;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!viewerSessionId || !isActuallyVisible) return;
|
||||
const interval = setInterval(() => {
|
||||
metricsApi.heartbeat(viewerSessionId).catch(() => {});
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [viewerSessionId, isActuallyVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setHistories({ cpu: [], memory: [], disk: [], gpu: {} });
|
||||
}
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const logServerActivity = async () => {
|
||||
if (
|
||||
!currentHostConfig?.id ||
|
||||
activityLoggedRef.current ||
|
||||
activityLoggingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
try {
|
||||
const hostName =
|
||||
currentHostConfig.name ||
|
||||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
await logActivity("server_stats", currentHostConfig.id, hostName);
|
||||
} catch {
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const pushHistory = React.useCallback((data: ServerMetrics) => {
|
||||
setHistories((prev) => {
|
||||
const add = (arr: number[], v: number | null | undefined) =>
|
||||
[...arr, v ?? 0].slice(-HISTORY_LEN);
|
||||
return {
|
||||
cpu: add(prev.cpu, data.cpu?.percent),
|
||||
memory: add(prev.memory, data.memory?.percent),
|
||||
disk: add(prev.disk, data.disk?.percent),
|
||||
gpu: appendGpuHistories(prev.gpu, data.gpu?.gpus, HISTORY_LEN),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTOTPSubmit = async (totpCode: string) => {
|
||||
if (!totpSessionId || !currentHostConfig) return;
|
||||
try {
|
||||
const result = await metricsApi.submitTotp(totpSessionId, totpCode);
|
||||
if (result.success) {
|
||||
setTotpRequired(false);
|
||||
setTotpSessionId(null);
|
||||
setTotpVerified(true);
|
||||
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
|
||||
} else {
|
||||
toast.error(t("hostMetrics.totpFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("hostMetrics.totpFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTOTPCancel = async () => {
|
||||
setTotpRequired(false);
|
||||
if (currentHostConfig?.id) {
|
||||
await metricsApi.stopMetrics(currentHostConfig.id).catch(() => {});
|
||||
}
|
||||
if (currentTab !== null) removeTab(currentTab);
|
||||
};
|
||||
|
||||
// The shell keeps the host list current, including plugin settings.
|
||||
const latestHost = useHost(hostConfig?.id);
|
||||
React.useEffect(() => {
|
||||
if (latestHost) {
|
||||
setCurrentHostConfig({
|
||||
...(latestHost as unknown as HostConfig),
|
||||
id: Number(latestHost.id),
|
||||
});
|
||||
}
|
||||
}, [latestHost]);
|
||||
|
||||
// Core's status check, the same one behind the host list's dot.
|
||||
const hostStatus = useHostStatus(hostId ?? undefined);
|
||||
React.useEffect(() => {
|
||||
if (!statusCheckEnabled) {
|
||||
setServerStatus("offline");
|
||||
return;
|
||||
}
|
||||
if (hostStatus) {
|
||||
setServerStatus(hostStatus.status === "online" ? "online" : "offline");
|
||||
}
|
||||
}, [hostStatus?.status, statusCheckEnabled]);
|
||||
|
||||
const stopMetricsPollingRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
const fetchMetrics = React.useCallback(async (): Promise<void> => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
if (currentHostConfig.authType === "none") {
|
||||
toast.error(t("hostMetrics.noneAuthNotSupported"));
|
||||
if (currentTab !== null) removeTab(currentTab);
|
||||
throw new Error(t("hostMetrics.noneAuthNotSupported"));
|
||||
}
|
||||
|
||||
if (!totpVerified) {
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "stats_connecting",
|
||||
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
|
||||
});
|
||||
const result = await metricsApi.startMetrics(currentHostConfig.id);
|
||||
result?.connectionLogs?.forEach((log) =>
|
||||
addLog(log as ConnectionLogPayload),
|
||||
);
|
||||
if (result.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(result.sessionId || null);
|
||||
setTotpPrompt(result.prompt || "Verification code");
|
||||
// The TOTP dialog gates further progress; retry loop pauses via `enabled`.
|
||||
return;
|
||||
}
|
||||
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
|
||||
}
|
||||
|
||||
// The connect above only opens the SSH session; the first real sample is
|
||||
// collected asynchronously on the backend (status check, then metrics
|
||||
// exec), so it isn't ready the instant the connection succeeds. Give it
|
||||
// a few short retries before treating the initial fetch as a failure.
|
||||
let data = await metricsApi.getMetrics(currentHostConfig.id);
|
||||
for (let i = 0; !data && i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
data = await metricsApi.getMetrics(currentHostConfig.id);
|
||||
}
|
||||
if (!data) {
|
||||
throw new Error(t("hostMetrics.connectionFailed"));
|
||||
}
|
||||
|
||||
setMetrics(data);
|
||||
pushHistory(data);
|
||||
setServerStatus("online");
|
||||
logServerActivity();
|
||||
addLog({
|
||||
type: "success",
|
||||
stage: "connected",
|
||||
message: t("terminal.connected"),
|
||||
});
|
||||
|
||||
let signature = metricsChangeKey(data);
|
||||
const minIntervalMs = statsConfig.metricsInterval * 1000;
|
||||
stopMetricsPollingRef.current?.();
|
||||
stopMetricsPollingRef.current = runAdaptivePolling(
|
||||
async () => {
|
||||
const next = await metricsApi.getMetrics(currentHostConfig.id);
|
||||
if (!next) throw new Error(t("hostMetrics.connectionFailed"));
|
||||
const nextSignature = metricsChangeKey(next);
|
||||
const changed = nextSignature !== signature;
|
||||
signature = nextSignature;
|
||||
setMetrics(next);
|
||||
pushHistory(next);
|
||||
return changed;
|
||||
},
|
||||
{
|
||||
minIntervalMs,
|
||||
maxIntervalMs: Math.min(120_000, minIntervalMs * 6),
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
{ runImmediately: false },
|
||||
);
|
||||
}, [
|
||||
currentHostConfig,
|
||||
totpVerified,
|
||||
statsConfig.metricsInterval,
|
||||
addLog,
|
||||
t,
|
||||
currentTab,
|
||||
removeTab,
|
||||
pushHistory,
|
||||
metricsApi,
|
||||
]);
|
||||
|
||||
const metricsRetry = useConnectionRetry({
|
||||
connect: async () => {
|
||||
try {
|
||||
await fetchMetrics();
|
||||
if (!totpRequired) metricsRetry.markConnected();
|
||||
} catch (error: unknown) {
|
||||
const logError = error as ConnectionLogError;
|
||||
if (logError.connectionLogs) {
|
||||
logError.connectionLogs.forEach((log) => addLog(log));
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("hostMetrics.connectionFailed"),
|
||||
});
|
||||
}
|
||||
metricsRetry.markFailed();
|
||||
}
|
||||
},
|
||||
enabled:
|
||||
isPageVisible &&
|
||||
metricsEnabled &&
|
||||
!totpRequired &&
|
||||
!!currentHostConfig?.id,
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
const metricsRetryRef = React.useRef(metricsRetry);
|
||||
metricsRetryRef.current = metricsRetry;
|
||||
|
||||
// Connects once per host and stays connected while this tab exists, even
|
||||
// when the user switches to another tab and back. Only the browser tab
|
||||
// going into the background (isPageVisible) pauses/resumes it -- switching
|
||||
// between Termix tabs must not tear down and reconnect the session.
|
||||
React.useEffect(() => {
|
||||
if (!metricsEnabled || !currentHostConfig?.id) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const stopMetrics = async () => {
|
||||
stopMetricsPollingRef.current?.();
|
||||
stopMetricsPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
await metricsApi
|
||||
.stopMetrics(currentHostConfig.id, viewerSessionId || undefined)
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const debounce = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
if (isPageVisible) {
|
||||
clearLogs();
|
||||
metricsRetryRef.current.reset();
|
||||
metricsRetryRef.current.retryNow();
|
||||
} else {
|
||||
stopMetrics();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounce);
|
||||
stopMetricsPollingRef.current?.();
|
||||
stopMetricsPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
metricsApi.stopMetrics(currentHostConfig.id).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [currentHostConfig?.id, isPageVisible, metricsEnabled]);
|
||||
|
||||
// After a successful TOTP submit, resume the connect flow immediately.
|
||||
React.useEffect(() => {
|
||||
if (totpVerified && !totpRequired) {
|
||||
metricsRetryRef.current.reset();
|
||||
metricsRetryRef.current.retryNow();
|
||||
}
|
||||
}, [totpVerified, totpRequired]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px",
|
||||
height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)",
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
if (metricsRetry.status !== "connected") {
|
||||
metricsRetry.retryNow();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const data = await metricsApi.getMetrics(currentHostConfig.id);
|
||||
if (data) {
|
||||
setMetrics(data);
|
||||
pushHistory(data);
|
||||
}
|
||||
} catch {
|
||||
setServerStatus("offline");
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showCards =
|
||||
metricsEnabled && metricsRetry.status === "connected" && metrics;
|
||||
const showOffline =
|
||||
metricsEnabled &&
|
||||
metricsRetry.status !== "connecting" &&
|
||||
!metrics &&
|
||||
serverStatus === "offline";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="relative flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-x-hidden overflow-y-auto">
|
||||
{!totpRequired &&
|
||||
(metricsRetry.status === "connected" || showOffline) && (
|
||||
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
|
||||
<Server className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-0">
|
||||
<ComponentSlot
|
||||
slotId="host-metrics.toolbar"
|
||||
props={{
|
||||
hostId: currentHostConfig?.id,
|
||||
host: currentHostConfig,
|
||||
}}
|
||||
/>
|
||||
{editMode && (
|
||||
<>
|
||||
<ColumnCountStepper
|
||||
columns={effectiveLayout.columns}
|
||||
onChange={(columns) =>
|
||||
setLayout({ ...effectiveLayout, columns })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-2 text-xs text-muted-foreground"
|
||||
onClick={() =>
|
||||
setLayout(
|
||||
defaultLayoutFromWidgets(
|
||||
statsConfig.enabledWidgets ?? [],
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("hostMetrics.reset")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant={editMode ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="ml-1"
|
||||
title={t("hostMetrics.customize")}
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
>
|
||||
<LayoutDashboard className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="ml-1 gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("hostMetrics.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMode && (
|
||||
<div className="mx-3 mt-3 flex shrink-0 items-center gap-2 border border-dashed border-accent-brand/40 bg-accent-brand/5 px-4 py-2">
|
||||
<LayoutDashboard className="size-3.5 shrink-0 text-accent-brand" />
|
||||
<span className="text-xs font-semibold text-accent-brand">
|
||||
{t("hostMetrics.editModeInstructions")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCards && (
|
||||
<div className="px-3 pt-3 pb-3">
|
||||
<CardGridCanvas
|
||||
slots={visibleSlots}
|
||||
columns={effectiveLayout.columns}
|
||||
editMode={editMode}
|
||||
renderCard={renderCard}
|
||||
cardCatalog={cardCatalog}
|
||||
cardLabel={cardLabel}
|
||||
onChange={(slots, columns) =>
|
||||
setLayout({
|
||||
slots: slots as HostMetricsLayout["slots"],
|
||||
columns,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{metricsEnabled && !totpRequired && (
|
||||
<ConnectionScreen
|
||||
status={showOffline ? "connected" : metricsRetry.status}
|
||||
message={t("hostMetrics.connecting")}
|
||||
attempt={metricsRetry.attempt}
|
||||
maxAttempts={metricsRetry.maxAttempts}
|
||||
nextRetryInMs={metricsRetry.nextRetryInMs}
|
||||
onManualRetry={metricsRetry.retryNow}
|
||||
logPosition={
|
||||
metricsRetry.status === "error" ||
|
||||
metricsRetry.status === "disconnected"
|
||||
? "top"
|
||||
: "bottom"
|
||||
}
|
||||
emptyState={
|
||||
showOffline ? (
|
||||
<div className="text-center opacity-40">
|
||||
<Server className="mx-auto mb-4 size-16" />
|
||||
<p className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("hostMetrics.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{t("hostMetrics.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTOTPSubmit}
|
||||
onCancel={handleTOTPCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostMetricsTab(props: HostMetricsProps): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<HostMetricsInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
MetricsChartConfig,
|
||||
MetricsChartMetric,
|
||||
MetricsChartRange,
|
||||
WidgetEditFormProps,
|
||||
} from "../shared/homepage.js";
|
||||
|
||||
const METRICS: { id: MetricsChartMetric; label: string }[] = [
|
||||
{ id: "cpu", label: "CPU" },
|
||||
{ id: "memory", label: "Memory" },
|
||||
{ id: "disk", label: "Disk" },
|
||||
{ id: "net_rx", label: "RX MB/s" },
|
||||
{ id: "net_tx", label: "TX MB/s" },
|
||||
];
|
||||
|
||||
const RANGES: { id: MetricsChartRange; label: string }[] = [
|
||||
{ id: "15m", label: "15m" },
|
||||
{ id: "1h", label: "1h" },
|
||||
{ id: "6h", label: "6h" },
|
||||
{ id: "24h", label: "24h" },
|
||||
];
|
||||
|
||||
export function MetricsChartEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<MetricsChartConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts: records } = useHosts();
|
||||
const hosts = records.map((host) => ({
|
||||
id: Number(host.id),
|
||||
name: host.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<Select2
|
||||
value={config.hostId}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, hostId: Number(e.target.value) })
|
||||
}
|
||||
className="h-8 text-xs bg-background border border-border px-2 text-foreground"
|
||||
>
|
||||
<option value={0}>{t("homepage.selectHost")}</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.chartMetric")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{METRICS.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, metric: m.id })}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.metric === m.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.metricRange")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, range: r.id })}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.range === r.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showCurrentValue}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showCurrentValue: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showCurrentValue")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { WidgetTitle, runVisibleInterval } from "@termix/plugin-sdk/ui";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { useHost, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { MetricsChartEditForm } from "./MetricsChartEditForm";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type MetricsChartConfig,
|
||||
type MetricsChartMetric,
|
||||
type WidgetComponentProps,
|
||||
type WidgetDefinition,
|
||||
} from "../shared/homepage.js";
|
||||
import { type MetricsHistoryRow, useHostMetricsApi } from "./host-metrics-api";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function getMetricValue(
|
||||
row: MetricsHistoryRow,
|
||||
metric: MetricsChartMetric,
|
||||
): number | null {
|
||||
switch (metric) {
|
||||
case "cpu":
|
||||
return row.cpu_percent;
|
||||
case "memory":
|
||||
return row.mem_percent;
|
||||
case "disk":
|
||||
return row.disk_percent;
|
||||
case "net_rx":
|
||||
return row.net_rx_bytes != null ? row.net_rx_bytes / 1_000_000 : null;
|
||||
case "net_tx":
|
||||
return row.net_tx_bytes != null ? row.net_tx_bytes / 1_000_000 : null;
|
||||
}
|
||||
}
|
||||
|
||||
function getMetricLabel(
|
||||
metric: MetricsChartMetric,
|
||||
t: (k: string) => string,
|
||||
): string {
|
||||
switch (metric) {
|
||||
case "cpu":
|
||||
return t("homepage.metricCpu");
|
||||
case "memory":
|
||||
return t("homepage.metricMemory");
|
||||
case "disk":
|
||||
return t("homepage.metricDisk");
|
||||
case "net_rx":
|
||||
return "RX MB/s";
|
||||
case "net_tx":
|
||||
return "TX MB/s";
|
||||
}
|
||||
}
|
||||
|
||||
function getMetricUnit(metric: MetricsChartMetric): string {
|
||||
if (metric === "net_rx" || metric === "net_tx") return " MB/s";
|
||||
return "%";
|
||||
}
|
||||
|
||||
interface SparklineProps {
|
||||
values: number[];
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function Sparkline({ values, width, height, color }: SparklineProps) {
|
||||
if (values.length < 2) return null;
|
||||
const max = Math.max(...values, 1);
|
||||
const min = 0;
|
||||
const range = max - min;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * width;
|
||||
const y = height - ((v - min) / range) * height;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
});
|
||||
const polyPts = [`0,${height}`, ...pts, `${width},${height}`].join(" ");
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`mg-${color.replace("#", "")}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points={polyPts} fill={`url(#mg-${color.replace("#", "")})`} />
|
||||
<polyline
|
||||
points={pts.join(" ")}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsChartWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<MetricsChartConfig>) {
|
||||
const { getMetricsHistory } = useHostMetricsApi();
|
||||
const { t } = useTranslation();
|
||||
const { hostId, metric, range, showCurrentValue } = config;
|
||||
const hostName = useHost(hostId || undefined)?.name ?? null;
|
||||
const [values, setValues] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dims, setDims] = useState({ w: 0, h: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const obs = new ResizeObserver((entries) => {
|
||||
const e = entries[0]?.contentRect;
|
||||
if (e) setDims({ w: Math.floor(e.width), h: Math.floor(e.height) });
|
||||
});
|
||||
obs.observe(containerRef.current);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
const fetchData = () => {
|
||||
if (!hostId) return;
|
||||
getMetricsHistory(hostId, { range })
|
||||
.then((res) => {
|
||||
const vals = res.rows
|
||||
.map((r) => getMetricValue(r, metric))
|
||||
.filter((v): v is number => v != null);
|
||||
setValues(vals);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 60_000);
|
||||
}, [hostId, metric, range]);
|
||||
|
||||
const accent = getAccentColor();
|
||||
const current = values.length > 0 ? values[values.length - 1] : null;
|
||||
const unit = getMetricUnit(metric);
|
||||
const label = getMetricLabel(metric, t);
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<TrendingUp size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-1 shrink-0">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-semibold text-foreground truncate">
|
||||
{hostName ?? `Host #${hostId}`}
|
||||
</span>
|
||||
<span className="flex gap-2 text-[9px] text-muted-foreground">
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="shrink-0">{range}</span>
|
||||
</span>
|
||||
</div>
|
||||
{showCurrentValue && current != null && (
|
||||
<span
|
||||
className="text-sm font-bold tabular-nums shrink-0"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{metric === "net_rx" || metric === "net_tx"
|
||||
? current.toFixed(2)
|
||||
: Math.round(current)}
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="flex-1 min-h-0 relative">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
) : values.length < 2 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.noData")}
|
||||
</div>
|
||||
) : (
|
||||
dims.w > 0 &&
|
||||
dims.h > 0 && (
|
||||
<Sparkline
|
||||
values={values}
|
||||
width={dims.w}
|
||||
height={dims.h}
|
||||
color={accent}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Registered by the plugin while it runs. */
|
||||
export const metricsChartWidget: WidgetDefinition<MetricsChartConfig> = {
|
||||
id: "metrics_chart",
|
||||
name: "Metrics Chart",
|
||||
description: "Historical CPU, memory, disk, or network chart for a host",
|
||||
category: "monitoring",
|
||||
icon: <TrendingUp size={14} />,
|
||||
defaultConfig: {
|
||||
hostId: 0,
|
||||
metric: "cpu",
|
||||
range: "1h",
|
||||
showCurrentValue: true,
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 4 },
|
||||
components: { view: MetricsChartWidget, editForm: MetricsChartEditForm },
|
||||
};
|
||||
|
||||
export { MetricsChartWidget };
|
||||
@@ -0,0 +1,269 @@
|
||||
import { usePermission, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { MetricsSummaryStore } from "./summary-store";
|
||||
import { useMetricsSummary } from "./summary-store";
|
||||
|
||||
type MetricKey =
|
||||
"cpu" | "memory" | "disk" | "uptime" | "system" | "network" | "processes";
|
||||
|
||||
function RowBar({ label, value }: { label: string; value: number }) {
|
||||
const tone =
|
||||
value >= 90
|
||||
? ["bg-red-500", "text-red-400"]
|
||||
: value >= 70
|
||||
? ["bg-yellow-500", "text-yellow-400"]
|
||||
: ["bg-accent-brand", "text-accent-brand"];
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 w-16">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||
<span className={`text-[10px] font-bold ${tone[1]}`}>
|
||||
{value.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-0.5 bg-muted w-full">
|
||||
<div className={`h-full ${tone[0]}`} style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** CPU, RAM and disk bars in a dashboard host row ("dashboard.hostRow"). */
|
||||
export function createDashboardHostMetrics(store: MetricsSummaryStore) {
|
||||
return function DashboardHostMetrics(props: Record<string, unknown>) {
|
||||
const { t } = useTranslation();
|
||||
const allowed = usePermission("use");
|
||||
const hostId = Number(props.hostId) || null;
|
||||
const online = props.online === true;
|
||||
const metrics = useMetricsSummary(store, allowed && online ? hostId : null);
|
||||
|
||||
const cpu = metrics?.cpu?.percent ?? null;
|
||||
const ram = metrics?.memory?.percent ?? null;
|
||||
const disk = metrics?.disk?.percent ?? null;
|
||||
if (!allowed) return null;
|
||||
if (!online || (cpu === null && ram === null && disk === null)) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{[0, 1, 2].map((slot) => (
|
||||
<span
|
||||
key={slot}
|
||||
className="text-[10px] text-muted-foreground w-16 text-center"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{cpu !== null && <RowBar label={t("dashboard.cpu")} value={cpu} />}
|
||||
{ram !== null && <RowBar label={t("dashboard.ram")} value={ram} />}
|
||||
{disk !== null && (
|
||||
<RowBar label={t("dashboardTab.disk")} value={disk} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function accentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetBar({
|
||||
label,
|
||||
value,
|
||||
sublabel,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
sublabel?: string;
|
||||
}) {
|
||||
const pct = value ?? 0;
|
||||
const color = pct > 90 ? "#ef4444" : pct > 70 ? "#f97316" : accentColor();
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span className="text-right">
|
||||
{value != null ? `${Math.round(pct)}%` : "N/A"}
|
||||
{sublabel && <span className="ml-1 opacity-60">{sublabel}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{ width: `${pct}%`, background: color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex justify-between text-[10px] gap-2">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className="text-foreground truncate text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB`;
|
||||
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`;
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
/** The numbers under core's homepage host status widget ("homepage.hostMetrics"). */
|
||||
export function createHomepageHostMetrics(store: MetricsSummaryStore) {
|
||||
return function HomepageHostMetrics(props: Record<string, unknown>) {
|
||||
const { t } = useTranslation();
|
||||
const allowed = usePermission("use");
|
||||
const hostId = Number(props.hostId) || null;
|
||||
const shown = (
|
||||
Array.isArray(props.shownMetrics) ? props.shownMetrics : []
|
||||
) as MetricKey[];
|
||||
const online = props.online === true;
|
||||
const metrics = useMetricsSummary(store, allowed ? hostId : null);
|
||||
if (!allowed || shown.length === 0) return null;
|
||||
|
||||
const interfaces = metrics?.network?.interfaces ?? [];
|
||||
const sum = (pick: (iface: (typeof interfaces)[number]) => unknown) =>
|
||||
interfaces.reduce((total, iface) => {
|
||||
const raw = pick(iface);
|
||||
return raw ? total + parseFloat(String(raw)) : total;
|
||||
}, 0);
|
||||
const totalRx = sum((iface) => iface.rxBytes);
|
||||
const totalTx = sum((iface) => iface.txBytes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{shown.includes("cpu") && (
|
||||
<WidgetBar
|
||||
label={t("homepage.metricCpu")}
|
||||
value={metrics?.cpu?.percent ?? null}
|
||||
sublabel={metrics?.cpu?.cores ? `${metrics.cpu.cores}c` : undefined}
|
||||
/>
|
||||
)}
|
||||
{shown.includes("memory") && (
|
||||
<WidgetBar
|
||||
label={t("homepage.metricMemory")}
|
||||
value={metrics?.memory?.percent ?? null}
|
||||
sublabel={
|
||||
metrics?.memory?.usedGiB != null &&
|
||||
metrics?.memory?.totalGiB != null
|
||||
? `${metrics.memory.usedGiB.toFixed(1)}/${metrics.memory.totalGiB.toFixed(1)} GiB`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{shown.includes("disk") && (
|
||||
<WidgetBar
|
||||
label={t("homepage.metricDisk")}
|
||||
value={metrics?.disk?.percent ?? null}
|
||||
sublabel={
|
||||
metrics?.disk?.usedHuman && metrics?.disk?.totalHuman
|
||||
? `${metrics.disk.usedHuman}/${metrics.disk.totalHuman}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{shown.includes("uptime") && (
|
||||
<InfoRow
|
||||
label={t("homepage.metricUptime")}
|
||||
value={metrics?.uptime?.formatted ?? null}
|
||||
/>
|
||||
)}
|
||||
{shown.includes("system") && metrics?.system && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<InfoRow
|
||||
label={t("homepage.metricOs")}
|
||||
value={metrics.system.os ?? null}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("homepage.metricKernel")}
|
||||
value={metrics.system.kernel ?? null}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("homepage.metricHostname")}
|
||||
value={metrics.system.hostname ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{shown.includes("network") && interfaces.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<div className="text-[10px] font-medium text-muted-foreground mb-0.5">
|
||||
{t("homepage.metricNetwork")}
|
||||
</div>
|
||||
{interfaces.slice(0, 3).map((iface) => (
|
||||
<div
|
||||
key={iface.name}
|
||||
className="flex justify-between text-[10px] gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 truncate max-w-[60px]">
|
||||
{iface.name}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{iface.ip || iface.state}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(totalRx > 0 || totalTx > 0) && (
|
||||
<div className="flex justify-between text-[10px] gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground">RX/TX</span>
|
||||
<span className="text-foreground">
|
||||
{formatBytes(totalRx)} / {formatBytes(totalTx)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shown.includes("processes") && metrics?.processes && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">
|
||||
{t("homepage.metricProcesses")}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{metrics.processes.total ?? "?"}{" "}
|
||||
{t("homepage.metricProcessesTotal")}
|
||||
{metrics.processes.running != null
|
||||
? `, ${metrics.processes.running} ${t("homepage.metricProcessesRunning")}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
{metrics.processes.top?.slice(0, 3).map((proc) => (
|
||||
<div
|
||||
key={proc.pid}
|
||||
className="flex justify-between text-[10px] gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground truncate max-w-[80px]">
|
||||
{proc.command}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0">{proc.cpu}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!metrics && online && (
|
||||
<span className="text-[10px] text-muted-foreground/60 italic">
|
||||
{t("homepage.metricsNotAvailable")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
usePluginApiFor,
|
||||
useTranslation,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
cn,
|
||||
getPollingEnvironmentMultiplier,
|
||||
resolveConnectionOrigin,
|
||||
resolveRemoteHostId,
|
||||
runAdaptivePolling,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
|
||||
/** What the ssh-terminal plugin hands a "terminal.toolbarStatus" component. */
|
||||
interface TerminalToolbarStatusProps {
|
||||
host: {
|
||||
id: number | string;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
syncId?: string | null;
|
||||
};
|
||||
isConnected: boolean;
|
||||
/** Visible on a desktop viewport and connected: poll only while true. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface Sample {
|
||||
cpu: number | null;
|
||||
memory: number | null;
|
||||
disk: number | null;
|
||||
}
|
||||
|
||||
function StatBar({
|
||||
label,
|
||||
percent,
|
||||
}: {
|
||||
label: string;
|
||||
percent: number | null;
|
||||
}) {
|
||||
const value = percent == null ? null : Math.max(0, Math.min(100, percent));
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-1.5">
|
||||
<span className="w-12 shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="h-1.5 w-12 shrink-0 overflow-hidden rounded-full bg-muted">
|
||||
{value != null && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
value >= 90
|
||||
? "bg-red-500"
|
||||
: value >= 70
|
||||
? "bg-yellow-500"
|
||||
: "bg-accent-brand",
|
||||
)}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="w-7 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground">
|
||||
{value == null ? "--" : `${Math.round(value)}%`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** CPU, memory and disk bars in the terminal toolbar's expanded view. */
|
||||
export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
||||
const { host, active } = props as unknown as TerminalToolbarStatusProps;
|
||||
const { t } = useTranslation();
|
||||
const localApi = usePluginApi();
|
||||
const remoteApi = usePluginApiFor("remote");
|
||||
const [metrics, setMetrics] = useState<Sample | null>(null);
|
||||
const [available, setAvailable] = useState(true);
|
||||
const hostId = host?.id ? Number(host.id) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !hostId) {
|
||||
setMetrics(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let stopPolling: (() => void) | undefined;
|
||||
let viewerSessionId: string | undefined;
|
||||
let previous: Sample | null = null;
|
||||
// A host on the desktop app's remote server has its own id there.
|
||||
let api = localApi;
|
||||
let targetId: number = hostId;
|
||||
setAvailable(true);
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await api.get<{
|
||||
cpu?: { percent?: number };
|
||||
memory?: { percent?: number };
|
||||
disk?: { percent?: number };
|
||||
} | null>(`/metrics/${targetId}`, {
|
||||
validateStatus: (status: number) => status === 200 || status === 404,
|
||||
});
|
||||
const data = response.data;
|
||||
if (cancelled || !data) return;
|
||||
const next: Sample = {
|
||||
cpu: data.cpu?.percent ?? null,
|
||||
memory: data.memory?.percent ?? null,
|
||||
disk: data.disk?.percent ?? null,
|
||||
};
|
||||
const changed =
|
||||
!previous ||
|
||||
(["cpu", "memory", "disk"] as const).some((key) => {
|
||||
const before = previous?.[key];
|
||||
const now = next[key];
|
||||
return before == null || now == null || Math.abs(now - before) >= 2;
|
||||
});
|
||||
previous = next;
|
||||
setMetrics(next);
|
||||
return changed;
|
||||
} catch {
|
||||
// Keep the previous sample after a transient polling error.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
if ((await resolveConnectionOrigin(host)) === "remote") {
|
||||
const remoteId = await resolveRemoteHostId(host.syncId);
|
||||
if (cancelled) return;
|
||||
if (remoteId === null) {
|
||||
setAvailable(false);
|
||||
return;
|
||||
}
|
||||
api = remoteApi;
|
||||
targetId = remoteId;
|
||||
}
|
||||
const { data } = await api.post<{
|
||||
requires_totp?: boolean;
|
||||
viewerSessionId?: string;
|
||||
}>(`/metrics/start/${targetId}`);
|
||||
if (cancelled) {
|
||||
if (data.viewerSessionId) {
|
||||
void api
|
||||
.post(`/metrics/stop/${targetId}`, {
|
||||
viewerSessionId: data.viewerSessionId,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.requires_totp) {
|
||||
setAvailable(false);
|
||||
return;
|
||||
}
|
||||
viewerSessionId = data.viewerSessionId;
|
||||
stopPolling = runAdaptivePolling(
|
||||
poll,
|
||||
{
|
||||
minIntervalMs: 5_000,
|
||||
maxIntervalMs: 20_000,
|
||||
stablePollsPerStep: 2,
|
||||
maxRequestDutyCycle: 0.2,
|
||||
},
|
||||
{ intervalMultiplier: getPollingEnvironmentMultiplier },
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setAvailable(false);
|
||||
}
|
||||
};
|
||||
void start();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopPolling?.();
|
||||
void api
|
||||
.post(`/metrics/stop/${targetId}`, { viewerSessionId })
|
||||
.catch(() => {});
|
||||
};
|
||||
}, [active, hostId, localApi, remoteApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!available) {
|
||||
return (
|
||||
<span role="status" className="px-2 py-1 text-xs text-muted-foreground">
|
||||
{t("terminalStatus.unavailable")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatBar label={t("hostMetrics.cpu")} percent={metrics?.cpu ?? null} />
|
||||
<StatBar
|
||||
label={t("hostMetrics.memory")}
|
||||
percent={metrics?.memory ?? null}
|
||||
/>
|
||||
<StatBar label={t("hostMetrics.disk")} percent={metrics?.disk ?? null} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cn } from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export type HistoryTab = "live" | "1h" | "6h" | "24h" | "7d";
|
||||
|
||||
const TABS: HistoryTab[] = ["live", "1h", "6h", "24h", "7d"];
|
||||
|
||||
export function CardTimeTabs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: HistoryTab;
|
||||
onChange: (tab: HistoryTab) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => onChange(tab)}
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest transition-colors border",
|
||||
value === tab
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab === "live" ? t("hostMetrics.tabLive") : tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
RadialGauge,
|
||||
Sparkline,
|
||||
MiniStat,
|
||||
MetricCard,
|
||||
LineChart,
|
||||
type LineChartSeries,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { type ServerMetrics } from "../../shared/metrics.js";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Cpu } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
|
||||
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
|
||||
|
||||
export function CpuCard({
|
||||
metrics,
|
||||
history,
|
||||
hostId,
|
||||
}: {
|
||||
metrics: ServerMetrics | null;
|
||||
history: number[];
|
||||
hostId: number | null;
|
||||
}) {
|
||||
const { getMetricsHistory } = useHostMetricsApi();
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
|
||||
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "live" || hostId == null) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setRows(null);
|
||||
getMetricsHistory(hostId, { range: activeTab })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setRows(res.rows);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, hostId]);
|
||||
|
||||
const percent = metrics?.cpu?.percent ?? null;
|
||||
const cores = metrics?.cpu?.cores ?? null;
|
||||
const load = metrics?.cpu?.load ?? null;
|
||||
|
||||
const series: LineChartSeries[] = [
|
||||
{
|
||||
key: "cpu",
|
||||
label: t("hostMetrics.cpuUsage"),
|
||||
color: "var(--accent-brand)",
|
||||
data: rows?.map((r) => r.cpu_percent) ?? [],
|
||||
},
|
||||
];
|
||||
const timestamps = rows?.map((r) => r.ts) ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.cpuUsage")}
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
action={
|
||||
hostId != null ? (
|
||||
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTab === "live" && (
|
||||
<div className="flex items-center gap-4">
|
||||
<RadialGauge
|
||||
value={percent}
|
||||
caption={
|
||||
cores !== null
|
||||
? t("hostMetrics.cpuCores", { count: cores })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{load && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<MiniStat caption="1m" value={load[0].toFixed(2)} />
|
||||
<MiniStat caption="5m" value={load[1].toFixed(2)} />
|
||||
<MiniStat caption="15m" value={load[2].toFixed(2)} />
|
||||
</div>
|
||||
)}
|
||||
<Sparkline
|
||||
data={[...history, percent ?? 0]}
|
||||
domain={[0, 100]}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab !== "live" && (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length === 0 && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("metricsHistory.noData")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length > 0 && (
|
||||
<LineChart
|
||||
series={series}
|
||||
timestamps={timestamps}
|
||||
domain={[0, 100]}
|
||||
yFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
height={160}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
RadialGauge,
|
||||
Sparkline,
|
||||
MiniStat,
|
||||
MetricCard,
|
||||
LineChart,
|
||||
type LineChartSeries,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { type ServerMetrics } from "../../shared/metrics.js";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { FilesystemPicker } from "./FilesystemPicker";
|
||||
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
|
||||
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
|
||||
|
||||
export function DiskCard({
|
||||
metrics,
|
||||
history,
|
||||
hostId,
|
||||
}: {
|
||||
metrics: ServerMetrics | null;
|
||||
history: number[];
|
||||
hostId: number | null;
|
||||
}) {
|
||||
const { getMetricsHistory } = useHostMetricsApi();
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
|
||||
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "live" || hostId == null) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setRows(null);
|
||||
getMetricsHistory(hostId, { range: activeTab })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setRows(res.rows);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, hostId]);
|
||||
|
||||
const filesystems = useMemo(
|
||||
() => metrics?.disk?.filesystems ?? [],
|
||||
[metrics?.disk?.filesystems],
|
||||
);
|
||||
const defaultMount = metrics?.disk?.mount ?? null;
|
||||
const [selectedMount, setSelectedMount] = useState<string | null>(null);
|
||||
|
||||
// Drop a stale selection if the mount disappears between polls, so the card
|
||||
// falls back to the primary filesystem instead of rendering nothing.
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedMount !== null &&
|
||||
filesystems.length > 0 &&
|
||||
!filesystems.some((fs) => fs.mount === selectedMount)
|
||||
) {
|
||||
setSelectedMount(null);
|
||||
}
|
||||
}, [filesystems, selectedMount]);
|
||||
|
||||
const activeMount = selectedMount ?? defaultMount;
|
||||
const activeFs = filesystems.find((fs) => fs.mount === activeMount) ?? null;
|
||||
const isCustomMount = selectedMount !== null && activeFs !== null;
|
||||
|
||||
const percent = isCustomMount
|
||||
? activeFs.percent
|
||||
: (metrics?.disk?.percent ?? null);
|
||||
const usedHuman = isCustomMount
|
||||
? activeFs.usedHuman
|
||||
: (metrics?.disk?.usedHuman ?? null);
|
||||
const totalHuman = isCustomMount
|
||||
? activeFs.totalHuman
|
||||
: (metrics?.disk?.totalHuman ?? null);
|
||||
const availableHuman = isCustomMount
|
||||
? activeFs.availableHuman
|
||||
: (metrics?.disk?.availableHuman ?? null);
|
||||
|
||||
const series: LineChartSeries[] = [
|
||||
{
|
||||
key: "disk",
|
||||
label: t("hostMetrics.diskUsage"),
|
||||
color: "var(--chart-4)",
|
||||
data: rows?.map((r) => r.disk_percent) ?? [],
|
||||
},
|
||||
];
|
||||
const timestamps = rows?.map((r) => r.ts) ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.diskUsage")}
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
{activeTab === "live" && (
|
||||
<FilesystemPicker
|
||||
filesystems={filesystems}
|
||||
value={activeMount}
|
||||
onChange={setSelectedMount}
|
||||
/>
|
||||
)}
|
||||
{hostId != null && (
|
||||
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTab === "live" && (
|
||||
<div className="flex items-center gap-4">
|
||||
<RadialGauge value={percent} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MiniStat
|
||||
caption={activeMount ?? t("hostMetrics.disk")}
|
||||
value={
|
||||
usedHuman && totalHuman
|
||||
? `${usedHuman}/${totalHuman}`
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.available")}
|
||||
value={availableHuman ?? "N/A"}
|
||||
/>
|
||||
</div>
|
||||
<Sparkline
|
||||
data={[...history, percent ?? 0]}
|
||||
domain={[0, 100]}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab !== "live" && (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length === 0 && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("metricsHistory.noData")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length > 0 && (
|
||||
<LineChart
|
||||
series={series}
|
||||
timestamps={timestamps}
|
||||
domain={[0, 100]}
|
||||
yFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
height={160}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { type DiskFilesystem } from "../../shared/metrics.js";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export function FilesystemPicker({
|
||||
filesystems,
|
||||
value,
|
||||
onChange,
|
||||
align = "end",
|
||||
className,
|
||||
}: {
|
||||
filesystems: DiskFilesystem[];
|
||||
value: string | null;
|
||||
onChange: (mount: string) => void;
|
||||
align?: "start" | "end";
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (filesystems.length <= 1) return null;
|
||||
|
||||
const selected = filesystems.find((fs) => fs.mount === value);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"flex max-w-[160px] items-center gap-1 border border-border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground transition-colors hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
title={t("hostMetrics.selectFilesystem")}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selected?.label || selected?.mount || value || "-"}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
className="w-auto min-w-[220px] max-w-[min(360px,calc(100vw-2rem))]"
|
||||
>
|
||||
{filesystems.map((fs) => (
|
||||
<DropdownMenuItem
|
||||
key={fs.mount}
|
||||
onSelect={() => onChange(fs.mount)}
|
||||
className="gap-3"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"size-3 shrink-0",
|
||||
fs.mount === value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{fs.label || fs.mount}
|
||||
</span>
|
||||
{fs.label && (
|
||||
<span className="max-w-24 truncate font-mono text-[10px] text-muted-foreground">
|
||||
{fs.mount}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex shrink-0 gap-2 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground">
|
||||
<span>
|
||||
{fs.usedHuman && fs.totalHuman
|
||||
? `${fs.usedHuman}/${fs.totalHuman}`
|
||||
: "N/A"}
|
||||
</span>
|
||||
{fs.percent != null && <span>{fs.percent}%</span>}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MetricCard } from "@termix/plugin-sdk/ui";
|
||||
import { type ServerMetrics } from "../../shared/metrics.js";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export function FirewallCard({ metrics }: { metrics: ServerMetrics | null }) {
|
||||
const { t } = useTranslation();
|
||||
const fw = metrics?.firewall;
|
||||
|
||||
const totalRules =
|
||||
fw?.chains?.reduce((sum, c) => sum + (c.rules?.length ?? 0), 0) ?? 0;
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.firewall.title")}
|
||||
icon={<ShieldCheck className="size-3.5" />}
|
||||
>
|
||||
{!fw || fw.type === "none" ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostMetrics.firewall.noData")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-semibold uppercase tracking-wide ${
|
||||
fw.status === "active"
|
||||
? "text-accent-brand"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{fw.status === "inactive"
|
||||
? t("hostMetrics.firewall.inactive")
|
||||
: fw.status}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{fw.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<span className="font-bold text-foreground">
|
||||
{fw.chains?.length ?? 0}
|
||||
</span>{" "}
|
||||
{t("hostMetrics.firewall.chains")}
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-bold text-foreground">{totalRules}</span>{" "}
|
||||
{t("hostMetrics.firewall.rules")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
MiniStat,
|
||||
RadialGauge,
|
||||
Sparkline,
|
||||
StatRow,
|
||||
MetricCard,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { type ServerMetrics } from "../../shared/metrics.js";
|
||||
import { type GpuDevice, type GpuProcess } from "../../shared/stats-widgets.js";
|
||||
import { useId } from "react";
|
||||
import { Gpu } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { GpuHistories, GpuHistory } from "./gpu-history";
|
||||
|
||||
const NA = "N/A";
|
||||
const EMPTY_HISTORY: GpuHistory = { utilization: [], memory: [] };
|
||||
|
||||
function formatGiB(mib: number | null): string {
|
||||
return mib === null ? NA : (mib / 1024).toFixed(1);
|
||||
}
|
||||
|
||||
function formatVram(gpu: GpuDevice): string {
|
||||
return gpu.memoryUsedMiB === null || gpu.memoryTotalMiB === null
|
||||
? NA
|
||||
: `${formatGiB(gpu.memoryUsedMiB)}/${formatGiB(gpu.memoryTotalMiB)}G`;
|
||||
}
|
||||
|
||||
function formatWhole(value: number | null, unit: string): string {
|
||||
return value === null ? NA : `${Math.round(value)}${unit}`;
|
||||
}
|
||||
|
||||
function formatPower(gpu: GpuDevice): string {
|
||||
if (gpu.powerDrawWatts === null && gpu.powerLimitWatts === null) return NA;
|
||||
return `${formatWhole(gpu.powerDrawWatts, "")}/${formatWhole(gpu.powerLimitWatts, "")}W`;
|
||||
}
|
||||
|
||||
function processVram(process: GpuProcess): string {
|
||||
const vram =
|
||||
process.memoryUsedMiB === null
|
||||
? NA
|
||||
: `${formatGiB(process.memoryUsedMiB)}G`;
|
||||
return process.gpuIndex === null ? vram : `${vram} (GPU ${process.gpuIndex})`;
|
||||
}
|
||||
|
||||
function executableName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() || path;
|
||||
}
|
||||
|
||||
/** A captioned 0-100% sparkline, so the two trends under each GPU are told apart. */
|
||||
function TrendSparkline({
|
||||
caption,
|
||||
data,
|
||||
colorClassName,
|
||||
}: {
|
||||
caption: string;
|
||||
data: number[];
|
||||
colorClassName?: string;
|
||||
}) {
|
||||
const captionId = useId();
|
||||
|
||||
return (
|
||||
<figure aria-labelledby={captionId} className="flex min-w-0 flex-col gap-1">
|
||||
<figcaption
|
||||
id={captionId}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>
|
||||
{caption}
|
||||
</figcaption>
|
||||
<Sparkline
|
||||
data={data}
|
||||
domain={[0, 100]}
|
||||
height={36}
|
||||
colorClassName={colorClassName}
|
||||
/>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function GpuDeviceRow({
|
||||
gpu,
|
||||
history,
|
||||
}: {
|
||||
gpu: GpuDevice;
|
||||
history: GpuHistory;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex min-w-0 items-baseline gap-1.5 text-xs">
|
||||
<span className="font-mono text-muted-foreground">#{gpu.index}</span>
|
||||
<span className="truncate font-semibold">{gpu.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<RadialGauge
|
||||
value={gpu.utilizationPercent}
|
||||
size={96}
|
||||
caption={t("hostMetrics.gpu.utilization")}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.gpu.memory")}
|
||||
value={formatVram(gpu)}
|
||||
/>
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.gpu.temperature")}
|
||||
value={formatWhole(gpu.temperatureCelsius, "°C")}
|
||||
/>
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.gpu.power")}
|
||||
value={formatPower(gpu)}
|
||||
/>
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.gpu.fan")}
|
||||
value={formatWhole(gpu.fanPercent, "%")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TrendSparkline
|
||||
caption={t("hostMetrics.gpu.utilization")}
|
||||
data={[...history.utilization, gpu.utilizationPercent ?? 0]}
|
||||
/>
|
||||
<TrendSparkline
|
||||
caption={t("hostMetrics.gpu.memory")}
|
||||
data={[...history.memory, gpu.memoryPercent ?? 0]}
|
||||
colorClassName="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GpuCard({
|
||||
metrics,
|
||||
gpuHistories,
|
||||
}: {
|
||||
metrics: ServerMetrics | null;
|
||||
gpuHistories: GpuHistories;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const gpus = metrics?.gpu?.gpus ?? [];
|
||||
const processes = metrics?.gpu?.processes ?? [];
|
||||
const driverVersion = gpus[0]?.driverVersion;
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.gpu.title")}
|
||||
icon={<Gpu className="size-3.5" />}
|
||||
action={
|
||||
driverVersion ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{t("hostMetrics.gpu.driver", { version: driverVersion })}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
scroll={gpus.length > 2 || processes.length > 6}
|
||||
scrollMax={520}
|
||||
>
|
||||
{gpus.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostMetrics.gpu.noDevice")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{gpus.map((gpu) => (
|
||||
<GpuDeviceRow
|
||||
key={gpu.index}
|
||||
gpu={gpu}
|
||||
history={gpuHistories[gpu.index] ?? EMPTY_HISTORY}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="mb-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hostMetrics.gpu.processes")}
|
||||
</span>
|
||||
{processes.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostMetrics.gpu.noProcesses")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{processes.map((process) => (
|
||||
<StatRow
|
||||
key={`${process.gpuIndex}-${process.pid}`}
|
||||
label={
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span title={process.name} className="text-foreground">
|
||||
{executableName(process.name)}
|
||||
</span>
|
||||
<span className="font-mono text-[10px]">
|
||||
{process.pid}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
value={processVram(process)}
|
||||
mono
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MetricCard } from "@termix/plugin-sdk/ui";
|
||||
import { type ServerMetrics } from "../../shared/metrics.js";
|
||||
import { UserCheck, UserX } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export function LoginStatsCard({ metrics }: { metrics: ServerMetrics | null }) {
|
||||
const { t } = useTranslation();
|
||||
const loginStats = metrics?.login_stats;
|
||||
const recentLogins = loginStats?.recentLogins ?? [];
|
||||
const failedLogins = loginStats?.failedLogins ?? [];
|
||||
|
||||
const allLogins = [
|
||||
...recentLogins.map((l) => ({ ...l, status: "success" as const })),
|
||||
...failedLogins.map((l) => ({ ...l, status: "failed" as const })),
|
||||
]
|
||||
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
|
||||
.slice(0, 10);
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.loginStats")}
|
||||
icon={<UserCheck className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{allLogins.length === 0 ? (
|
||||
<span className="py-2 text-xs italic text-muted-foreground">
|
||||
{t("hostMetrics.noRecentLoginData")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{allLogins.map((login, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center justify-between border p-2 ${login.status === "success" ? "border-border bg-muted/30" : "border-destructive/30 bg-destructive/5"}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{login.status === "failed" ? (
|
||||
<UserX className="size-3 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<UserCheck className="size-3 shrink-0 text-accent-brand" />
|
||||
)}
|
||||
<span
|
||||
className={`truncate text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
|
||||
>
|
||||
{login.user}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{login.ip}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{new Date(login.time).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
RadialGauge,
|
||||
Sparkline,
|
||||
MiniStat,
|
||||
MetricCard,
|
||||
LineChart,
|
||||
type LineChartSeries,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { type ServerMetrics } from "../../shared/metrics.js";
|
||||
import { useState, useEffect } from "react";
|
||||
import { MemoryStick } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
|
||||
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
|
||||
|
||||
export function MemoryCard({
|
||||
metrics,
|
||||
history,
|
||||
hostId,
|
||||
}: {
|
||||
metrics: ServerMetrics | null;
|
||||
history: number[];
|
||||
hostId: number | null;
|
||||
}) {
|
||||
const { getMetricsHistory } = useHostMetricsApi();
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
|
||||
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "live" || hostId == null) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setRows(null);
|
||||
getMetricsHistory(hostId, { range: activeTab })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setRows(res.rows);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, hostId]);
|
||||
|
||||
const percent = metrics?.memory?.percent ?? null;
|
||||
const usedGiB = metrics?.memory?.usedGiB ?? null;
|
||||
const totalGiB = metrics?.memory?.totalGiB ?? null;
|
||||
const freeGiB =
|
||||
usedGiB !== null && totalGiB !== null ? totalGiB - usedGiB : null;
|
||||
|
||||
const series: LineChartSeries[] = [
|
||||
{
|
||||
key: "mem",
|
||||
label: t("hostMetrics.memoryUsage"),
|
||||
color: "var(--chart-3)",
|
||||
data: rows?.map((r) => r.mem_percent) ?? [],
|
||||
},
|
||||
];
|
||||
const timestamps = rows?.map((r) => r.ts) ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.memoryUsage")}
|
||||
icon={<MemoryStick className="size-3.5" />}
|
||||
action={
|
||||
hostId != null ? (
|
||||
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTab === "live" && (
|
||||
<div className="flex items-center gap-4">
|
||||
<RadialGauge value={percent} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.memory")}
|
||||
value={
|
||||
usedGiB !== null && totalGiB !== null
|
||||
? `${usedGiB.toFixed(1)}/${totalGiB.toFixed(1)}G`
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.free")}
|
||||
value={freeGiB !== null ? `${freeGiB.toFixed(1)}G` : "N/A"}
|
||||
/>
|
||||
</div>
|
||||
<Sparkline
|
||||
data={[...history, percent ?? 0]}
|
||||
domain={[0, 100]}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab !== "live" && (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length === 0 && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("metricsHistory.noData")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length > 0 && (
|
||||
<LineChart
|
||||
series={series}
|
||||
timestamps={timestamps}
|
||||
domain={[0, 100]}
|
||||
yFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
height={160}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
MetricCard,
|
||||
LineChart,
|
||||
type LineChartSeries,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { type ServerMetrics } from "../../shared/metrics.js";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Cable, Container, Network, Wifi, WifiOff } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
|
||||
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
|
||||
|
||||
function ifaceIcon(name: string) {
|
||||
if (/^(wl|wlan|wifi|ath)/.test(name))
|
||||
return <Wifi className="size-3 text-muted-foreground/60" />;
|
||||
if (/^(eth|en|enp|eno|ens)/.test(name))
|
||||
return <Cable className="size-3 text-muted-foreground/60" />;
|
||||
if (/^(docker|br-|veth|virbr|vlan|bond|tun|tap|wg|lo)/.test(name))
|
||||
return <Container className="size-3 text-muted-foreground/60" />;
|
||||
return <Network className="size-3 text-muted-foreground/60" />;
|
||||
}
|
||||
|
||||
function computeNetRates(rows: MetricsHistoryRow[]): {
|
||||
rxRates: Array<number | null>;
|
||||
txRates: Array<number | null>;
|
||||
} {
|
||||
const rxRates: Array<number | null> = [];
|
||||
const txRates: Array<number | null> = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (i === 0) {
|
||||
rxRates.push(null);
|
||||
txRates.push(null);
|
||||
continue;
|
||||
}
|
||||
const prev = rows[i - 1];
|
||||
const curr = rows[i];
|
||||
const dtSec =
|
||||
(new Date(curr.ts).getTime() - new Date(prev.ts).getTime()) / 1000;
|
||||
if (
|
||||
dtSec < 0.5 ||
|
||||
prev.net_rx_bytes == null ||
|
||||
curr.net_rx_bytes == null ||
|
||||
prev.net_tx_bytes == null ||
|
||||
curr.net_tx_bytes == null
|
||||
) {
|
||||
rxRates.push(null);
|
||||
txRates.push(null);
|
||||
continue;
|
||||
}
|
||||
const rxDelta = curr.net_rx_bytes - prev.net_rx_bytes;
|
||||
const txDelta = curr.net_tx_bytes - prev.net_tx_bytes;
|
||||
rxRates.push(rxDelta < 0 ? null : rxDelta / dtSec);
|
||||
txRates.push(txDelta < 0 ? null : txDelta / dtSec);
|
||||
}
|
||||
return { rxRates, txRates };
|
||||
}
|
||||
|
||||
function formatBytes(bps: number): string {
|
||||
if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
|
||||
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
|
||||
return `${Math.round(bps)} B/s`;
|
||||
}
|
||||
|
||||
export function NetworkCard({
|
||||
metrics,
|
||||
hostId,
|
||||
}: {
|
||||
metrics: ServerMetrics | null;
|
||||
hostId: number | null;
|
||||
}) {
|
||||
const { getMetricsHistory } = useHostMetricsApi();
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
|
||||
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "live" || hostId == null) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setRows(null);
|
||||
getMetricsHistory(hostId, { range: activeTab })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setRows(res.rows);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, hostId]);
|
||||
|
||||
const interfaces = metrics?.network?.interfaces ?? [];
|
||||
|
||||
const { rxRates, txRates } = rows
|
||||
? computeNetRates(rows)
|
||||
: { rxRates: [], txRates: [] };
|
||||
const netMax = Math.max(
|
||||
rxRates.reduce<number>((m, v) => (v != null && v > m ? v : m), 0),
|
||||
txRates.reduce<number>((m, v) => (v != null && v > m ? v : m), 0),
|
||||
1024,
|
||||
);
|
||||
const timestamps = rows?.map((r) => r.ts) ?? [];
|
||||
const hasNetData = rxRates.some((v) => v !== null);
|
||||
|
||||
const rxSeries: LineChartSeries = {
|
||||
key: "rx",
|
||||
label: t("metricsHistory.download"),
|
||||
color: "var(--chart-2)",
|
||||
data: rxRates,
|
||||
};
|
||||
const txSeries: LineChartSeries = {
|
||||
key: "tx",
|
||||
label: t("metricsHistory.upload"),
|
||||
color: "var(--chart-5)",
|
||||
data: txRates,
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.networkInterfaces")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
scroll={activeTab === "live"}
|
||||
action={
|
||||
hostId != null ? (
|
||||
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{activeTab === "live" &&
|
||||
(interfaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-6 text-muted-foreground">
|
||||
<WifiOff className="size-6 opacity-40" />
|
||||
<span className="text-xs">
|
||||
{t("hostMetrics.noInterfacesFound")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{interfaces.map((iface, i) => (
|
||||
<div
|
||||
key={`${iface.name}-${i}`}
|
||||
className="flex flex-col gap-1 border border-border bg-muted/30 p-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{ifaceIcon(iface.name)}
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground/50"}`}
|
||||
/>
|
||||
<span className="font-mono text-sm font-bold">
|
||||
{iface.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="border border-border px-1.5 py-px text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
{iface.state}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-mono text-[10px] text-muted-foreground">
|
||||
<span className="truncate">{iface.ip}</span>
|
||||
{(iface.rxRateBps != null || iface.txRateBps != null) && (
|
||||
<span className="shrink-0">
|
||||
↓{" "}
|
||||
{iface.rxRateBps == null
|
||||
? "—"
|
||||
: formatBytes(iface.rxRateBps)}{" "}
|
||||
/ ↑{" "}
|
||||
{iface.txRateBps == null
|
||||
? "—"
|
||||
: formatBytes(iface.txRateBps)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{activeTab !== "live" && (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && !hasNetData && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("metricsHistory.noData")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && hasNetData && (
|
||||
<LineChart
|
||||
series={[rxSeries, txSeries]}
|
||||
timestamps={timestamps}
|
||||
domain={[0, netMax]}
|
||||
yFormatter={formatBytes}
|
||||
height={160}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 4405 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user