mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: finish the host-metrics plugin, move host status into core (B16)
This commit is contained in:
1 parent
14eb558b85
commit
0a3f05d99d
308 files changed
+53073
-8985
No files matched your search
@@ -161,7 +161,7 @@ http {
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
expires off;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-don5VKNqA8VQplPO1j5zyOVDdbTQrgGSSeVPwJ7b0Lo='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-teIJ2CKVs77AjLM0kquz3mSdDahr9QtMZGG0tQjbbkU='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ http {
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
expires off;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-don5VKNqA8VQplPO1j5zyOVDdbTQrgGSSeVPwJ7b0Lo='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-teIJ2CKVs77AjLM0kquz3mSdDahr9QtMZGG0tQjbbkU='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- host_metrics_preferences, host_health_checks, host_health_history and
|
||||
-- host_metrics_history moved to the host-metrics plugin, which adopts them by
|
||||
-- rename in its own first migration. That runs after this one, so a real
|
||||
-- DROP TABLE here would delete every row first.
|
||||
SELECT 1;--> statement-breakpoint
|
||||
-- Status checks are core: the per-host switch and interval, copied out of
|
||||
-- stats_config at boot by host-status-config-migration.ts.
|
||||
ALTER TABLE `ssh_data` ADD `status_check_enabled` boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` ADD `status_check_interval` int;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- ssh_data.stats_config split: its status half moved to status_check_enabled
|
||||
-- and status_check_interval, its metrics half to the host-metrics plugin's
|
||||
-- host settings. Both copies run at boot, after this migration, so a real
|
||||
-- DROP COLUMN here would delete the data first. The column stays in place,
|
||||
-- unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -302,6 +302,20 @@
|
||||
"when": 1790227893950,
|
||||
"tag": "0042_colorful_bill_hollister",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "5",
|
||||
"when": 1790266379295,
|
||||
"tag": "0043_unusual_lucky_pierre",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "5",
|
||||
"when": 1790266402855,
|
||||
"tag": "0044_jittery_thunderball",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- host_metrics_preferences, host_health_checks, host_health_history and
|
||||
-- host_metrics_history moved to the host-metrics plugin, which adopts them by
|
||||
-- rename in its own first migration. That runs after this one, so a real
|
||||
-- DROP TABLE here would delete every row first.
|
||||
SELECT 1;--> statement-breakpoint
|
||||
-- Status checks are core: the per-host switch and interval, copied out of
|
||||
-- stats_config at boot by host-status-config-migration.ts.
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "status_check_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "status_check_interval" integer;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- ssh_data.stats_config split: its status half moved to status_check_enabled
|
||||
-- and status_check_interval, its metrics half to the host-metrics plugin's
|
||||
-- host settings. Both copies run at boot, after this migration, so a real
|
||||
-- DROP COLUMN here would delete the data first. The column stays in place,
|
||||
-- unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -309,6 +309,20 @@
|
||||
"when": 1790227893380,
|
||||
"tag": "0043_melodic_terror",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "7",
|
||||
"when": 1790266378802,
|
||||
"tag": "0044_nostalgic_omega_sentinel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 45,
|
||||
"version": "7",
|
||||
"when": 1790266402403,
|
||||
"tag": "0045_thankful_shinobi_shaw",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- host_metrics_preferences, host_health_checks, host_health_history and
|
||||
-- host_metrics_history moved to the host-metrics plugin, which adopts them by
|
||||
-- rename in its own first migration. That runs after this one, so a real
|
||||
-- DROP TABLE here would delete every row first.
|
||||
SELECT 1;--> statement-breakpoint
|
||||
-- Status checks are core: the per-host switch and interval, copied out of
|
||||
-- stats_config at boot by host-status-config-migration.ts.
|
||||
ALTER TABLE `ssh_data` ADD `status_check_enabled` integer DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` ADD `status_check_interval` integer;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- ssh_data.stats_config split: its status half moved to status_check_enabled
|
||||
-- and status_check_interval, its metrics half to the host-metrics plugin's
|
||||
-- host settings. Both copies run at boot, after this migration, so a real
|
||||
-- DROP COLUMN here would delete the data first. The column stays in place,
|
||||
-- unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -281,6 +281,20 @@
|
||||
"when": 1790227892787,
|
||||
"tag": "0039_lively_impossible_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "6",
|
||||
"when": 1790266378262,
|
||||
"tag": "0040_rich_zuras",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1790266401908,
|
||||
"tag": "0041_nosy_mystique",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -466,8 +466,12 @@ Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`,
|
||||
`terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9**
|
||||
renamed `terminal.dock` to `terminal.sidePanel` and added the status slot,
|
||||
where host-metrics puts its CPU, memory and disk bars), `onboarding.steps`, `onboarding.features`,
|
||||
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`
|
||||
and `shell.overlay`. **B12** added `shell.overlay`, a component slot the
|
||||
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`,
|
||||
`shell.overlay`, `dashboard.hostMetrics` and `homepage.hostMetrics`. **B16**
|
||||
added the last two, component slots in the dashboard's host status card
|
||||
(`{ hostId, online }`, one per host row) and the homepage's host status widget
|
||||
(`{ hostId, shownMetrics, online }`): core draws the status and a plugin draws
|
||||
the numbers, so core no longer calls the metrics API. **B12** added `shell.overlay`, a component slot the
|
||||
shell renders once at its root for always-mounted plugin UI: session-sharing
|
||||
keeps its share dialog and its room-invite watcher there.
|
||||
`session.remoteDisplay` moved to session-sharing, which declares it and draws
|
||||
@@ -482,6 +486,9 @@ or without tailscale enabled. Cross-plugin frontend calls go through actions:
|
||||
plugin actions the same way when a core view shows a plugin's data:
|
||||
`files.openHost`, and **B7**'s `tunnels.statuses` and `tunnels.open` behind the
|
||||
dashboard's active tunnel counter, which reads as zero while tunnels is off.
|
||||
**B16** added `host-metrics.disk` (the file manager's disk usage bar) and
|
||||
`snippets.get` / `snippets.execute` (host-metrics's quick actions); each
|
||||
answers undefined while its plugin is off.
|
||||
|
||||
**B9** moved the terminal itself into ssh-terminal, which adds
|
||||
`terminal.open(host, { path?, joinSharedSessionId?, joinShareId?, label? })`
|
||||
@@ -571,6 +578,11 @@ connection helpers (`isElectron`, `resolveConnectionOrigin`, `pluginWsUrl`,
|
||||
(`logActivity`, `getHostPassword`, `patchOpenTab`, `getUserPreferences`,
|
||||
`parseCustomKeybindings`, `setHostAutoTmux`, `getCookie`). The APIs are a
|
||||
stopgap in the right place: D1 turns them into typed bridge members.
|
||||
**B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the
|
||||
Appearance density and chart options), and the homepage widget pieces
|
||||
`WidgetTitle` and `runVisibleInterval`, for host-metrics's cards and its
|
||||
metrics chart widget. The frontend SDK gained `useHostStatus(hostId)`: core's
|
||||
status for a host, from the shell's own polling, or null outside the shell.
|
||||
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
|
||||
`ConnectionStage` type, for remote desktop's display socket and its remote
|
||||
host lookup. `useConnectionDefaults` now carries terminal defaults only; the
|
||||
@@ -696,6 +708,44 @@ the `shareable`, `canShare`, `openShareModal` and `getShareTarget` members of
|
||||
`TabOptions`/`TabHandle` are gone. Core's active connections list gets the
|
||||
sessions shared with a user from the `sessions.sharedWithMe` action.
|
||||
|
||||
#### Host status is core
|
||||
|
||||
The status dot in the host list, the dashboard and the homepage widget is
|
||||
core, in `src/backend/hosts/status/`. **B16** moved it out of host-metrics's
|
||||
poller. Core opens a TCP connection to every host with status checks on
|
||||
(`status_check_enabled`, a core host column), on the host's own interval
|
||||
(`status_check_interval`) or the admin default (`global_status_check_interval`
|
||||
under `GET/PUT /host/status/settings`), through core's jump host chain when the
|
||||
host has one. It never logs in: on RADIUS or Duo backed devices a login fires
|
||||
a real 2FA push. That gives "reachable" or "offline". A host is "online" when
|
||||
a login worked: an open session (`ctx.hosts.trackSession`) or a plugin's
|
||||
`ctx.hosts.status.reportLogin`. Polling is demand driven: a user asking for
|
||||
`GET /host/status` starts their own hosts. Core emits `host.status`
|
||||
(`{ hostId, ownerUserId, status, previous, online, reason? }`) when a status
|
||||
changes, and `host.key.updated` (`{ hostId }`) when a changed host key is
|
||||
accepted. The status half of the old `stats_config` moved into the two columns
|
||||
(`host-status-config-migration.ts`); its metrics half is host-metrics's.
|
||||
|
||||
**Host metrics (B16)** polls only hosts somebody is looking at (a tab, a
|
||||
dashboard row, a homepage widget, or another plugin through the
|
||||
`host-metrics.viewers` service, v1: `register(hostId)`, `heartbeat`,
|
||||
`unregister`, all as the caller). It samples over `ctx.ssh` pools and timers
|
||||
from `ctx.schedule`, keeps the adaptive metrics concurrency (one worker per 20
|
||||
polled hosts, 5 to 50, `METRICS_POLL_CONCURRENCY` overrides), and waits for
|
||||
core to call a host reachable before the first heavy sample. It emits
|
||||
`plugin.host-metrics.snapshot` (`{ hostId, ownerUserId, metrics }`) for every
|
||||
sample, `plugin.host-metrics.status` (`{ hostId, ownerUserId, state,
|
||||
previous }`, state `collecting`, `auth_failed`, `host_key_changed` or
|
||||
`unreachable`) when collection changes, and `plugin.host-metrics.health-check`
|
||||
(`{ hostId, userId, checkId, ok, detail? }`) for each health check result;
|
||||
automations subscribes to them. A plugin adds data to every sample by
|
||||
providing `host-metrics.collectors` (v1: `collect(client, hostId)`) under its
|
||||
own provider name; the result lands under `extra[<name>]`. Proxmox does not:
|
||||
its stats come from the Proxmox API, not from SSH on the host. The intervals,
|
||||
history retention and the new-host default are admin settings, the
|
||||
temperature unit a user setting, and the rest of `stats_config` host
|
||||
settings.
|
||||
|
||||
### 10. Lifecycle
|
||||
|
||||
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
|
||||
@@ -999,7 +1049,11 @@ the database, unused, until 3.0.0 removes it. A copy reads it with raw SQL
|
||||
through `utils/crypto-migration/raw-rows.ts`, whose `selectRows` and
|
||||
`runStatement` work on all three engines: `getDb().all()` exists only on
|
||||
SQLite. Remote desktop's host options and `user_preferences.rdp_defaults` are
|
||||
the first columns handled this way.
|
||||
the first columns handled this way. **B16** split `stats_config` the same way, into
|
||||
two core columns and host-metrics's host settings. Adding columns and
|
||||
dropping one in the same drizzle-kit run makes it ask whether that is a
|
||||
rename, which it cannot do without a terminal, so B16 generated two
|
||||
migrations: the added columns first, then the no-op drop.
|
||||
|
||||
---
|
||||
|
||||
@@ -1266,12 +1320,13 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
|
||||
| `ctx.currentActor()` | none | **A1** |
|
||||
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
|
||||
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9** |
|
||||
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** |
|
||||
| `ctx.sync.registerEntity` | none | **A3** |
|
||||
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
||||
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
||||
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
|
||||
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, extended **B5** |
|
||||
| `ctx.hosts.status.*` | `hosts:read` | **B16** |
|
||||
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
|
||||
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
||||
| `ctx.notify.*` | `notify:send` | A6 |
|
||||
@@ -1280,6 +1335,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
|
||||
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
|
||||
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
|
||||
| `ctx.schedule.every` / `.after` | none | **B16** |
|
||||
| `ctx.fetch` | `network:outbound` | B |
|
||||
|
||||
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
|
||||
@@ -1431,6 +1487,39 @@ resolution (the owner's shared snapshot or their own override), never the
|
||||
owner's raw secret; no connect access, or no host, is `null`. Every call is
|
||||
audited as `plugin_credentials_read`, allowed or refused.
|
||||
|
||||
**B16** added `ctx.hosts.status`, the plugin side of core's host status
|
||||
check (see [Host status](#host-status-is-core)):
|
||||
|
||||
- `get(hostId)` is core's last result, or `null` before the first check or
|
||||
for a host with status checks off. `check(hostId)` checks now unless the
|
||||
last result is under 30 seconds old. host-metrics calls it before the first
|
||||
heavy sample of a host.
|
||||
- `reportLogin(hostId, { ok, hostKeyChanged? })` tells core a login to the
|
||||
host worked or failed. A working login is what turns "reachable" into
|
||||
"online". Synchronous and not audited, like `trackSession`: a poller calls
|
||||
it on every sample.
|
||||
- `registerPort(connectionType, resolve)` tells core which port proves a host
|
||||
of that connection type is up, for a protocol whose port is the plugin's own
|
||||
host setting. remote-desktop registers `rdp`, `vnc` and `telnet`; without
|
||||
it core checks the host's port. Removed on deactivate.
|
||||
|
||||
**B16** also added `ctx.ssh.dropPooled(pool, hostId)`, which closes this
|
||||
plugin's pooled connections to one host (host-metrics calls it on
|
||||
`host.updated`). It only touches keys this plugin created, so, like
|
||||
deactivate, it never closes another plugin's connections. The core pool is
|
||||
never cleared as a whole by a plugin.
|
||||
|
||||
**B16** added `ctx.schedule`: `every(ms, fn, { jitterMs?, runNow? })` and
|
||||
`after(ms, fn)`, each returning a function that stops it. Timers live in the
|
||||
plugin's disposable bag, so deactivate clears them; a run that throws is
|
||||
logged, and a repeating job skips a tick while its last run is still going.
|
||||
`jitterMs` delays the first run by a random amount up to that, so a fleet of
|
||||
per-host timers spreads out. Needs no capability.
|
||||
|
||||
`ctx.db.persist({ lazy: true })` (**B16**) marks the database dirty and lets
|
||||
the debounced save write it, for frequent low-value writes such as metrics
|
||||
samples; the default still saves before returning.
|
||||
|
||||
`ctx.hosts.create` (**B14**) hands the created row to every plugin's
|
||||
`hostImportNormalizer`, the same as a bulk import row, so a plugin creating a
|
||||
host can set another plugin's host settings (proxmox marks an imported
|
||||
@@ -1961,7 +2050,8 @@ Then, with the app running:
|
||||
## Legacy core imports: the debt D1 removes
|
||||
|
||||
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
|
||||
(B2) and remote-desktop (B14), which import nothing from core. The others still reach core by relative
|
||||
(B2), remote-desktop (B14) and host-metrics (B16), which import nothing from
|
||||
core. The others still reach core by relative
|
||||
path (`../../../../src/backend/...`), which an esbuild plugin,
|
||||
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
||||
and rewrites to the compiled output path (`../../../backend/backend/...`, or
|
||||
@@ -1988,9 +2078,9 @@ What the lint fence enforces today, in `eslint.config.mjs`:
|
||||
| Core importing a plugin backend | **Error** | 0 | - |
|
||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||
| The shell importing plugin code | **Error** | 0 | - |
|
||||
| A plugin frontend importing core through `@/` | Warning | 120 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 63 files | D1 |
|
||||
| A plugin importing another plugin's source | Warning | 3 files | B18 |
|
||||
| A plugin frontend importing core through `@/` | Warning | 84 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 29 files | D1 |
|
||||
| A plugin importing another plugin's source | Warning | 1 file | B18 |
|
||||
|
||||
A warning does not fail a build, so the counts are held by
|
||||
`scripts/check-plugin-boundaries.cjs`, run by `npm run lint`. Every offender is
|
||||
@@ -2012,12 +2102,12 @@ Known specifics:
|
||||
automations is disabled. In B18 `ai` calls automations through an
|
||||
`automations` service as an `optionalDependency` and the direct import goes
|
||||
away.
|
||||
- **TODO(B18):** `plugins/host-metrics/src/backend/` reaches `automations`
|
||||
twice, for a `MetricsSnapshot` type and a fire-and-forget
|
||||
`import("...headless-viewer.js")` that registers a viewer bridge. Both
|
||||
degrade silently when automations is absent, so host-metrics declares it as
|
||||
an `optionalDependency` (added in A2; it previously declared nothing, and
|
||||
the loader had no reason to order the two).
|
||||
- **B16** moved host-metrics onto the SDK: it imports nothing from core or
|
||||
automations. Automations reaches it through the `host-metrics.viewers`
|
||||
service and its `plugin.host-metrics.*` events, and core reaches it through
|
||||
the `dashboard.hostMetrics` and `homepage.hostMetrics` slots and the
|
||||
`host-metrics.disk` action. Core's `hosts/metrics-shared/` is gone: ai,
|
||||
automations, proxmox and tailscale use `@termix/plugin-sdk/host-commands`.
|
||||
- Core feature servers that are not plugins yet still own ports: dashboard
|
||||
30006 and homepage 30012. Each keeps its nginx block until its own Phase B
|
||||
step. No plugin owns a port any more (A4). **B6** moved file-manager off
|
||||
|
||||
@@ -10,29 +10,15 @@ it's done.
|
||||
shared with host-metrics's originals. Promote a shared version into
|
||||
`@termix/plugin-sdk/ui` once a second manager-card plugin needs one, per the
|
||||
contract's "add to the SDK when a second plugin needs it" rule. Owner: D1.
|
||||
- **B3 (tailscale):** `plugins/tailscale/src/backend/host-metrics-manager.ts`
|
||||
and `routes.ts` still resolve hosts and access through core's
|
||||
`PermissionManager`/`resolveHostById`/`DataCrypto` by relative import
|
||||
instead of `ctx.hosts`, which **B4** built. Same debt every other
|
||||
host-metrics manager already carries; not new, but tailscale now carries it
|
||||
too. Owner: host-metrics's own Phase B step (it and every manager plugin
|
||||
convert together).
|
||||
- **B4 (fleets/host-metrics):** `src/backend/hosts/metrics-shared/` (platform
|
||||
detection, package commands, exec-elevated) is still core's own copy, used
|
||||
by host-metrics, which has not converted yet. `@termix/plugin-sdk/host-commands`
|
||||
is the SDK copy fleets now uses; core's copy was left in place rather than
|
||||
turned into a re-export, since host-metrics's own conversion step is where
|
||||
its imports should move wholesale. Owner: host-metrics's Phase B step.
|
||||
- **B5 (proxmox):** `runAdaptivePolling` and `cn` are duplicated in
|
||||
`plugins/proxmox/src/frontend/stats/` rather than shared with core's
|
||||
`src/ui/lib/` originals. `useConnectionRetry` (the third duplicate B5 left
|
||||
here) is resolved: **B6** promoted it into `@termix/plugin-sdk/frontend`
|
||||
once file-manager became its fourth caller (after proxmox, docker,
|
||||
host-metrics, remote-desktop) and switched proxmox's own copy over too.
|
||||
host-metrics's own conversion needs `runAdaptivePolling` for its stats tab,
|
||||
so promote it into `@termix/plugin-sdk/frontend` (and `cn` into
|
||||
`@termix/plugin-sdk/ui`) the next time either is touched. Owner: D1, or
|
||||
host-metrics's Phase B step if it lands first.
|
||||
`runAdaptivePolling` and `cn` are exported from `@termix/plugin-sdk/ui`
|
||||
now (B9), and host-metrics uses them there (B16); only proxmox's copies are
|
||||
left to switch. Owner: D1.
|
||||
- **B5 (proxmox):** `runDueProxmoxAutoSyncs` in
|
||||
`plugins/proxmox/src/backend/routes.ts` still reaches
|
||||
`createCurrentPluginSettingsRepository`/`createCurrentHostRepository` by
|
||||
@@ -102,8 +88,9 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
`logActivity` is a core `@/main-axios` call and the plugin imports nothing
|
||||
from `@/`. Old "tunnel" entries still reopen the tab through the plugin's
|
||||
`activityTypes`. An `app.logActivity` (or equivalent bridge member) would
|
||||
restore it for this plugin and let docker, file-manager and host-metrics drop
|
||||
their own `@/main-axios` import for it too. Owner: D1.
|
||||
restore it for this plugin and let docker and file-manager drop their own
|
||||
`@/main-axios` import for it too (host-metrics uses the `logActivity` the
|
||||
ui entry exports). Owner: D1.
|
||||
- **B7 (tunnels):** on the desktop app, tunnel statuses from a connected
|
||||
remote server are no longer merged into the tab: the old code polled the
|
||||
remote tunnel service through `getRemoteTunnelApi()`, and the SDK has no
|
||||
@@ -345,6 +332,46 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
`@termix/plugin-sdk/ui`), workspaces included. `npm run type-check` covers
|
||||
plugins through `tsconfig.plugins*.json` and passes. Owner: D0.
|
||||
|
||||
- **B16 (host-metrics):** automations still sets up its headless viewers
|
||||
through `setViewerRegistry` in `plugins/automations/src/backend/headless-viewer.ts`,
|
||||
which nothing calls any more, so hosts an automation watches are only
|
||||
sampled while somebody has them open. Switch it to the
|
||||
`host-metrics.viewers` service (`register(hostId)` as the automation's
|
||||
owner, `heartbeat`, `unregister`) and make host-metrics an optional
|
||||
dependency again. Its triggers already listen to the final topics
|
||||
(`plugin.host-metrics.snapshot`, `plugin.host-metrics.health-check`, core
|
||||
`host.status`, `host.login`), but through core's bus by relative import
|
||||
instead of `ctx.events`. Owner: B17.
|
||||
- **B16 (host-metrics):** Termix-Mobile read `statsConfig` on each host
|
||||
(`statusCheckEnabled`, `enabledWidgets`) and the host status from the
|
||||
metrics API. The host payload now carries `statusCheckEnabled` and
|
||||
`statusCheckInterval` at the top level, the metrics options under
|
||||
`pluginSettings["host-metrics"]`, and statuses come from `GET /host/status`.
|
||||
The mobile app needs the matching change, or the host payload a
|
||||
`statsConfig` built from those fields for one release. Owner: D1.
|
||||
- **B16 (host-metrics):** a host's status is only checked once its owner has
|
||||
asked for statuses (opened the app) since the server started: a user who
|
||||
only has a host shared with them sees no dot for it until then. This was
|
||||
already true before B16 (the first user to load the host list started
|
||||
polling for their own hosts only). Starting checks for the owners of every
|
||||
host a requester can see would fix it. Owner: D1.
|
||||
- **B16 (host-metrics):** the host JSON export does not carry host-metrics's
|
||||
host settings (it did carry `statsConfig`); import reads both the old
|
||||
`statsConfig` and `pluginSettings["host-metrics"]` through the plugin's
|
||||
import normalizer. Same gap as the B7 line about exporting plugin host
|
||||
settings generically. Owner: D1.
|
||||
- **B16 (host-metrics):** the CPU and network collectors keep their last
|
||||
sample per host in module-level maps (`widgets/cpu-collector.ts`,
|
||||
`widgets/network-collector.ts`) to compute rates. The poller clears them on
|
||||
deactivate and per host, so nothing leaks, but they are still module state
|
||||
rather than per-activation. Owner: D1.
|
||||
- **Pre-existing, found in B16:** automations
|
||||
`tests/backend/tunnels.test.ts` fails "fails the step cleanly while the
|
||||
tunnels plugin is off": it expects "tunnels plugin is not available" and
|
||||
gets "tunnels.start is not a function". Same cause as the tunnels line
|
||||
above: since B12 `ctx.services.get` hands back an empty handle instead of
|
||||
throwing, so the step has to check `typeof start`. Owner: B17 or D0.
|
||||
|
||||
## Manual checks after 2.9.0
|
||||
|
||||
- SSH terminal: password auth, key auth (and an encrypted key's passphrase
|
||||
@@ -362,6 +389,16 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
off records nothing, retention prunes an old recording on schedule, a guacd
|
||||
RDP/VNC/Telnet recording still gets a row and plays back, and deleting a
|
||||
user anonymizes their recordings instead of removing them.
|
||||
- Host status and metrics: on a 2.8 database, hosts whose status checks were
|
||||
off stay off and a custom interval survives; the host list dot goes
|
||||
reachable, then online once the Host Metrics tab or a terminal logs in;
|
||||
an RDP-only host on a custom port shows online; the metrics tab, the
|
||||
dashboard host bars, the homepage host status widget, the metrics chart
|
||||
widget, the terminal toolbar bars and the file manager's disk bar; a
|
||||
manager card (services, packages) and a health check; TOTP on the metrics
|
||||
connect; the admin metrics interval, history retention and new-host default;
|
||||
the temperature unit; disabling host metrics leaves every other plugin's
|
||||
SSH working, then enabling it again.
|
||||
- Remote desktop: an RDP, a VNC and a Telnet host; a host behind one jump
|
||||
host (tunnels plugin on and off) and behind a chain of two; macOS Screen
|
||||
Sharing over VNC with no username; RDP recording (and none with
|
||||
|
||||
@@ -112,7 +112,14 @@ export interface PluginDatabase {
|
||||
* lives in memory and is saved to its encrypted file only when asked, so a
|
||||
* write without it can be lost on restart. A no-op on Postgres and MySQL.
|
||||
*/
|
||||
persist: () => Promise<void>;
|
||||
persist: (options?: {
|
||||
/**
|
||||
* Mark the database dirty and let the debounced save pick it up, rather
|
||||
* than saving now. For frequent, low-value writes such as samples, where
|
||||
* losing a couple of seconds on a crash is fine.
|
||||
*/
|
||||
lazy?: boolean;
|
||||
}) => Promise<void>;
|
||||
/**
|
||||
* The engine the server runs on. Table objects encode the same on all
|
||||
* three, but MySQL has no RETURNING, so portable code reads a written row
|
||||
@@ -614,6 +621,48 @@ export interface PluginHosts {
|
||||
type: string,
|
||||
hostName: string,
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* Core's reachability status, the one behind the host list's status dot.
|
||||
* Needs hosts:read.
|
||||
*/
|
||||
status: PluginHostStatusApi;
|
||||
}
|
||||
|
||||
/** online: a login worked. reachable: the port answers. offline: it does not. */
|
||||
export type PluginHostStatus = "online" | "reachable" | "offline";
|
||||
|
||||
export interface PluginHostStatusEntry {
|
||||
status: PluginHostStatus;
|
||||
lastChecked: string;
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Core checks every host with status checks on by opening a TCP connection to
|
||||
* it. A plugin that logs in to hosts tells core how that went, which is what
|
||||
* turns "reachable" into "online".
|
||||
*/
|
||||
export interface PluginHostStatusApi {
|
||||
/** The last known status, or null when core has not checked the host yet. */
|
||||
get: (hostId: number) => PluginHostStatusEntry | null;
|
||||
/** Checks the host now unless core checked it recently. */
|
||||
check: (hostId: number) => Promise<PluginHostStatusEntry | null>;
|
||||
/** Reports a login attempt. Not audited: pollers call it every sample. */
|
||||
reportLogin: (
|
||||
hostId: number,
|
||||
outcome: { ok: boolean; hostKeyChanged?: boolean },
|
||||
) => void;
|
||||
/**
|
||||
* Tells core which port proves a host of this connection type is up, for
|
||||
* protocols whose port lives in the plugin's own host settings. Core falls
|
||||
* back to the host's port. Removed on deactivate.
|
||||
*/
|
||||
registerPort: (
|
||||
connectionType: string,
|
||||
resolve: (
|
||||
hostId: number,
|
||||
) => number | undefined | Promise<number | undefined>,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -782,6 +831,12 @@ export interface PluginSsh {
|
||||
/** The pool key withConnection would use, for clearing it. */
|
||||
poolKey: (pool: string, host: PluginSshHost) => string;
|
||||
|
||||
/**
|
||||
* Closes this plugin's pooled connections to a host, for when its details
|
||||
* changed. Only keys this plugin created are touched.
|
||||
*/
|
||||
dropPooled: (pool: string, hostId: number) => void;
|
||||
|
||||
/**
|
||||
* The host as the acting user may connect to it: RBAC, owner-key
|
||||
* decryption, shared-host overrides and external secret references
|
||||
@@ -1216,6 +1271,28 @@ export interface PluginCapabilities {
|
||||
require: (capability: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timers owned by the plugin. Every timer is cleared on deactivate, a run that
|
||||
* throws is logged instead of crashing the server, and a repeating job never
|
||||
* overlaps itself: a tick that arrives while the last run is still going is
|
||||
* skipped. No capability needed.
|
||||
*/
|
||||
export interface PluginSchedule {
|
||||
/** Runs `fn` every `intervalMs`. Returns a function that stops it. */
|
||||
every: (
|
||||
intervalMs: number,
|
||||
fn: () => void | Promise<void>,
|
||||
options?: {
|
||||
/** Delays the first run by up to this much, so many timers spread out. */
|
||||
jitterMs?: number;
|
||||
/** Also run once straight away. */
|
||||
runNow?: boolean;
|
||||
},
|
||||
) => () => void;
|
||||
/** Runs `fn` once after `delayMs`. Returns a function that cancels it. */
|
||||
after: (delayMs: number, fn: () => void | Promise<void>) => () => void;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
@@ -1247,6 +1324,8 @@ export interface PluginContext {
|
||||
readonly credentials: PluginCredentials;
|
||||
/** Audit lines under the plugin's own action names. */
|
||||
readonly audit: PluginAudit;
|
||||
/** Timers that stop on deactivate. */
|
||||
readonly schedule: PluginSchedule;
|
||||
|
||||
/**
|
||||
* Runs `fn` with `userId` as the acting user, for background work that has
|
||||
|
||||
@@ -695,6 +695,16 @@ export interface PluginHostBridge {
|
||||
id: string,
|
||||
) => ComponentType<Record<string, unknown>> | undefined;
|
||||
hostProtocols: (host: PluginHostRecord) => string[];
|
||||
useHostStatus: (hostId: number | undefined) => HostStatusInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's status for a host, the one behind the host list's dot. online: a
|
||||
* login worked. reachable: the port answers. unknown: not checked yet.
|
||||
*/
|
||||
export interface HostStatusInfo {
|
||||
status: "online" | "reachable" | "offline" | "unknown";
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
let host: PluginHostBridge | null = null;
|
||||
@@ -765,6 +775,17 @@ export function useHosts(): { hosts: PluginHostRecord[]; loaded: boolean } {
|
||||
return requireHost().useHosts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's status for one host, kept current by the shell's own polling. Null
|
||||
* outside the app shell (a standalone window) and for hosts with status
|
||||
* checks off.
|
||||
*/
|
||||
export function useHostStatus(
|
||||
hostId: number | undefined,
|
||||
): HostStatusInfo | null {
|
||||
return requireHost().useHostStatus(hostId);
|
||||
}
|
||||
|
||||
export function useCurrentUser(): CurrentUser | null {
|
||||
return requireHost().useCurrentUser();
|
||||
}
|
||||
|
||||
@@ -38,8 +38,17 @@ import type { PluginDatabase } from "./backend.js";
|
||||
import type {
|
||||
PluginNativeRdpRequest,
|
||||
PluginProtocolTarget,
|
||||
PluginHostStatusEntry,
|
||||
} from "./backend.js";
|
||||
|
||||
/** A timer registered through ctx.schedule, run by hand with runScheduled. */
|
||||
export interface FakeScheduledJob {
|
||||
kind: "every" | "after";
|
||||
ms: number;
|
||||
fn: () => void | Promise<void>;
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
export interface FakeContextOptions {
|
||||
pluginId?: string;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
@@ -80,6 +89,8 @@ export interface FakeContextOptions {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.desktop.available() answers. Defaults to false. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.hosts.status.get and check answer, by host id. */
|
||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||
}
|
||||
|
||||
export interface FakeAuthRegistrations {
|
||||
@@ -150,6 +161,22 @@ export interface FakePluginContext {
|
||||
interaction: string;
|
||||
request: Record<string, unknown>;
|
||||
}>;
|
||||
/** Every ctx.hosts.status.reportLogin call, in order. */
|
||||
statusReports: Array<{
|
||||
hostId: number;
|
||||
ok: boolean;
|
||||
hostKeyChanged?: boolean;
|
||||
}>;
|
||||
/** ctx.hosts.status.registerPort resolvers, by connection type. */
|
||||
statusPorts: Map<string, (hostId: number) => unknown>;
|
||||
/** Backing store behind ctx.hosts.status.get and check. */
|
||||
hostStatuses: Map<number, PluginHostStatusEntry>;
|
||||
/** Every ctx.ssh.dropPooled call, in order. */
|
||||
droppedPools: Array<{ pool: string; hostId: number }>;
|
||||
/** Every ctx.schedule timer, stopped ones included. */
|
||||
scheduled: FakeScheduledJob[];
|
||||
/** Runs every live ctx.schedule job once; "after" jobs are then done. */
|
||||
runScheduled: () => Promise<void>;
|
||||
/** Changes the acting user, as core's request middleware would. */
|
||||
setActor: (userId: string | undefined) => void;
|
||||
/** Provided or seeded services, by name or "<service>#<provider>". */
|
||||
@@ -227,6 +254,29 @@ export function createFakeContext(
|
||||
const activities: FakePluginContext["activities"] = [];
|
||||
const trackedSessions: number[] = [];
|
||||
const interactions: FakePluginContext["interactions"] = [];
|
||||
const statusReports: FakePluginContext["statusReports"] = [];
|
||||
const statusPorts: FakePluginContext["statusPorts"] = new Map();
|
||||
const hostStatuses = new Map<number, PluginHostStatusEntry>(
|
||||
Object.entries(options.hostStatuses ?? {}).map(([id, entry]) => [
|
||||
Number(id),
|
||||
entry,
|
||||
]),
|
||||
);
|
||||
const droppedPools: FakePluginContext["droppedPools"] = [];
|
||||
const scheduled: FakeScheduledJob[] = [];
|
||||
const schedule = (
|
||||
kind: FakeScheduledJob["kind"],
|
||||
ms: number,
|
||||
fn: FakeScheduledJob["fn"],
|
||||
) => {
|
||||
const job: FakeScheduledJob = { kind, ms, fn, stopped: false };
|
||||
scheduled.push(job);
|
||||
const stop = () => {
|
||||
job.stopped = true;
|
||||
};
|
||||
disposals.push(stop);
|
||||
return stop;
|
||||
};
|
||||
const hostsById = new Map<number, PluginHostSummary>(
|
||||
(options.hosts ?? []).map((h) => [h.id, h]),
|
||||
);
|
||||
@@ -561,6 +611,28 @@ export function createFakeContext(
|
||||
recordActivity: async (hostId, type, hostName) => {
|
||||
activities.push({ hostId, type, hostName });
|
||||
},
|
||||
status: {
|
||||
get: (hostId) => hostStatuses.get(hostId) ?? null,
|
||||
check: async (hostId) => hostStatuses.get(hostId) ?? null,
|
||||
reportLogin: (hostId, outcome) => {
|
||||
statusReports.push({ hostId, ...outcome });
|
||||
},
|
||||
registerPort: (connectionType, resolve) => {
|
||||
statusPorts.set(connectionType, resolve);
|
||||
return () => {
|
||||
statusPorts.delete(connectionType);
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
schedule: {
|
||||
every: (intervalMs, fn, scheduleOptions) => {
|
||||
const stop = schedule("every", intervalMs, fn);
|
||||
if (scheduleOptions?.runNow) void fn();
|
||||
return stop;
|
||||
},
|
||||
after: (delayMs, fn) => schedule("after", delayMs, fn),
|
||||
},
|
||||
|
||||
audit: {
|
||||
@@ -591,6 +663,9 @@ export function createFakeContext(
|
||||
host: { id: 0, ip: "", port: 22, username: "" } as never,
|
||||
dispose: () => {},
|
||||
}),
|
||||
dropPooled: (pool, hostId) => {
|
||||
droppedPools.push({ pool, hostId });
|
||||
},
|
||||
poolKey: (pool, host) =>
|
||||
`${pool}:${host.userId}:${host.ip}:${host.port}:${host.username}`,
|
||||
resolveHost: async (hostId, resolveOptions) =>
|
||||
@@ -701,6 +776,17 @@ export function createFakeContext(
|
||||
activities,
|
||||
trackedSessions,
|
||||
interactions,
|
||||
statusReports,
|
||||
statusPorts,
|
||||
hostStatuses,
|
||||
droppedPools,
|
||||
scheduled,
|
||||
runScheduled: async () => {
|
||||
for (const job of scheduled.filter((candidate) => !candidate.stopped)) {
|
||||
if (job.kind === "after") job.stopped = true;
|
||||
await job.fn();
|
||||
}
|
||||
},
|
||||
setActor: (userId) => {
|
||||
actor = userId;
|
||||
},
|
||||
@@ -739,6 +825,8 @@ export interface MockContextOptions {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.desktop.available() answers. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.hosts.status answers. See FakeContextOptions. */
|
||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||
}
|
||||
|
||||
export interface MockPluginContext extends FakePluginContext {
|
||||
@@ -777,6 +865,7 @@ export function createMockCtx(
|
||||
services: options.services,
|
||||
protocolTargets: options.protocolTargets,
|
||||
desktopAvailable: options.desktopAvailable,
|
||||
hostStatuses: options.hostStatuses,
|
||||
manifest: {
|
||||
capabilities: options.capabilities ?? [],
|
||||
...options.manifest,
|
||||
@@ -815,9 +904,9 @@ export function createMockCtx(
|
||||
require("db:own");
|
||||
return guardedDb.refs();
|
||||
},
|
||||
persist: async () => {
|
||||
persist: async (persistOptions) => {
|
||||
require("db:own");
|
||||
return guardedDb.persist();
|
||||
return guardedDb.persist(persistOptions);
|
||||
},
|
||||
dialect: guardedDb.dialect,
|
||||
},
|
||||
@@ -937,6 +1026,24 @@ export function createMockCtx(
|
||||
require("hosts:read");
|
||||
return ctx.hosts.recordActivity(hostId, type, hostName);
|
||||
},
|
||||
status: {
|
||||
get: (hostId) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.status.get(hostId);
|
||||
},
|
||||
check: async (hostId) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.status.check(hostId);
|
||||
},
|
||||
reportLogin: (hostId, outcome) => {
|
||||
require("hosts:read");
|
||||
ctx.hosts.status.reportLogin(hostId, outcome);
|
||||
},
|
||||
registerPort: (connectionType, resolve) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.status.registerPort(connectionType, resolve);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ssh: {
|
||||
|
||||
@@ -81,6 +81,7 @@ const offline = () =>
|
||||
useSettings: () => ({ values: {}, loaded: true, save: async () => {} }),
|
||||
useHost: () => null,
|
||||
useHosts: () => ({ hosts: [], loaded: true }),
|
||||
useHostStatus: () => null,
|
||||
useCurrentUser: () => null,
|
||||
useTheme: () => ({ theme: "dark" }),
|
||||
toast: { success: noop, error: noop, info: noop, warning: noop },
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
import { getTool } from "./catalog.js";
|
||||
import {
|
||||
|
||||
@@ -27,12 +27,10 @@ vi.mock("../../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
}));
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock(
|
||||
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
|
||||
() => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}),
|
||||
);
|
||||
vi.mock("@termix/plugin-sdk/host-commands", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
vi.mock("../../../src/backend/ssh.js", () => ({
|
||||
withSshConnection: async (
|
||||
_host: unknown,
|
||||
|
||||
@@ -2,11 +2,11 @@ import {
|
||||
DEFAULT_STEP_TIMEOUT_MS,
|
||||
type Step,
|
||||
} from "../../../../../src/types/automations.js";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execCommand,
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { getSnippet, resolveSnippetCommandFor } from "../snippets.js";
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AutomationDefinition } from "../../../../src/types/automations.js"
|
||||
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import { withSshConnection } from "./ssh.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { onDockerEvent } from "./triggers.js";
|
||||
|
||||
@@ -367,18 +367,34 @@ export function subscribeAutomationTriggers(): void {
|
||||
if (unsubscribers.length > 0) return;
|
||||
|
||||
unsubscribers = [
|
||||
pluginEvents.on(TOPICS.hostMetrics, (payload) =>
|
||||
pluginEvents.on("plugin.host-metrics.snapshot", (payload) =>
|
||||
onMetrics(payload as MetricEvent),
|
||||
),
|
||||
pluginEvents.on(TOPICS.hostStatus, (payload) =>
|
||||
onStatus(payload as StatusEvent),
|
||||
),
|
||||
pluginEvents.on(TOPICS.hostHealthCheck, (payload) =>
|
||||
pluginEvents.on("plugin.host-metrics.health-check", (payload) =>
|
||||
onHealthCheck(payload as HealthEvent),
|
||||
),
|
||||
pluginEvents.on(TOPICS.internalEvent, (payload) =>
|
||||
onInternalEvent(payload as InternalEvent),
|
||||
),
|
||||
// An SSH login seen by the terminal, which host-metrics used to relay.
|
||||
pluginEvents.on(TOPICS.hostLogin, (payload) => {
|
||||
const login = payload as {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
sshUser?: string;
|
||||
fromIp?: string;
|
||||
};
|
||||
if (!login?.userId) return;
|
||||
return onInternalEvent({
|
||||
event: "user_login",
|
||||
userId: login.userId,
|
||||
hostId: login.hostId,
|
||||
details: { sshUser: login.sshUser, fromIp: login.fromIp },
|
||||
});
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
ConnectionScreen,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
|
||||
import { useConnectionRetry } from "@termix/plugin-sdk/frontend";
|
||||
import { invokeAction, useConnectionRetry } from "@termix/plugin-sdk/frontend";
|
||||
import { copyToClipboard } from "@/lib/clipboard.ts";
|
||||
import {
|
||||
listSSHFiles,
|
||||
@@ -78,12 +78,11 @@ import {
|
||||
} from "./api/file-manager-data-api";
|
||||
import {
|
||||
logActivity,
|
||||
getServerMetricsById,
|
||||
transferToHost,
|
||||
addTransferRecent,
|
||||
type TransferMethodPreference,
|
||||
type DiskFilesystem,
|
||||
} from "@/main-axios.ts";
|
||||
import type { DiskFilesystem, HostDiskInfo } from "./disk-info";
|
||||
import {
|
||||
getAdaptiveResourceBudget,
|
||||
markAdaptiveResourceUsed,
|
||||
@@ -3262,19 +3261,17 @@ function FileManagerContent({
|
||||
useEffect(() => {
|
||||
if (currentHost?.id) {
|
||||
loadPinnedFiles();
|
||||
getServerMetricsById(currentHost.id)
|
||||
.then((metrics) => {
|
||||
if (
|
||||
metrics?.disk?.percent != null &&
|
||||
metrics.disk.usedHuman &&
|
||||
metrics.disk.totalHuman
|
||||
) {
|
||||
// Undefined while the host-metrics plugin is off: no disk bar then.
|
||||
invokeAction("host-metrics.disk", currentHost.id)
|
||||
.then((result) => {
|
||||
const disk = result as HostDiskInfo | null | undefined;
|
||||
if (disk?.percent != null && disk.usedHuman && disk.totalHuman) {
|
||||
setDiskInfo({
|
||||
usedHuman: metrics.disk.usedHuman,
|
||||
totalHuman: metrics.disk.totalHuman,
|
||||
percent: metrics.disk.percent,
|
||||
mount: metrics.disk.mount ?? null,
|
||||
filesystems: metrics.disk.filesystems ?? [],
|
||||
usedHuman: disk.usedHuman,
|
||||
totalHuman: disk.totalHuman,
|
||||
percent: disk.percent,
|
||||
mount: disk.mount ?? null,
|
||||
filesystems: disk.filesystems ?? [],
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
removeFolderShortcut,
|
||||
} from "./api/file-manager-data-api";
|
||||
import { listSSHFiles } from "./api/ssh-file-operations-api";
|
||||
import { type DiskFilesystem } from "@/main-axios.ts";
|
||||
import type { DiskFilesystem } from "./disk-info";
|
||||
import { toast } from "sonner";
|
||||
import FolderTree from "@/components/folder.tsx";
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** One mounted filesystem, as the host-metrics plugin reports it. */
|
||||
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 HostDiskInfo {
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
totalHuman: string | null;
|
||||
mount: string | null;
|
||||
filesystems?: DiskFilesystem[];
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
# Host Metrics
|
||||
|
||||
Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard, Tailscale).
|
||||
Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard). Other plugins add manager cards through the `host-metrics.managers` slot (Tailscale does).
|
||||
|
||||
The host list's online and offline dot is core's own status check, not this plugin. A working metrics login is what turns a reachable host online.
|
||||
|
||||
Bundled with Termix and enabled by default.
|
||||
|
||||
## Layout
|
||||
|
||||
manifest.json id, capabilities and what this plugin contributes
|
||||
src/backend/ activate(ctx) / deactivate(), plus the routes
|
||||
src/backend/ activate(ctx) / deactivate(), the poller and the routes
|
||||
src/frontend/ the UI it registers
|
||||
src/shared/ types both sides use
|
||||
migrations/ adopts core's four metrics tables (sqlite, postgres, mysql)
|
||||
locales/en.json its strings (English only; the rest are translated)
|
||||
tests/backend/ vitest, node
|
||||
tests/frontend/ vitest, jsdom
|
||||
|
||||
@@ -182,7 +182,8 @@
|
||||
"wgLastHandshake": "Last handshake",
|
||||
"wgHandshakeNever": "Never",
|
||||
"wgTransfer": "Transfer"
|
||||
}
|
||||
},
|
||||
"snippetsUnavailable": "The Snippets plugin is off, so quick actions cannot run."
|
||||
},
|
||||
"metricsHistory": {
|
||||
"title": "Metrics History",
|
||||
@@ -223,7 +224,9 @@
|
||||
"noQuickActions": "No quick actions yet.",
|
||||
"buttonLabel": "Button label",
|
||||
"selectSnippetPlaceholder": "Select snippet...",
|
||||
"addActionBtn": "Add Action"
|
||||
"addActionBtn": "Add Action",
|
||||
"hostNotFound": "Host not found",
|
||||
"loadingHost": "Loading host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrics and alerts",
|
||||
@@ -238,9 +241,91 @@
|
||||
"noData": "No data available",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range"
|
||||
"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"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Cancel",
|
||||
"confirmRunButton": "Run",
|
||||
"confirmRunMessage": "Run \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Geen vinnige aksies nog nie.",
|
||||
"buttonLabel": "Knoppie-etiket",
|
||||
"selectSnippetPlaceholder": "Kies uittreksel...",
|
||||
"addActionBtn": "Voeg Aksie by"
|
||||
"addActionBtn": "Voeg Aksie by",
|
||||
"hostNotFound": "Gasheer nie gevind nie",
|
||||
"loadingHost": "Laai gasheer..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrieke en waarskuwings",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Geen data beskikbaar nie",
|
||||
"showCurrentValue": "Wys huidige waarde",
|
||||
"chartMetric": "Metrieke",
|
||||
"metricRange": "Reikwydte"
|
||||
"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..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "SVE",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Skyf"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Kanselleer",
|
||||
"confirmRunButton": "Hardloop",
|
||||
"confirmRunMessage": "Begin \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Verbonde"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "لا توجد إجراءات سريعة بعد.",
|
||||
"buttonLabel": "تسمية الزر",
|
||||
"selectSnippetPlaceholder": "اختر مقتطفًا...",
|
||||
"addActionBtn": "إضافة إجراء"
|
||||
"addActionBtn": "إضافة إجراء",
|
||||
"hostNotFound": "المضيف غير موجود",
|
||||
"loadingHost": "جارٍ تحميل المضيف..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "المقاييس والتنبيهات",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "لا توجد بيانات",
|
||||
"showCurrentValue": "إظهار القيمة الحالية",
|
||||
"chartMetric": "المقياس",
|
||||
"metricRange": "النطاق"
|
||||
"metricRange": "النطاق",
|
||||
"host": "المضيف",
|
||||
"loading": "جار التحميل...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "القرص",
|
||||
"metricHostname": "اسم المضيف",
|
||||
"metricKernel": "النواة",
|
||||
"metricMemory": "الذاكرة",
|
||||
"metricNetwork": "الشبكة",
|
||||
"metricOs": "نظام التشغيل",
|
||||
"metricProcesses": "العمليات",
|
||||
"metricProcessesRunning": "قيد التشغيل",
|
||||
"metricProcessesTotal": "إجمالي",
|
||||
"metricUptime": "وقت التشغيل",
|
||||
"metricsNotAvailable": "المقاييس غير متوفرة",
|
||||
"noHostSelected": "لم يتم تحديد أي مضيف",
|
||||
"selectHost": "اختيار مضيف..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "المعالج",
|
||||
"ram": "الذاكرة"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "القرص"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "إلغاء",
|
||||
"confirmRunButton": "تشغيل",
|
||||
"confirmRunMessage": "تشغيل \"{{name}}\"؟"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "متصل"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Все още няма бързи действия.",
|
||||
"buttonLabel": "Етикет на бутона",
|
||||
"selectSnippetPlaceholder": "Изберете фрагмент...",
|
||||
"addActionBtn": "Добавяне на действие"
|
||||
"addActionBtn": "Добавяне на действие",
|
||||
"hostNotFound": "Хостът не е намерен",
|
||||
"loadingHost": "Зареждане на хоста..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Показатели и сигнали",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Няма налични данни",
|
||||
"showCurrentValue": "Показване на текущата стойност",
|
||||
"chartMetric": "Метричен",
|
||||
"metricRange": "Диапазон"
|
||||
"metricRange": "Диапазон",
|
||||
"host": "Домакин",
|
||||
"loading": "Зареждане...",
|
||||
"metricCpu": "Процесор",
|
||||
"metricDisk": "Диск",
|
||||
"metricHostname": "Име на хост",
|
||||
"metricKernel": "Ядро",
|
||||
"metricMemory": "Памет",
|
||||
"metricNetwork": "Мрежа",
|
||||
"metricOs": "ОС",
|
||||
"metricProcesses": "Процеси",
|
||||
"metricProcessesRunning": "бягане",
|
||||
"metricProcessesTotal": "общо",
|
||||
"metricUptime": "Време на работа",
|
||||
"metricsNotAvailable": "Показателите не са налични",
|
||||
"noHostSelected": "Няма избран хост",
|
||||
"selectHost": "Изберете хост..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "Процесор",
|
||||
"ram": "RAM памет"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Диск"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Отказ",
|
||||
"confirmRunButton": "Бягане",
|
||||
"confirmRunMessage": "Изпълни „{{name}}“?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Свързан"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "এখনো কোনো দ্রুত পদক্ষেপ নেওয়া হয়নি।",
|
||||
"buttonLabel": "বোতাম লেবেল",
|
||||
"selectSnippetPlaceholder": "স্নিপেট নির্বাচন করুন...",
|
||||
"addActionBtn": "অ্যাকশন যোগ করুন"
|
||||
"addActionBtn": "অ্যাকশন যোগ করুন",
|
||||
"hostNotFound": "হোস্ট খুঁজে পাওয়া যায়নি",
|
||||
"loadingHost": "হোস্ট লোড হচ্ছে..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "মেট্রিক্স এবং সতর্কতা",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "কোন ডেটা উপলব্ধ নেই",
|
||||
"showCurrentValue": "বর্তমান মান দেখান",
|
||||
"chartMetric": "মেট্রিক",
|
||||
"metricRange": "পরিসর"
|
||||
"metricRange": "পরিসর",
|
||||
"host": "হোস্ট",
|
||||
"loading": "লোড হচ্ছে...",
|
||||
"metricCpu": "সিপিইউ",
|
||||
"metricDisk": "ডিস্ক",
|
||||
"metricHostname": "হোস্টনাম",
|
||||
"metricKernel": "কার্নেল",
|
||||
"metricMemory": "স্মৃতি",
|
||||
"metricNetwork": "নেটওয়ার্ক",
|
||||
"metricOs": "ওএস",
|
||||
"metricProcesses": "প্রক্রিয়া",
|
||||
"metricProcessesRunning": "দৌড়ানো",
|
||||
"metricProcessesTotal": "মোট",
|
||||
"metricUptime": "আপটাইম",
|
||||
"metricsNotAvailable": "মেট্রিক্স উপলব্ধ নেই",
|
||||
"noHostSelected": "কোন হোস্ট নির্বাচন করা হয়নি",
|
||||
"selectHost": "একজন হোস্ট নির্বাচন করুন..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "সিপিইউ",
|
||||
"ram": "রাম"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "ডিস্ক"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "বাতিল করুন",
|
||||
"confirmRunButton": "দৌড়",
|
||||
"confirmRunMessage": "\"{{name}} \" চালান?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "সংযুক্ত"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Encara no hi ha accions ràpides.",
|
||||
"buttonLabel": "Etiqueta del botó",
|
||||
"selectSnippetPlaceholder": "Selecciona un fragment...",
|
||||
"addActionBtn": "Afegeix una acció"
|
||||
"addActionBtn": "Afegeix una acció",
|
||||
"hostNotFound": "No s'ha trobat l'amfitrió",
|
||||
"loadingHost": "S'està carregant l'amfitrió..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Mètriques i alertes",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "No hi ha dades disponibles",
|
||||
"showCurrentValue": "Mostra el valor actual",
|
||||
"chartMetric": "Mètrica",
|
||||
"metricRange": "Rang"
|
||||
"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ó..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disc"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Cancel·la",
|
||||
"confirmRunButton": "Corre",
|
||||
"confirmRunMessage": "Executar \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connectat"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Zatím žádné rychlé akce.",
|
||||
"buttonLabel": "Štítek tlačítka",
|
||||
"selectSnippetPlaceholder": "Vybrat úryvek...",
|
||||
"addActionBtn": "Přidat akci"
|
||||
"addActionBtn": "Přidat akci",
|
||||
"hostNotFound": "Hostitel nenalezen",
|
||||
"loadingHost": "Načítání hostitele..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metriky a upozornění",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Nejsou k dispozici žádná data",
|
||||
"showCurrentValue": "Zobrazit aktuální hodnotu",
|
||||
"chartMetric": "Metrický",
|
||||
"metricRange": "Rozsah"
|
||||
"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..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "Procesor",
|
||||
"ram": "BERAN"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Zrušit",
|
||||
"confirmRunButton": "Běh",
|
||||
"confirmRunMessage": "Spustit „{{name}}“?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Připojeno"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Ingen hurtige handlinger endnu.",
|
||||
"buttonLabel": "Knapmærke",
|
||||
"selectSnippetPlaceholder": "Vælg uddrag...",
|
||||
"addActionBtn": "Tilføj handling"
|
||||
"addActionBtn": "Tilføj handling",
|
||||
"hostNotFound": "Værten blev ikke fundet",
|
||||
"loadingHost": "Indlæser vært..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Målinger og advarsler",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Ingen data tilgængelige",
|
||||
"showCurrentValue": "Vis aktuel værdi",
|
||||
"chartMetric": "Metrisk",
|
||||
"metricRange": "Rækkevidde"
|
||||
"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..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "VÆDDER"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Ophæve",
|
||||
"confirmRunButton": "Løbe",
|
||||
"confirmRunMessage": "Kør \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Forbundet"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Noch keine Schnellaktionen.",
|
||||
"buttonLabel": "Schaltflächenbeschriftung",
|
||||
"selectSnippetPlaceholder": "Snippet auswählen...",
|
||||
"addActionBtn": "Aktion hinzufügen"
|
||||
"addActionBtn": "Aktion hinzufügen",
|
||||
"hostNotFound": "Host nicht gefunden",
|
||||
"loadingHost": "Host wird geladen..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Kennzahlen und Warnmeldungen",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Keine Daten verfügbar",
|
||||
"showCurrentValue": "Aktuellen Wert anzeigen",
|
||||
"chartMetric": "Metrik",
|
||||
"metricRange": "Bereich"
|
||||
"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..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Datenträger"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Abbrechen",
|
||||
"confirmRunButton": "Ausführen",
|
||||
"confirmRunMessage": "„{{name}}“ ausführen?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Verbunden"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Δεν υπάρχουν ακόμη γρήγορες ενέργειες.",
|
||||
"buttonLabel": "Ετικέτα κουμπιού",
|
||||
"selectSnippetPlaceholder": "Επιλογή αποσπάσματος...",
|
||||
"addActionBtn": "Προσθήκη ενέργειας"
|
||||
"addActionBtn": "Προσθήκη ενέργειας",
|
||||
"hostNotFound": "Δεν βρέθηκε κεντρικός υπολογιστής",
|
||||
"loadingHost": "Φόρτωση κεντρικού υπολογιστή..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Μετρήσεις και ειδοποιήσεις",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Δεν υπάρχουν διαθέσιμα δεδομένα",
|
||||
"showCurrentValue": "Εμφάνιση τρέχουσας τιμής",
|
||||
"chartMetric": "Μετρικός",
|
||||
"metricRange": "Σειρά"
|
||||
"metricRange": "Σειρά",
|
||||
"host": "Πλήθος",
|
||||
"loading": "Φόρτωση...",
|
||||
"metricCpu": "ΚΜΕ",
|
||||
"metricDisk": "Δίσκος",
|
||||
"metricHostname": "Όνομα κεντρικού υπολογιστή",
|
||||
"metricKernel": "Πυρήνας",
|
||||
"metricMemory": "Μνήμη",
|
||||
"metricNetwork": "Δίκτυο",
|
||||
"metricOs": "Λειτουργικό σύστημα",
|
||||
"metricProcesses": "Διαδικασίες",
|
||||
"metricProcessesRunning": "τρέξιμο",
|
||||
"metricProcessesTotal": "σύνολο",
|
||||
"metricUptime": "Χρόνος λειτουργίας",
|
||||
"metricsNotAvailable": "Οι μετρήσεις δεν είναι διαθέσιμες",
|
||||
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
|
||||
"selectHost": "Επιλέξτε έναν οικοδεσπότη..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "ΚΜΕ",
|
||||
"ram": "ΕΜΒΟΛΟ"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Δίσκος"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Ματαίωση",
|
||||
"confirmRunButton": "Τρέξιμο",
|
||||
"confirmRunMessage": "Εκτέλεση \"{{name}}\";"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Συνδεδεμένος"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Aún no hay acciones rápidas.",
|
||||
"buttonLabel": "Etiqueta del botón",
|
||||
"selectSnippetPlaceholder": "Seleccionar fragmento...",
|
||||
"addActionBtn": "Añadir acción"
|
||||
"addActionBtn": "Añadir acción",
|
||||
"hostNotFound": "Host no encontrado",
|
||||
"loadingHost": "Cargando host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Métricas y alertas",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Sin datos disponibles",
|
||||
"showCurrentValue": "Mostrar valor actual",
|
||||
"chartMetric": "Métrica",
|
||||
"metricRange": "Rango"
|
||||
"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..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disco"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Cancelar",
|
||||
"confirmRunButton": "Ejecutar",
|
||||
"confirmRunMessage": "¿Ejecutar \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Conectado"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Ei vielä nopeita toimia.",
|
||||
"buttonLabel": "Painikkeen nimi",
|
||||
"selectSnippetPlaceholder": "Valitse katkelma...",
|
||||
"addActionBtn": "Lisää toiminto"
|
||||
"addActionBtn": "Lisää toiminto",
|
||||
"hostNotFound": "Isäntää ei löydy",
|
||||
"loadingHost": "Ladataan isäntää..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Mittarit ja hälytykset",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Ei tietoja saatavilla",
|
||||
"showCurrentValue": "Näytä nykyinen arvo",
|
||||
"chartMetric": "Metrinen",
|
||||
"metricRange": "Alue"
|
||||
"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ä..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "Suoritin",
|
||||
"ram": "RAM-muistia"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Levy"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Peruuttaa",
|
||||
"confirmRunButton": "Juokse",
|
||||
"confirmRunMessage": "Suorittaa \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Yhdistetty"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Aucune action rapide pour le moment.",
|
||||
"buttonLabel": "Étiquette du bouton",
|
||||
"selectSnippetPlaceholder": "Sélectionner un snippet...",
|
||||
"addActionBtn": "Ajouter une action"
|
||||
"addActionBtn": "Ajouter une action",
|
||||
"hostNotFound": "Hôte introuvable",
|
||||
"loadingHost": "Chargement de l'hôte..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Métriques et alertes",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Aucune donnée disponible",
|
||||
"showCurrentValue": "Afficher la valeur actuelle",
|
||||
"chartMetric": "Métrique",
|
||||
"metricRange": "Plage"
|
||||
"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…"
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disque"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Annuler",
|
||||
"confirmRunButton": "Exécuter",
|
||||
"confirmRunMessage": "Exécuter « {{name}} » ?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connecté"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "אין עדיין פעולות מהירות.",
|
||||
"buttonLabel": "תווית כפתור",
|
||||
"selectSnippetPlaceholder": "בחר קטע...",
|
||||
"addActionBtn": "הוסף פעולה"
|
||||
"addActionBtn": "הוסף פעולה",
|
||||
"hostNotFound": "המארח לא נמצא",
|
||||
"loadingHost": "טוען מארח..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "מדדים והתראות",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "אין נתונים זמינים",
|
||||
"showCurrentValue": "הצג ערך נוכחי",
|
||||
"chartMetric": "מֶטרִי",
|
||||
"metricRange": "לָנוּעַ"
|
||||
"metricRange": "לָנוּעַ",
|
||||
"host": "מְאָרֵחַ",
|
||||
"loading": "טְעִינָה...",
|
||||
"metricCpu": "מעבד",
|
||||
"metricDisk": "דִיסק",
|
||||
"metricHostname": "שם מארח",
|
||||
"metricKernel": "גַרעִין",
|
||||
"metricMemory": "זֵכֶר",
|
||||
"metricNetwork": "רֶשֶׁת",
|
||||
"metricOs": "מערכת הפעלה",
|
||||
"metricProcesses": "תהליכים",
|
||||
"metricProcessesRunning": "רִיצָה",
|
||||
"metricProcessesTotal": "סַך הַכֹּל",
|
||||
"metricUptime": "זמן פעולה",
|
||||
"metricsNotAvailable": "מדדים אינם זמינים",
|
||||
"noHostSelected": "לא נבחר מארח",
|
||||
"selectHost": "בחר מארח..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "מעבד",
|
||||
"ram": "אַיִל"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "דִיסק"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "לְבַטֵל",
|
||||
"confirmRunButton": "לָרוּץ",
|
||||
"confirmRunMessage": "להפעיל את \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "מְחוּבָּר"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "अभी तक कोई त्वरित कार्रवाई नहीं।",
|
||||
"buttonLabel": "बटन लेबल",
|
||||
"selectSnippetPlaceholder": "स्निपेट चुनें...",
|
||||
"addActionBtn": "कार्रवाई जोड़ें"
|
||||
"addActionBtn": "कार्रवाई जोड़ें",
|
||||
"hostNotFound": "होस्ट नहीं मिला",
|
||||
"loadingHost": "होस्ट लोड हो रहा है..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "मैट्रिक्स और अलर्ट",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "कोई डेटा उपलब्ध नहीं",
|
||||
"showCurrentValue": "वर्तमान मान दिखाएं",
|
||||
"chartMetric": "मीट्रिक",
|
||||
"metricRange": "सीमा"
|
||||
"metricRange": "सीमा",
|
||||
"host": "होस्ट",
|
||||
"loading": "लोड हो रहा है...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "डिस्क",
|
||||
"metricHostname": "होस्टनेम",
|
||||
"metricKernel": "कर्नेल",
|
||||
"metricMemory": "मेमोरी",
|
||||
"metricNetwork": "नेटवर्क",
|
||||
"metricOs": "ओएस",
|
||||
"metricProcesses": "प्रोसेस",
|
||||
"metricProcessesRunning": "चालू",
|
||||
"metricProcessesTotal": "कुल",
|
||||
"metricUptime": "अपटाइम",
|
||||
"metricsNotAvailable": "मेट्रिक्स उपलब्ध नहीं",
|
||||
"noHostSelected": "कोई होस्ट चयनित नहीं",
|
||||
"selectHost": "होस्ट चुनें..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "डिस्क"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "रद्द करें",
|
||||
"confirmRunButton": "चलाएँ",
|
||||
"confirmRunMessage": "\"{{name}}\" चलाएँ?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "कनेक्टेड"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Még nincsenek gyors intézkedések.",
|
||||
"buttonLabel": "Gomb felirata",
|
||||
"selectSnippetPlaceholder": "Válasszon részletet...",
|
||||
"addActionBtn": "Művelet hozzáadása"
|
||||
"addActionBtn": "Művelet hozzáadása",
|
||||
"hostNotFound": "Nem található gazdagép",
|
||||
"loadingHost": "Gazdagép betöltése..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrikák és riasztások",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Nincsenek elérhető adatok",
|
||||
"showCurrentValue": "Aktuális érték megjelenítése",
|
||||
"chartMetric": "Metrika",
|
||||
"metricRange": "Hatótávolság"
|
||||
"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..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Korong"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Mégsem",
|
||||
"confirmRunButton": "Fut",
|
||||
"confirmRunMessage": "Futtassa a \"{{name}} \" programot?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Csatlakoztatva"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Belum ada tindakan cepat.",
|
||||
"buttonLabel": "Label tombol",
|
||||
"selectSnippetPlaceholder": "Pilih cuplikan...",
|
||||
"addActionBtn": "Tambahkan Tindakan"
|
||||
"addActionBtn": "Tambahkan Tindakan",
|
||||
"hostNotFound": "Host tidak ditemukan",
|
||||
"loadingHost": "Memuat host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrik dan peringatan",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Tidak ada data yang tersedia",
|
||||
"showCurrentValue": "Tampilkan Nilai Saat Ini",
|
||||
"chartMetric": "Metrik",
|
||||
"metricRange": "Jangkauan"
|
||||
"metricRange": "Jangkauan",
|
||||
"host": "Tuan rumah",
|
||||
"loading": "Memuat...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Nama host",
|
||||
"metricKernel": "Inti",
|
||||
"metricMemory": "Ingatan",
|
||||
"metricNetwork": "Jaringan",
|
||||
"metricOs": "Sistem Operasi",
|
||||
"metricProcesses": "Proses",
|
||||
"metricProcessesRunning": "berlari",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Waktu aktif",
|
||||
"metricsNotAvailable": "Metrik tidak tersedia",
|
||||
"noHostSelected": "Tidak ada pembawa acara yang dipilih.",
|
||||
"selectHost": "Pilih tuan rumah..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Membatalkan",
|
||||
"confirmRunButton": "Berlari",
|
||||
"confirmRunMessage": "Jalankan \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Terhubung"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Nessuna azione rapida ancora.",
|
||||
"buttonLabel": "Etichetta pulsante",
|
||||
"selectSnippetPlaceholder": "Seleziona snippet...",
|
||||
"addActionBtn": "Aggiungi Azione"
|
||||
"addActionBtn": "Aggiungi Azione",
|
||||
"hostNotFound": "Host non trovato",
|
||||
"loadingHost": "Caricamento host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metriche e avvisi",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Nessun dato disponibile",
|
||||
"showCurrentValue": "Mostra valore corrente",
|
||||
"chartMetric": "Metrica",
|
||||
"metricRange": "Intervallo"
|
||||
"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..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disco"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Annulla",
|
||||
"confirmRunButton": "Esegui",
|
||||
"confirmRunMessage": "Eseguire \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connesso"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "クイックアクションはまだありません",
|
||||
"buttonLabel": "ボタンラベル",
|
||||
"selectSnippetPlaceholder": "スニペットを選択...",
|
||||
"addActionBtn": "アクションを追加"
|
||||
"addActionBtn": "アクションを追加",
|
||||
"hostNotFound": "ホストが見つかりません",
|
||||
"loadingHost": "ホストを読み込み中..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "指標とアラート",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "利用可能なデータはありません",
|
||||
"showCurrentValue": "現在の値を表示",
|
||||
"chartMetric": "メトリック",
|
||||
"metricRange": "範囲"
|
||||
"metricRange": "範囲",
|
||||
"host": "ホスト",
|
||||
"loading": "読み込み中...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "ディスク",
|
||||
"metricHostname": "ホスト名",
|
||||
"metricKernel": "カーネル",
|
||||
"metricMemory": "メモリ",
|
||||
"metricNetwork": "ネットワーク",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "プロセス",
|
||||
"metricProcessesRunning": "実行中",
|
||||
"metricProcessesTotal": "合計",
|
||||
"metricUptime": "稼働時間",
|
||||
"metricsNotAvailable": "メトリクスは利用できません",
|
||||
"noHostSelected": "ホストが選択されていません",
|
||||
"selectHost": "ホストを選択..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "ディスク"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "キャンセル",
|
||||
"confirmRunButton": "実行",
|
||||
"confirmRunMessage": "「{{name}}」を実行しますか?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "接続中"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "아직 빠른 작업이 없습니다.",
|
||||
"buttonLabel": "버튼 레이블",
|
||||
"selectSnippetPlaceholder": "스니펫 선택...",
|
||||
"addActionBtn": "작업 추가"
|
||||
"addActionBtn": "작업 추가",
|
||||
"hostNotFound": "호스트를 찾을 수 없습니다",
|
||||
"loadingHost": "호스트 로드 중..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "지표 및 알림",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "데이터 없음",
|
||||
"showCurrentValue": "현재 값 표시",
|
||||
"chartMetric": "메트릭",
|
||||
"metricRange": "범위"
|
||||
"metricRange": "범위",
|
||||
"host": "호스트",
|
||||
"loading": "로딩 중...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "디스크",
|
||||
"metricHostname": "호스트명",
|
||||
"metricKernel": "커널",
|
||||
"metricMemory": "메모리",
|
||||
"metricNetwork": "네트워크",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "프로세스",
|
||||
"metricProcessesRunning": "실행 중",
|
||||
"metricProcessesTotal": "전체",
|
||||
"metricUptime": "업타임",
|
||||
"metricsNotAvailable": "메트릭을 사용할 수 없습니다",
|
||||
"noHostSelected": "선택된 호스트 없음",
|
||||
"selectHost": "호스트 선택..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "디스크"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "취소",
|
||||
"confirmRunButton": "실행",
|
||||
"confirmRunMessage": "\"{{name}}\"을(를) 실행하시겠습니까?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "연결됨"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Nog geen snelle acties ondernomen.",
|
||||
"buttonLabel": "Knoplabel",
|
||||
"selectSnippetPlaceholder": "Selecteer fragment...",
|
||||
"addActionBtn": "Actie toevoegen"
|
||||
"addActionBtn": "Actie toevoegen",
|
||||
"hostNotFound": "Host niet gevonden",
|
||||
"loadingHost": "Host wordt geladen..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Statistieken en waarschuwingen",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Geen gegevens beschikbaar",
|
||||
"showCurrentValue": "Toon huidige waarde",
|
||||
"chartMetric": "Metrisch",
|
||||
"metricRange": "Bereik"
|
||||
"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..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Schijf"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Annuleren",
|
||||
"confirmRunButton": "Loop",
|
||||
"confirmRunMessage": "Run \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Aangesloten"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Ingen hurtighandlinger ennå.",
|
||||
"buttonLabel": "Knappetikett",
|
||||
"selectSnippetPlaceholder": "Velg utdrag...",
|
||||
"addActionBtn": "Legg til handling"
|
||||
"addActionBtn": "Legg til handling",
|
||||
"hostNotFound": "Verten ble ikke funnet",
|
||||
"loadingHost": "Laster inn vert ..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Målinger og varsler",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Ingen data tilgjengelig",
|
||||
"showCurrentValue": "Vis gjeldende verdi",
|
||||
"chartMetric": "Metrisk",
|
||||
"metricRange": "Spekter"
|
||||
"metricRange": "Spekter",
|
||||
"host": "Vert",
|
||||
"loading": "Laster inn...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Vertsnavn",
|
||||
"metricKernel": "Kjerne",
|
||||
"metricMemory": "Hukommelse",
|
||||
"metricNetwork": "Nettverk",
|
||||
"metricOs": "OS",
|
||||
"metricProcesses": "Prosesser",
|
||||
"metricProcessesRunning": "løping",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Oppetid",
|
||||
"metricsNotAvailable": "Målinger er ikke tilgjengelige",
|
||||
"noHostSelected": "Ingen vert valgt",
|
||||
"selectHost": "Velg en vert..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "VÆR"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Kansellere",
|
||||
"confirmRunButton": "Løp",
|
||||
"confirmRunMessage": "Kjør \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Tilkoblet"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Brak szybkich akcji.",
|
||||
"buttonLabel": "Etykieta przycisku",
|
||||
"selectSnippetPlaceholder": "Wybierz snippet...",
|
||||
"addActionBtn": "Dodaj akcję"
|
||||
"addActionBtn": "Dodaj akcję",
|
||||
"hostNotFound": "Nie znaleziono hosta",
|
||||
"loadingHost": "Ładowanie hosta..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metryki i alerty",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Brak dostępnych danych",
|
||||
"showCurrentValue": "Pokaż wartość bieżącą",
|
||||
"chartMetric": "Metryka",
|
||||
"metricRange": "Zakres"
|
||||
"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..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "Procesor",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Dysk"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Anuluj",
|
||||
"confirmRunButton": "Uruchom",
|
||||
"confirmRunMessage": "Uruchomić „{{name}}”?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Połączono"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Nenhuma ação imediata ainda.",
|
||||
"buttonLabel": "Rótulo do botão",
|
||||
"selectSnippetPlaceholder": "Selecione o trecho...",
|
||||
"addActionBtn": "Adicionar ação"
|
||||
"addActionBtn": "Adicionar ação",
|
||||
"hostNotFound": "Host não encontrado",
|
||||
"loadingHost": "Carregando host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Métricas e alertas",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Não há dados disponíveis.",
|
||||
"showCurrentValue": "Mostrar valor atual",
|
||||
"chartMetric": "Métrica",
|
||||
"metricRange": "Faixa"
|
||||
"metricRange": "Faixa",
|
||||
"host": "Hospedar",
|
||||
"loading": "Carregando...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disco",
|
||||
"metricHostname": "Nome do host",
|
||||
"metricKernel": "Kernel",
|
||||
"metricMemory": "Memória",
|
||||
"metricNetwork": "Rede",
|
||||
"metricOs": "SO",
|
||||
"metricProcesses": "Processos",
|
||||
"metricProcessesRunning": "correndo",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Tempo de atividade",
|
||||
"metricsNotAvailable": "Métricas não disponíveis",
|
||||
"noHostSelected": "Nenhum host selecionado",
|
||||
"selectHost": "Selecione um host..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "BATER"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disco"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Cancelar",
|
||||
"confirmRunButton": "Correr",
|
||||
"confirmRunMessage": "Executar \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Conectado"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Ainda não existem ações rápidas.",
|
||||
"buttonLabel": "Texto do botão",
|
||||
"selectSnippetPlaceholder": "Selecionar snippet...",
|
||||
"addActionBtn": "Adicionar Ação"
|
||||
"addActionBtn": "Adicionar Ação",
|
||||
"hostNotFound": "Host não encontrado",
|
||||
"loadingHost": "A carregar o host..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Métricas e alertas",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Sem dados disponíveis",
|
||||
"showCurrentValue": "Mostrar valor atual",
|
||||
"chartMetric": "Métrica",
|
||||
"metricRange": "Intervalo"
|
||||
"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..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disco"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Cancelar",
|
||||
"confirmRunButton": "Executar",
|
||||
"confirmRunMessage": "Executar \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Ligado"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Nicio acțiune rapidă încă.",
|
||||
"buttonLabel": "Etichetă buton",
|
||||
"selectSnippetPlaceholder": "Selectați fragmentul...",
|
||||
"addActionBtn": "Adăugați o acțiune"
|
||||
"addActionBtn": "Adăugați o acțiune",
|
||||
"hostNotFound": "Gazdă negăsită",
|
||||
"loadingHost": "Se încarcă gazda..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Metrici și alerte",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Nu sunt disponibile date",
|
||||
"showCurrentValue": "Afișează valoarea curentă",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Gamă"
|
||||
"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ă..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disc"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Anula",
|
||||
"confirmRunButton": "Aleargă",
|
||||
"confirmRunMessage": "Executați „{{name}}”?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Conectat"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Пока нет быстрых действий.",
|
||||
"buttonLabel": "Надпись кнопки",
|
||||
"selectSnippetPlaceholder": "Выбрать сниппет...",
|
||||
"addActionBtn": "Добавить действие"
|
||||
"addActionBtn": "Добавить действие",
|
||||
"hostNotFound": "Хост не найден",
|
||||
"loadingHost": "Загрузка хоста..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Метрики и оповещения",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Нет данных",
|
||||
"showCurrentValue": "Показать текущее значение",
|
||||
"chartMetric": "Метрика",
|
||||
"metricRange": "Диапазон"
|
||||
"metricRange": "Диапазон",
|
||||
"host": "Хост",
|
||||
"loading": "Загрузка...",
|
||||
"metricCpu": "ЦП",
|
||||
"metricDisk": "Диск",
|
||||
"metricHostname": "Имя хоста",
|
||||
"metricKernel": "Ядро",
|
||||
"metricMemory": "Память",
|
||||
"metricNetwork": "Сеть",
|
||||
"metricOs": "ОС",
|
||||
"metricProcesses": "Процессы",
|
||||
"metricProcessesRunning": "запущено",
|
||||
"metricProcessesTotal": "всего",
|
||||
"metricUptime": "Время работы",
|
||||
"metricsNotAvailable": "Метрики недоступны",
|
||||
"noHostSelected": "Хост не выбран",
|
||||
"selectHost": "Выберите хост..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Диск"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Отмена",
|
||||
"confirmRunButton": "Запустить",
|
||||
"confirmRunMessage": "Запустить \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Подключено"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Још нема брзих акција.",
|
||||
"buttonLabel": "Ознака дугмета",
|
||||
"selectSnippetPlaceholder": "Изаберите исечак...",
|
||||
"addActionBtn": "Додај акцију"
|
||||
"addActionBtn": "Додај акцију",
|
||||
"hostNotFound": "Хост није пронађен",
|
||||
"loadingHost": "Учитавање хоста..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Метрике и упозорења",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Нема доступних података",
|
||||
"showCurrentValue": "Прикажи тренутну вредност",
|
||||
"chartMetric": "Метрика",
|
||||
"metricRange": "Домет"
|
||||
"metricRange": "Домет",
|
||||
"host": "Домаћин",
|
||||
"loading": "Учитавање...",
|
||||
"metricCpu": "Процесор",
|
||||
"metricDisk": "Диск",
|
||||
"metricHostname": "Име хоста",
|
||||
"metricKernel": "Језгро",
|
||||
"metricMemory": "Памћење",
|
||||
"metricNetwork": "Мрежа",
|
||||
"metricOs": "ОС",
|
||||
"metricProcesses": "Процеси",
|
||||
"metricProcessesRunning": "трчање",
|
||||
"metricProcessesTotal": "укупно",
|
||||
"metricUptime": "Време непрекидног рада",
|
||||
"metricsNotAvailable": "Метрике нису доступне",
|
||||
"noHostSelected": "Није изабран домаћин",
|
||||
"selectHost": "Изаберите хост..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "Процесор",
|
||||
"ram": "РАМ меморија"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Диск"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Откажи",
|
||||
"confirmRunButton": "Трчи",
|
||||
"confirmRunMessage": "Покрени „{{name}}“?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Повезано"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Inga snabba åtgärder ännu.",
|
||||
"buttonLabel": "Knappetikett",
|
||||
"selectSnippetPlaceholder": "Välj utdrag...",
|
||||
"addActionBtn": "Lägg till åtgärd"
|
||||
"addActionBtn": "Lägg till åtgärd",
|
||||
"hostNotFound": "Värden hittades inte",
|
||||
"loadingHost": "Laddar värd..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Mätvärden och varningar",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Inga data tillgängliga",
|
||||
"showCurrentValue": "Visa aktuellt värde",
|
||||
"chartMetric": "Metrisk",
|
||||
"metricRange": "Räckvidd"
|
||||
"metricRange": "Räckvidd",
|
||||
"host": "Värd",
|
||||
"loading": "Belastning...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "Disk",
|
||||
"metricHostname": "Värdnamn",
|
||||
"metricKernel": "Kärna",
|
||||
"metricMemory": "Minne",
|
||||
"metricNetwork": "Nätverk",
|
||||
"metricOs": "operativsystem",
|
||||
"metricProcesses": "Processer",
|
||||
"metricProcessesRunning": "spring",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricUptime": "Drifttid",
|
||||
"metricsNotAvailable": "Mätvärden är inte tillgängliga",
|
||||
"noHostSelected": "Ingen värd vald",
|
||||
"selectHost": "Välj en värd..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM-minne"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Avboka",
|
||||
"confirmRunButton": "Sikt",
|
||||
"confirmRunMessage": "Kör \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Ansluten"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "ยังไม่มีการดำเนินการใดๆ อย่างเร่งด่วนในขณะนี้",
|
||||
"buttonLabel": "ป้ายกำกับปุ่ม",
|
||||
"selectSnippetPlaceholder": "เลือกส่วนย่อย...",
|
||||
"addActionBtn": "เพิ่มการดำเนินการ"
|
||||
"addActionBtn": "เพิ่มการดำเนินการ",
|
||||
"hostNotFound": "ไม่พบโฮสต์",
|
||||
"loadingHost": "กำลังโหลดโฮสต์..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "ตัวชี้วัดและการแจ้งเตือน",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "ไม่มีข้อมูล",
|
||||
"showCurrentValue": "แสดงมูลค่าปัจจุบัน",
|
||||
"chartMetric": "เมตริก",
|
||||
"metricRange": "พิสัย"
|
||||
"metricRange": "พิสัย",
|
||||
"host": "เจ้าภาพ",
|
||||
"loading": "กำลังโหลด...",
|
||||
"metricCpu": "ซีพียู",
|
||||
"metricDisk": "ดิสก์",
|
||||
"metricHostname": "ชื่อโฮสต์",
|
||||
"metricKernel": "เคอร์เนล",
|
||||
"metricMemory": "หน่วยความจำ",
|
||||
"metricNetwork": "เครือข่าย",
|
||||
"metricOs": "โอเอส",
|
||||
"metricProcesses": "กระบวนการ",
|
||||
"metricProcessesRunning": "วิ่ง",
|
||||
"metricProcessesTotal": "ทั้งหมด",
|
||||
"metricUptime": "เวลาใช้งาน",
|
||||
"metricsNotAvailable": "ข้อมูลตัวชี้วัดไม่พร้อมใช้งาน",
|
||||
"noHostSelected": "ไม่ได้เลือกโฮสต์",
|
||||
"selectHost": "เลือกโฮสต์..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "ซีพียู",
|
||||
"ram": "แรม"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "ดิสก์"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "ยกเลิก",
|
||||
"confirmRunButton": "วิ่ง",
|
||||
"confirmRunMessage": "เรียกใช้ \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "เชื่อมต่อแล้ว"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Henüz hızlı eylem yok.",
|
||||
"buttonLabel": "Düğme etiketi",
|
||||
"selectSnippetPlaceholder": "Parçacık seç...",
|
||||
"addActionBtn": "Eylem Ekle"
|
||||
"addActionBtn": "Eylem Ekle",
|
||||
"hostNotFound": "Host bulunamadı",
|
||||
"loadingHost": "Host yükleniyor..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Ölçümler ve uyarılar",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Veri yok",
|
||||
"showCurrentValue": "Geçerli Değeri Göster",
|
||||
"chartMetric": "Metrik",
|
||||
"metricRange": "Aralık"
|
||||
"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..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Disk"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "İptal",
|
||||
"confirmRunButton": "Çalıştır",
|
||||
"confirmRunMessage": "\"{{name}}\" çalıştırılsın mı?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Bağlandı"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Поки що жодних швидких дій.",
|
||||
"buttonLabel": "Мітка кнопки",
|
||||
"selectSnippetPlaceholder": "Вибрати фрагмент...",
|
||||
"addActionBtn": "Додати дію"
|
||||
"addActionBtn": "Додати дію",
|
||||
"hostNotFound": "Хост не знайдено",
|
||||
"loadingHost": "Завантаження хоста..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Метрики та сповіщення",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Дані недоступні",
|
||||
"showCurrentValue": "Показати поточне значення",
|
||||
"chartMetric": "Метрика",
|
||||
"metricRange": "Діапазон"
|
||||
"metricRange": "Діапазон",
|
||||
"host": "Хост",
|
||||
"loading": "Завантаження...",
|
||||
"metricCpu": "Процесор",
|
||||
"metricDisk": "Диск",
|
||||
"metricHostname": "Ім'я хоста",
|
||||
"metricKernel": "Ядро",
|
||||
"metricMemory": "Пам'ять",
|
||||
"metricNetwork": "Мережа",
|
||||
"metricOs": "ОС",
|
||||
"metricProcesses": "Процеси",
|
||||
"metricProcessesRunning": "біг",
|
||||
"metricProcessesTotal": "загальна сума",
|
||||
"metricUptime": "Час безперебійної роботи",
|
||||
"metricsNotAvailable": "Метрики недоступні",
|
||||
"noHostSelected": "Хост не вибрано",
|
||||
"selectHost": "Виберіть хоста..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "Процесор",
|
||||
"ram": "Оперативна пам'ять"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Диск"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Скасувати",
|
||||
"confirmRunButton": "Бігти",
|
||||
"confirmRunMessage": "Запустити \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Підключено"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "Chưa có hành động nhanh nào.",
|
||||
"buttonLabel": "Nhãn nút",
|
||||
"selectSnippetPlaceholder": "Chọn đoạn mã...",
|
||||
"addActionBtn": "Thêm hành động"
|
||||
"addActionBtn": "Thêm hành động",
|
||||
"hostNotFound": "Không tìm thấy máy chủ",
|
||||
"loadingHost": "Đang tải máy chủ..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "Số liệu và cảnh báo",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "Không có dữ liệu",
|
||||
"showCurrentValue": "Hiển thị giá trị hiện tại",
|
||||
"chartMetric": "Chỉ số",
|
||||
"metricRange": "Phạm vi"
|
||||
"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ủ..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "Ổ đĩa"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "Hủy",
|
||||
"confirmRunButton": "Chạy",
|
||||
"confirmRunMessage": "Chạy \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Đã kết nối"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "尚无快速操作。",
|
||||
"buttonLabel": "按钮标签",
|
||||
"selectSnippetPlaceholder": "选择代码片段...",
|
||||
"addActionBtn": "添加操作"
|
||||
"addActionBtn": "添加操作",
|
||||
"hostNotFound": "未找到主机",
|
||||
"loadingHost": "正在加载主机..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "指标与告警",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "暂无数据",
|
||||
"showCurrentValue": "显示当前值",
|
||||
"chartMetric": "指标",
|
||||
"metricRange": "范围"
|
||||
"metricRange": "范围",
|
||||
"host": "主机",
|
||||
"loading": "加载中...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "磁盘",
|
||||
"metricHostname": "主机名",
|
||||
"metricKernel": "内核",
|
||||
"metricMemory": "内存",
|
||||
"metricNetwork": "网络",
|
||||
"metricOs": "操作系统",
|
||||
"metricProcesses": "进程",
|
||||
"metricProcessesRunning": "运行中",
|
||||
"metricProcessesTotal": "总数",
|
||||
"metricUptime": "运行时间",
|
||||
"metricsNotAvailable": "指标不可用",
|
||||
"noHostSelected": "未选择主机",
|
||||
"selectHost": "选择主机..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "内存"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "磁盘"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "取消",
|
||||
"confirmRunButton": "运行",
|
||||
"confirmRunMessage": "运行“{{name}}”?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "已连接"
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,9 @@
|
||||
"noQuickActions": "尚無快速動作。",
|
||||
"buttonLabel": "按鈕標籤",
|
||||
"selectSnippetPlaceholder": "選取程式碼片段...",
|
||||
"addActionBtn": "新增動作"
|
||||
"addActionBtn": "新增動作",
|
||||
"hostNotFound": "找不到主機",
|
||||
"loadingHost": "正在載入主機..."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_metrics": "指標和警報",
|
||||
@@ -255,6 +257,41 @@
|
||||
"noData": "無可用資料",
|
||||
"showCurrentValue": "顯示目前值",
|
||||
"chartMetric": "指標",
|
||||
"metricRange": "範圍"
|
||||
"metricRange": "範圍",
|
||||
"host": "主機",
|
||||
"loading": "載入中...",
|
||||
"metricCpu": "CPU",
|
||||
"metricDisk": "磁碟",
|
||||
"metricHostname": "主機名稱",
|
||||
"metricKernel": "核心",
|
||||
"metricMemory": "記憶體",
|
||||
"metricNetwork": "網路",
|
||||
"metricOs": "作業系統",
|
||||
"metricProcesses": "程序",
|
||||
"metricProcessesRunning": "執行中",
|
||||
"metricProcessesTotal": "總數",
|
||||
"metricUptime": "運作時間",
|
||||
"metricsNotAvailable": "效能指標無法使用",
|
||||
"noHostSelected": "未選取主機",
|
||||
"selectHost": "選擇主機..."
|
||||
},
|
||||
"dashboard": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"disk": "磁碟"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"snippets": {
|
||||
"cancel": "取消",
|
||||
"confirmRunButton": "執行",
|
||||
"confirmRunMessage": "執行 \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "已連線"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "host-metrics",
|
||||
"name": "Host Metrics",
|
||||
"version": "1.0.0",
|
||||
"description": "Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard, Tailscale).",
|
||||
"description": "Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard).",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
@@ -20,12 +20,15 @@
|
||||
"ssh:connect",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"events:core",
|
||||
"ui:surface"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"automations": "^1.0.0"
|
||||
},
|
||||
"provides": [
|
||||
{
|
||||
"service": "host-metrics.viewers",
|
||||
"version": "1.0.0",
|
||||
"permission": "host-metrics.use"
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
@@ -35,14 +38,98 @@
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use",
|
||||
"titleKey": "permissions.use.title",
|
||||
"descriptionKey": "permissions.use.description",
|
||||
"defaultRoles": ["admin", "user"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"admin": [
|
||||
{
|
||||
"key": "historyRetention",
|
||||
"type": "custom",
|
||||
"component": "retention"
|
||||
"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": [
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* One-line hand-off from the metrics poller onto the ctx.events bus.
|
||||
*
|
||||
* The poller is already a very large module, so the hooks it calls live here
|
||||
* instead. Everything is fire-and-forget: a failure in a subscriber must never
|
||||
* disturb metric collection, and publishing onto the bus creates no static edge
|
||||
* to the automations layer (which reads repositories the metrics module also
|
||||
* pulls in).
|
||||
*
|
||||
* For the generic (non-metrics) internal event notifier, see
|
||||
* `hosts/automation-events.ts` -- that one is shared across features.
|
||||
*/
|
||||
|
||||
import {
|
||||
pluginEvents,
|
||||
TOPICS,
|
||||
} from "../../../../src/backend/plugins/events.js";
|
||||
import type { MetricsSnapshot } from "../../../automations/src/backend/conditions.js";
|
||||
|
||||
export interface HostMetricsPayload {
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
metrics: MetricsSnapshot;
|
||||
}
|
||||
|
||||
export interface HostStatusPayload {
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface HostHealthCheckPayload {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export function notifyAutomationMetrics(
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
metrics: MetricsSnapshot,
|
||||
): void {
|
||||
if (!ownerUserId) return;
|
||||
pluginEvents.emit(TOPICS.hostMetrics, {
|
||||
hostId,
|
||||
ownerUserId,
|
||||
metrics,
|
||||
} satisfies HostMetricsPayload);
|
||||
}
|
||||
|
||||
export function notifyAutomationStatus(
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
online: boolean,
|
||||
): void {
|
||||
if (!ownerUserId) return;
|
||||
pluginEvents.emit(TOPICS.hostStatus, {
|
||||
hostId,
|
||||
ownerUserId,
|
||||
online,
|
||||
} satisfies HostStatusPayload);
|
||||
}
|
||||
|
||||
export function notifyAutomationHealthCheck(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
checkId: string,
|
||||
ok: boolean,
|
||||
detail?: string,
|
||||
): void {
|
||||
if (!userId) return;
|
||||
pluginEvents.emit(TOPICS.hostHealthCheck, {
|
||||
hostId,
|
||||
userId,
|
||||
checkId,
|
||||
ok,
|
||||
detail,
|
||||
} satisfies HostHealthCheckPayload);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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),
|
||||
memory: await collectMemoryMetrics(client, platform),
|
||||
disk: await collectDiskMetrics(
|
||||
client,
|
||||
mounts.excludedMounts,
|
||||
mounts.monitoredMounts,
|
||||
platform,
|
||||
),
|
||||
network: await collectNetworkMetrics(client, platform, host.id),
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,51 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { pluginSsh } from "./ssh.js";
|
||||
import type { PluginSsh, PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type StatsCapableHost = {
|
||||
connectionType?: string;
|
||||
authType?: string;
|
||||
/** 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;
|
||||
terminalConfig?: { sudoPassword?: string | null } | null;
|
||||
enableDocker?: boolean | null;
|
||||
jumpHosts?: Array<{ hostId: number }> | null;
|
||||
};
|
||||
|
||||
export type TcpPingStatsConfig = {
|
||||
statusCheckEnabled: boolean;
|
||||
disableTcpPing?: boolean;
|
||||
};
|
||||
|
||||
export function supportsMetrics(host: StatsCapableHost): boolean {
|
||||
/** 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") return false;
|
||||
return pluginSsh().supportsBackground(host.authType || "none");
|
||||
if (connectionType !== "ssh" && host.enableSsh !== true) return false;
|
||||
return ssh.supportsBackground(host.authType || "none");
|
||||
}
|
||||
|
||||
export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
|
||||
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
|
||||
export function sudoPasswordOf(host: MetricsHost): string | undefined {
|
||||
return host.sudoPassword || host.terminalConfig?.sudoPassword || undefined;
|
||||
}
|
||||
|
||||
export function parseStatusHostIds(value: unknown): Set<number> | null {
|
||||
if (value === undefined) return null;
|
||||
if (typeof value !== "string") return new Set();
|
||||
export type ConnectionLogType = "info" | "success" | "warning" | "error";
|
||||
|
||||
return new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map(Number)
|
||||
.filter((id) => Number.isSafeInteger(id) && id > 0),
|
||||
);
|
||||
export interface ConnectionLog {
|
||||
type: ConnectionLogType;
|
||||
stage: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function tcpPingThroughJumpHost(
|
||||
jumpClient: Pick<Client, "forwardOut" | "end">,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
/** 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 } : {}) };
|
||||
}
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
jumpClient.end();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
|
||||
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
|
||||
stream?.destroy();
|
||||
finish(!error && !!stream);
|
||||
});
|
||||
});
|
||||
export function newSessionId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import { createCurrentHostMetricsHistoryRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
|
||||
|
||||
type HistoryRoutesDeps = {
|
||||
validateHostId: RequestHandler;
|
||||
canAccessHost: (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
level: HostAction,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const RANGE_OFFSETS: Record<string, number> = {
|
||||
"1h": 1 * 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,
|
||||
};
|
||||
|
||||
export function registerHostMetricsHistoryRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, canAccessHost }: HistoryRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /metrics/history/{id}:
|
||||
* get:
|
||||
* summary: Get historical metrics for a host
|
||||
* 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
|
||||
* format: date-time
|
||||
* - in: query
|
||||
* name: to
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of metric history rows.
|
||||
* 403:
|
||||
* description: Access denied.
|
||||
* 404:
|
||||
* description: Host not found or no access.
|
||||
*/
|
||||
app.get("/metrics/history/:id", validateHostId, async (req, res) => {
|
||||
const hostId = Number(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const hasAccess = await canAccessHost(userId, hostId, "connect");
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
const { range, from, to } = req.query as Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
let fromTs: string;
|
||||
let toTs: string = new Date().toISOString();
|
||||
|
||||
if (range) {
|
||||
const offsetMs = RANGE_OFFSETS[range];
|
||||
if (!offsetMs) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
|
||||
}
|
||||
fromTs = new Date(Date.now() - offsetMs).toISOString();
|
||||
} else if (from && to) {
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return res.status(400).json({ error: "Invalid from/to date format" });
|
||||
}
|
||||
fromTs = fromDate.toISOString();
|
||||
toTs = toDate.toISOString();
|
||||
} else {
|
||||
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
// SQLite CURRENT_TIMESTAMP stores 'YYYY-MM-DD HH:MM:SS' (no T/Z).
|
||||
// Normalize both sides to that format for reliable string comparison.
|
||||
const toSqlite = (iso: string) =>
|
||||
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
|
||||
const rows = (
|
||||
await createCurrentHostMetricsHistoryRepository().listRange(
|
||||
hostId,
|
||||
toSqlite(fromTs),
|
||||
toSqlite(toTs),
|
||||
)
|
||||
).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, toTs });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to fetch metrics history", {
|
||||
operation: "metrics_history_fetch_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch metrics history" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,34 +1,108 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import {
|
||||
onHostDeleted,
|
||||
onHostLogin,
|
||||
onHostUpdated,
|
||||
shutdown,
|
||||
startHostMetricsService,
|
||||
} from "./routes.js";
|
||||
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 } 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) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
await startHostMetricsService(ctx.http.router());
|
||||
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();
|
||||
});
|
||||
|
||||
// Core used to POST these to this plugin's own port with an internal auth
|
||||
// token. They are events now: no port, no shared secret, and the
|
||||
// subscriptions are torn down with the plugin.
|
||||
ctx.events.on("host.updated", (payload) => {
|
||||
void onHostUpdated(payload as Parameters<typeof onHostUpdated>[0]);
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) void poller.hostUpdated(hostId);
|
||||
});
|
||||
ctx.events.on("host.deleted", (payload) => {
|
||||
onHostDeleted(payload as Parameters<typeof onHostDeleted>[0]);
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) poller.hostDeleted(hostId);
|
||||
});
|
||||
ctx.events.on("host.login", (payload) => {
|
||||
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
|
||||
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.log.info("Host Metrics mounted at /plugin-api/host-metrics");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
shutdown();
|
||||
}
|
||||
export async function deactivate() {}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import type { PluginSsh } 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"),
|
||||
);
|
||||
}
|
||||
ssh
|
||||
.openTransport(host, config as Record<string, unknown>)
|
||||
.then((transport) => {
|
||||
if (transport.jumpClient) {
|
||||
const jumpClient = transport.jumpClient as Client;
|
||||
client.on("close", () => jumpClient.end());
|
||||
}
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execCommand,
|
||||
shellSingleQuote,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface CronEntry {
|
||||
@@ -78,14 +80,12 @@ export function buildApplyCrontabCommand(body: string): string {
|
||||
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
|
||||
}
|
||||
|
||||
export function registerCronRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
export function registerCronRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
|
||||
managerHandler(deps, "connect", "cron_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
|
||||
return { entries: parseCrontab(stdout) };
|
||||
}),
|
||||
@@ -95,7 +95,7 @@ export function registerCronRoutes(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"cron_replace",
|
||||
async (client, _host, req) => {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { Express } from "express";
|
||||
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { execElevated, detectPlatform } from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidPort,
|
||||
isValidIpProtocol,
|
||||
isValidFirewallTarget,
|
||||
type IpProtocol,
|
||||
type FirewallTarget,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
} 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 {
|
||||
@@ -45,13 +44,14 @@ export function buildNftRuleCommand(
|
||||
}
|
||||
|
||||
export function registerFirewallRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/firewall/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
|
||||
managerHandler(deps, "connect", "firewall_read", async (client) => {
|
||||
return await collectFirewallMetrics(client);
|
||||
}),
|
||||
);
|
||||
@@ -60,7 +60,7 @@ export function registerFirewallRoutes(
|
||||
"/host-metrics/managers/firewall/:id/rule",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"firewall_rule",
|
||||
async (client, host, req) => {
|
||||
@@ -105,7 +105,7 @@ export function registerFirewallRoutes(
|
||||
"/host-metrics/managers/firewall/:id/persist",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"firewall_persist",
|
||||
async (client, host) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Express } from "express";
|
||||
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 type { AuthenticatedRequest } from "../../../../../src/types/index.js";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { createCurrentHostHealthRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { isValidPort } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { notifyAutomationHealthCheck } from "../automation-bridge.js";
|
||||
import type { HostMetricsRepository } from "../repository.js";
|
||||
|
||||
export interface HealthCheck {
|
||||
id: string;
|
||||
@@ -111,24 +111,12 @@ async function runChecks(
|
||||
|
||||
const HISTORY_KEEP = 500;
|
||||
|
||||
function recordHistory(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
results: HealthResult[],
|
||||
): Promise<void> {
|
||||
return createCurrentHostHealthRepository()
|
||||
.recordHistory(userId, hostId, results, HISTORY_KEEP)
|
||||
.then(() => undefined);
|
||||
}
|
||||
|
||||
async function loadChecks(
|
||||
repository: HostMetricsRepository,
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HealthCheck[]> {
|
||||
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
const row = await repository.findChecks(userId, hostId);
|
||||
if (!row?.checks) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.checks);
|
||||
@@ -139,60 +127,50 @@ async function loadChecks(
|
||||
}
|
||||
|
||||
export function registerHealthRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
app.get(
|
||||
"/host-metrics/managers/health/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"health_get",
|
||||
async (client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const checks = await loadChecks(userId, host.id);
|
||||
const results = checks.length ? await runChecks(client, checks) : [];
|
||||
if (results.length) {
|
||||
await recordHistory(userId, host.id, results);
|
||||
for (const r of results) {
|
||||
notifyAutomationHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const history = (
|
||||
await createCurrentHostHealthRepository().listHistory(
|
||||
userId,
|
||||
host.id,
|
||||
200,
|
||||
)
|
||||
).map((row) => ({
|
||||
checkId: row.checkId,
|
||||
ts: row.ts,
|
||||
ok: row.ok,
|
||||
latencyMs: row.latencyMs,
|
||||
detail: row.detail,
|
||||
}));
|
||||
return { checks, results, history };
|
||||
},
|
||||
),
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/health/:id/config",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"health_config",
|
||||
async (_client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const userId = host.actorId;
|
||||
const { checks, intervalSeconds } = req.body as {
|
||||
checks?: unknown;
|
||||
intervalSeconds?: number;
|
||||
@@ -206,13 +184,11 @@ export function registerHealthRoutes(
|
||||
intervalSeconds <= 86400
|
||||
? Math.round(intervalSeconds)
|
||||
: 300;
|
||||
const now = new Date().toISOString();
|
||||
await createCurrentHostHealthRepository().upsertChecks(
|
||||
await repository.saveChecks(
|
||||
userId,
|
||||
host.id,
|
||||
JSON.stringify(checks),
|
||||
interval,
|
||||
now,
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
@@ -222,28 +198,11 @@ export function registerHealthRoutes(
|
||||
app.post(
|
||||
"/host-metrics/managers/health/:id/run",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"health_run",
|
||||
async (client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const checks = await loadChecks(userId, host.id);
|
||||
const results = await runChecks(client, checks);
|
||||
if (results.length) {
|
||||
await recordHistory(userId, host.id, results);
|
||||
for (const r of results) {
|
||||
notifyAutomationHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { results };
|
||||
},
|
||||
),
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Express } from "express";
|
||||
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
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";
|
||||
@@ -19,10 +19,10 @@ import { registerWireGuardRoutes } from "./wireguard.js";
|
||||
* prefix on the stats app. All routes are on-demand (not polled).
|
||||
*/
|
||||
export function registerManagerRoutes(
|
||||
app: Express,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId, runOnHost } = deps;
|
||||
const { validateHostId } = deps;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
@@ -44,7 +44,7 @@ export function registerManagerRoutes(
|
||||
app.get(
|
||||
"/host-metrics/platform/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "platform_detect", (client) =>
|
||||
managerHandler(deps, "connect", "platform_detect", (client) =>
|
||||
detectPlatform(client),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execCommand,
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
} 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 {
|
||||
isAllowedPath,
|
||||
isValidSystemdUnit,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
/** Directories from which arbitrary log files may be tailed. */
|
||||
@@ -44,14 +41,12 @@ export function buildJournalCommand(unit: string, lines: number): string {
|
||||
return `journalctl -u ${shellSingleQuote(unit)} -n ${lines} --no-pager`;
|
||||
}
|
||||
|
||||
export function registerLogRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
export function registerLogRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id/files",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "logs_list", async (client) => {
|
||||
managerHandler(deps, "connect", "logs_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
|
||||
const found = stdout
|
||||
.split("\n")
|
||||
@@ -65,33 +60,28 @@ export function registerLogRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"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);
|
||||
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");
|
||||
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 };
|
||||
},
|
||||
),
|
||||
// Try unprivileged; many logs need root (auth.log, etc.).
|
||||
const result = await execElevated(client, cmd, host.sudoPassword);
|
||||
return { content: result.stdout, lines };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { isValidPackageName } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
import {
|
||||
execCommand,
|
||||
execElevated,
|
||||
detectPlatform,
|
||||
buildListUpgradableCommand,
|
||||
buildPackageActionCommand,
|
||||
parseUpgradable,
|
||||
type PackageAction,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js";
|
||||
} 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: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/packages/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
|
||||
managerHandler(deps, "connect", "packages_list", async (client) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const cmd = buildListUpgradableCommand(platform.pkg);
|
||||
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
|
||||
@@ -35,7 +36,7 @@ export function registerPackageRoutes(
|
||||
"/host-metrics/managers/packages/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"packages_action",
|
||||
async (client, host, req) => {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
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 {
|
||||
isValidPid,
|
||||
isValidSignal,
|
||||
type Signal,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface ProcessRow {
|
||||
@@ -54,9 +49,10 @@ export function buildKillCommand(pid: number, signal: Signal): string {
|
||||
}
|
||||
|
||||
export function registerProcessRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/processes/{id}:
|
||||
@@ -74,7 +70,7 @@ export function registerProcessRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/processes/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "processes_list", async (client) => {
|
||||
managerHandler(deps, "connect", "processes_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
|
||||
return { processes: parseProcessList(stdout) };
|
||||
}),
|
||||
@@ -109,7 +105,7 @@ export function registerProcessRoutes(
|
||||
"/host-metrics/managers/processes/:id/signal",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"processes_signal",
|
||||
async (client, host, req) => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { getErrorMessage } from "../../../../../src/backend/utils/error-message.js";
|
||||
import { ElevationError } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Request, Response } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type { AuthenticatedRequest } from "../../../../../src/types/index.js";
|
||||
import { statsLogger } from "../../../../../src/backend/utils/logger.js";
|
||||
import { ElevationError } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
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";
|
||||
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
|
||||
|
||||
export class AccessDeniedError extends Error {
|
||||
constructor(message = "No access to this host") {
|
||||
@@ -26,16 +25,15 @@ export class ManagerInputError extends Error {
|
||||
* given access level, and maps known errors to clean HTTP responses.
|
||||
*/
|
||||
export function managerHandler(
|
||||
runOnHost: RunOnHost,
|
||||
level: HostAction,
|
||||
{ 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 userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
const result = await runOnHost(hostId, userId, level, (client, host) =>
|
||||
const result = await runOnHost(hostId, level, (client, host) =>
|
||||
fn(client, host, req),
|
||||
);
|
||||
return res.json(result);
|
||||
@@ -49,13 +47,13 @@ export function managerHandler(
|
||||
if (error instanceof ElevationError) {
|
||||
return res.status(403).json({ error: error.message, code: error.code });
|
||||
}
|
||||
statsLogger.error(`Manager operation failed: ${operation}`, {
|
||||
log.error(`Manager operation failed: ${operation}`, {
|
||||
operation,
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: getErrorMessage(error, "Operation failed"),
|
||||
error: errorMessage(error, "Operation failed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidSystemdUnit,
|
||||
isValidServiceAction,
|
||||
type ServiceAction,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
} from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface SystemdService {
|
||||
@@ -50,9 +49,10 @@ export function buildServiceActionCommand(
|
||||
}
|
||||
|
||||
export function registerServiceRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/services/{id}:
|
||||
@@ -70,7 +70,7 @@ export function registerServiceRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/services/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "services_list", async (client) => {
|
||||
managerHandler(deps, "connect", "services_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
|
||||
return { services: parseServiceList(stdout) };
|
||||
}),
|
||||
@@ -105,7 +105,7 @@ export function registerServiceRoutes(
|
||||
"/host-metrics/managers/services/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"services_action",
|
||||
async (client, host, req) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
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";
|
||||
|
||||
@@ -106,13 +106,14 @@ export function parseDfMounts(output: string): MountUsage[] {
|
||||
}
|
||||
|
||||
export function registerSimpleReadRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/top-memory/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "top_memory", async (client) => {
|
||||
managerHandler(deps, "connect", "top_memory", async (client) => {
|
||||
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
|
||||
return { processes: parseTopMemory(stdout) };
|
||||
}),
|
||||
@@ -121,7 +122,7 @@ export function registerSimpleReadRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/timers/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
|
||||
managerHandler(deps, "connect", "systemd_timers", async (client) => {
|
||||
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
|
||||
return { timers: parseTimers(stdout) };
|
||||
}),
|
||||
@@ -130,7 +131,7 @@ export function registerSimpleReadRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/disk-breakdown/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
|
||||
managerHandler(deps, "connect", "disk_breakdown", async (client) => {
|
||||
const { stdout } = await execCommand(client, DF_CMD, 15000);
|
||||
return { mounts: parseDfMounts(stdout) };
|
||||
}),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execCommand,
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
detectPlatform,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidDomain,
|
||||
isValidDnsProvider,
|
||||
isAllowedPath,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
} 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";
|
||||
@@ -149,14 +149,12 @@ export function buildRevokeCommand(client: AcmeClient, name: string): string {
|
||||
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
|
||||
}
|
||||
|
||||
export function registerSslRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
export function registerSslRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/ssl/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
|
||||
managerHandler(deps, "connect", "ssl_list", async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const certs: CertInfo[] = [];
|
||||
if (platform.hasCertbot) {
|
||||
@@ -188,78 +186,68 @@ export function registerSslRoutes(
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/issue",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"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),
|
||||
};
|
||||
},
|
||||
),
|
||||
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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/renew",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"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),
|
||||
};
|
||||
},
|
||||
),
|
||||
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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -290,31 +278,26 @@ export function registerSslRoutes(
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/revoke",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"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),
|
||||
};
|
||||
},
|
||||
),
|
||||
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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,43 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { RequestHandler } from "express";
|
||||
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
|
||||
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
|
||||
* user has at least `level` access. Resolves the host (with sudoPassword) so
|
||||
* managers can elevate. Rejects with an access error if not permitted.
|
||||
* 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,
|
||||
userId: string,
|
||||
level: HostAction,
|
||||
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;
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
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 {
|
||||
isValidUsername,
|
||||
isValidGroupName,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface SystemUser {
|
||||
@@ -75,14 +71,12 @@ export function parseSudoers(output: string): string[] {
|
||||
|
||||
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
|
||||
|
||||
export function registerUserRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
export function registerUserRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/users/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "users_list", async (client) => {
|
||||
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),
|
||||
@@ -100,7 +94,7 @@ export function registerUserRoutes(
|
||||
"/host-metrics/managers/users/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"users_action",
|
||||
async (client, host, req) => {
|
||||
|
||||
File renamed without changes.
@@ -1,11 +1,11 @@
|
||||
import type { Express } from "express";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidWireGuardInterface,
|
||||
isValidWireGuardAction,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
} 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;
|
||||
@@ -117,9 +117,10 @@ export function parseWireGuardData(output: string): WireGuardData {
|
||||
}
|
||||
|
||||
export function registerWireGuardRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/wireguard/{id}:
|
||||
@@ -140,23 +141,13 @@ export function registerWireGuardRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/wireguard/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"wireguard_read",
|
||||
async (client, host) => {
|
||||
const result = await execElevated(
|
||||
client,
|
||||
PROBE_CMD,
|
||||
host.sudoPassword,
|
||||
{
|
||||
forceSudo: false,
|
||||
timeoutMs: 15000,
|
||||
},
|
||||
);
|
||||
return parseWireGuardData(result.stdout);
|
||||
},
|
||||
),
|
||||
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);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -192,7 +183,7 @@ export function registerWireGuardRoutes(
|
||||
"/host-metrics/managers/wireguard/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"wireguard_action",
|
||||
async (client, host, req) => {
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
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";
|
||||
import { clearCpuSampleCache } from "./widgets/cpu-collector.js";
|
||||
import { clearNetworkSampleCache } from "./widgets/network-collector.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();
|
||||
clearCpuSampleCache();
|
||||
clearNetworkSampleCache();
|
||||
}
|
||||
|
||||
/** 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);
|
||||
clearCpuSampleCache(hostId);
|
||||
clearNetworkSampleCache(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 = 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);
|
||||
|
||||
if (!authenticated) {
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import { createCurrentHostMetricsPreferenceRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
deriveEnabledWidgets,
|
||||
defaultLayoutFromWidgets,
|
||||
type HostMetricsLayout,
|
||||
} from "../../../../src/types/host-metrics.js";
|
||||
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
|
||||
|
||||
interface PrefStatsConfig {
|
||||
enabledWidgets?: string[];
|
||||
}
|
||||
|
||||
interface PrefHost {
|
||||
id: number;
|
||||
userId: string;
|
||||
statsConfig?: unknown;
|
||||
}
|
||||
|
||||
type HostMetricsPreferencesRoutesDeps = {
|
||||
validateHostId: RequestHandler;
|
||||
fetchHostById: (hostId: number, userId: string) => Promise<PrefHost | null>;
|
||||
parseStatsConfig: (statsConfig: unknown) => PrefStatsConfig;
|
||||
canAccessHost: (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
level: HostAction,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function registerHostMetricsPreferencesRoutes(
|
||||
app: Express,
|
||||
{
|
||||
validateHostId,
|
||||
fetchHostById,
|
||||
parseStatsConfig,
|
||||
canAccessHost,
|
||||
}: HostMetricsPreferencesRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /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 derived 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.
|
||||
* 404:
|
||||
* description: Host not found.
|
||||
*/
|
||||
app.get("/host-metrics/preferences/:id", validateHostId, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
if (!(await canAccessHost(userId, hostId, "connect"))) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const row =
|
||||
await createCurrentHostMetricsPreferenceRepository().findByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
|
||||
if (row?.layout) {
|
||||
try {
|
||||
const parsed = sanitizeLayout(JSON.parse(row.layout));
|
||||
if (parsed) return res.json({ layout: parsed });
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
const statsConfig = parseStatsConfig(host.statsConfig);
|
||||
const layout = defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
|
||||
return res.json({ layout });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to fetch host metrics preferences", {
|
||||
operation: "host_metrics_prefs_fetch_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to fetch host metrics preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/preferences/{id}:
|
||||
* post:
|
||||
* summary: Save the Host Metrics layout for a host
|
||||
* description: Persists the current user's card layout for the host and keeps statsConfig.enabledWidgets in sync (for hosts the user owns) so the mobile app keeps working.
|
||||
* 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.
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/preferences/:id",
|
||||
validateHostId,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
if (!(await canAccessHost(userId, hostId, "connect"))) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const layout = sanitizeLayout(req.body);
|
||||
if (!layout) {
|
||||
return res.status(400).json({ error: "Invalid layout" });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const layoutJson = JSON.stringify(layout);
|
||||
|
||||
await createCurrentHostMetricsPreferenceRepository().upsertLayout(
|
||||
userId,
|
||||
hostId,
|
||||
layoutJson,
|
||||
now,
|
||||
);
|
||||
|
||||
// Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the
|
||||
// user owns. stats_config is plain JSON text (not an encrypted field).
|
||||
if (host.userId === userId) {
|
||||
try {
|
||||
const current = parseStatsConfig(host.statsConfig);
|
||||
const merged = {
|
||||
...current,
|
||||
enabledWidgets: deriveEnabledWidgets(layout.slots),
|
||||
};
|
||||
await createCurrentHostMetricsPreferenceRepository().updateHostStatsConfig(
|
||||
userId,
|
||||
hostId,
|
||||
JSON.stringify(merged),
|
||||
);
|
||||
} catch (syncErr) {
|
||||
statsLogger.warn("Failed to sync enabledWidgets from layout", {
|
||||
operation: "host_metrics_prefs_sync_widgets",
|
||||
hostId,
|
||||
error:
|
||||
syncErr instanceof Error ? syncErr.message : String(syncErr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to save host metrics preferences", {
|
||||
operation: "host_metrics_prefs_save_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to save host metrics preferences" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user