feat: convert tmux monitoring into a plugin (B10)

This commit is contained in:
LukeGus committed 2026-09-23 22:42:56 -05:00
1 parent 79d77678c9
commit 708eedb861
145 files changed
+30308 -4458

No files matched your search

-15
View File
@@ -585,21 +585,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# --- tmux-monitor begin ---
location ~ ^/tmux_monitor(/.*)?$ {
proxy_pass http://127.0.0.1:30010;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# --- tmux-monitor end ---
location ^~ /serial/websocket/ {
proxy_pass http://127.0.0.1:30011/;
proxy_http_version 1.1;
-15
View File
@@ -563,21 +563,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# --- tmux-monitor begin ---
location ~ ^/tmux_monitor(/.*)?$ {
proxy_pass http://127.0.0.1:30010;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# --- tmux-monitor end ---
location ^~ /serial/websocket/ {
proxy_pass http://127.0.0.1:30011/;
proxy_http_version 1.1;
+5
View File
@@ -0,0 +1,5 @@
-- tmux_session_tags moved to the tmux-monitor plugin, whose first migration
-- renames it to p_tmux_monitor_session_tags. Dropping it here would lose
-- every saved tag, so this only stops drizzle tracking it. SELECT 1 because
-- MySQL rejects an empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -274,6 +274,13 @@
"when": 1790208810478,
"tag": "0038_quiet_karnak",
"breakpoints": true
},
{
"idx": 39,
"version": "5",
"when": 1790220944905,
"tag": "0039_condemned_shiva",
"breakpoints": true
}
]
}
+5
View File
@@ -0,0 +1,5 @@
-- tmux_session_tags moved to the tmux-monitor plugin, whose first migration
-- renames it to p_tmux_monitor_session_tags. Dropping it here would lose
-- every saved tag, so this only stops drizzle tracking it. SELECT 1 because
-- MySQL rejects an empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -281,6 +281,13 @@
"when": 1790208809860,
"tag": "0039_violet_firedrake",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1790220944274,
"tag": "0040_narrow_hellion",
"breakpoints": true
}
]
}
+5
View File
@@ -0,0 +1,5 @@
-- tmux_session_tags moved to the tmux-monitor plugin, whose first migration
-- renames it to p_tmux_monitor_session_tags. Dropping it here would lose
-- every saved tag, so this only stops drizzle tracking it. SELECT 1 because
-- MySQL rejects an empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -253,6 +253,13 @@
"when": 1790208809221,
"tag": "0035_striped_strong_guy",
"breakpoints": true
},
{
"idx": 36,
"version": "6",
"when": 1790220943638,
"tag": "0036_classy_nighthawk",
"breakpoints": true
}
]
}
+13 -8
View File
@@ -505,14 +505,16 @@ skipped, and it stays in the next snapshot (`isUnregisteredPluginTabType` in
`src/ui/plugin-host/sdk-ui.ts`. Adding to it is a contract change; removing
from it is a breaking one. Today it exports: alert, alert-dialog, badge,
button, card, checkbox, dialog, dropdown-menu, input, label, password-input,
select, select2, separator, switch, textarea, tooltip, sheet, section-card,
metric-card, charts, the card grid, `ConnectionScreen` and the connection
status helpers, `SnippetVariablesDialog`, `FullScreenAppWrapper`, the
connection log context, `TOTPDialog`, `SSHAuthDialog`, `WarpgateDialog`,
`PassphraseDialog`, `OPKSSHDialog` (until opkssh moves in Phase C),
`HostKeyVerificationDialog`, `useTabs`/`useTabsSafe`, `ActionSlot`,
`ComponentSlot`, `PluginComponent` and `FOLDER_COLORS` (the colour swatches
folders and workspaces pick from).
popover, scroll-area, select, select2, separator, skeleton, switch, textarea,
tooltip, sheet, section-card, metric-card, charts, the card grid,
`ConnectionScreen` and the connection status helpers,
`SnippetVariablesDialog`, `FullScreenAppWrapper`, the connection log context,
`TOTPDialog`, `SSHAuthDialog`, `WarpgateDialog`, `PassphraseDialog`,
`OPKSSHDialog` (until opkssh moves in Phase C), `HostKeyVerificationDialog`,
`useTabs`/`useTabsSafe`, `ActionSlot`, `ComponentSlot`, `PluginComponent` and
`FOLDER_COLORS` (the colour swatches folders and workspaces pick from).
**B10** added `popover`, `scroll-area` and `skeleton` for the tmux-monitor
plugin's session tree and its new-session/kill popovers.
**B9** added what a terminal-like surface needs from the shell: `cn`,
`useConfirmation`, `useIsMobile`, `runAdaptivePolling`; the terminal look
@@ -1186,6 +1188,9 @@ connect pipeline instead of importing ssh2 helpers from core:
since the file-manager plugin's interactive connect route and its dedicated
transfer sessions both need their own keepalive/timeout defaults rather than
falling back to the generic `"plugin"` purpose.
- **B10** added `"tmux"` to `PluginSshPurpose` the same way (core's
`SshConnectPurpose` already had it), for the tmux-monitor plugin's pooled
connection and the `tmux.sessions` service it provides to the terminal.
- **B9** added what an interactive transport needs, for the terminal:
`resolveHost(hostId, { syncId? })` (the host with secrets, by sync id first,
audited), the `"terminal"` purpose, `interactive` and `hostKeySocket` on
+30 -8
View File
@@ -163,14 +163,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`optionalAuth`, because share-link and room guests authenticate inside the
handler with their token. Guest auth itself is unchanged; review it with the
rest of the public routes.
- **B9 (ssh-terminal):** tmux has no provider yet. The terminal calls the
optional `tmux.sessions` v1 (`detect`, `attachOrCreate`, `waitForSession`)
and skips tmux attach without it (auto-tmux, the tmux monitor's attach, the
session picker). The toolbar's old host action list only ever held the
Tmux Monitor button, which is gone with it. `TmuxSessionPicker` lives in
ssh-terminal now. Owner: B10, which provides the service (the helpers are
`hosts/tmux/helper.ts`) and contributes its toolbar button to
`terminal.toolbar`.
- **B9 (ssh-terminal):** recording has no provider yet. The terminal calls the
optional `recordings.writer` v1 `open(meta)` and gets a sink with
`append(chunk)` (one call per 300 ms batch, the first starting with the
@@ -225,6 +217,36 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
fails on `src/ui/lib/host-to-ssh-host.ts` spelling `web-endpoint` (the
`pluginSettings` lookup B8 added). Not allowlisted and not introduced here.
Owner: D0 (read it through a registry or allowlist it with a reason).
- **B10 (tmux-monitor):** `enable_tmux_monitor` is still a live `ssh_data`
column; only the copy-into-plugin-settings migration shipped
(`tmux-monitor-settings-migration.ts`), the same as B6/B7's own columns.
Every core read site (`host.ts`, `host-normalizers.ts`,
`host-bulk-routes.ts`, `database.ts`'s encrypt/decrypt round trip) still
reads the column directly. Dropping it in lockstep (`schema.ts`,
`db/index.ts`, a drizzle migration per dialect, `schema:generate`) is the
same follow-up the B6/B7/B8 lines above describe. Owner: a dedicated
follow-up step, or D0.
- **B10 (tmux-monitor):** `terminalToolbar.openTmuxMonitor` and
`terminalToolbar.tmuxDetach` are dead locale keys (nothing calls
`t("terminalToolbar.openTmuxMonitor")` or `t("terminalToolbar.tmuxDetach")`
anywhere in the tree; the terminal's own live detach button uses
`terminalToolbar.detachTmux`/`detachTmuxDescription` instead). Pre-existing,
not introduced by this step; sibling dead keys
(`copyTerminalUrlAction`/`copyDockerUrlAction`/`copyHostMetricsUrlAction`,
`terminalUrlCopied`/`dockerUrlCopied`/`hostMetricsUrlCopied`) are the same
vintage, from before the generic `hosts.copiedToClipboard`/
`copyViewUrlAction` replaced them. Owner: whoever next cleans up
`src/ui/locales/en.json`, or D0.
- **B10 (tmux-monitor):** no plugin contributes an "Open Tmux Monitor" (or
"Open Docker" / "Open Tunnel" / "Open Host Metrics") quick link into
`terminal.toolbar` today; that whole quick-link row was dropped when the
toolbar was rebuilt as a generic action slot (`ai` is the only real
`terminal.toolbar` contributor, for its assistant button) and never
replaced per-feature. Adding tmux-monitor's own link is straightforward
(`app.registerSlotContribution("terminal.toolbar", ...)` plus
`app.registerAction` opening the singleton tab) but is a shared gap across
four plugins, not specific to this step. Owner: D1, or whoever revisits the
toolbar's quick-link row.
## Manual checks after 2.9.0
+2 -1
View File
@@ -612,7 +612,8 @@ export type PluginSshPurpose =
| "tunnel"
| "file-manager"
| "file-transfer"
| "terminal";
| "terminal"
| "tmux";
export type PluginSshAuthOutcome =
| { status: "ready" }
+14
View File
@@ -0,0 +1,14 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
- Tmux monitoring moved out of core's own server on port 30010 and now serves
under `/plugin-api/tmux-monitor` through core's connect pipeline.
- Owns its data: core's `tmux_session_tags` table is renamed to
`p_tmux_monitor_session_tags` on first activation, and each host's monitor
switch moves into this plugin's host settings.
- New `tmux-monitor.use` permission, given to the admin and user roles.
- Offers `tmux.sessions` to other plugins, so the terminal can attach to,
create and wait for tmux sessions without importing this plugin.
+42
View File
@@ -0,0 +1,42 @@
# Tmux Monitor
Browse and control tmux sessions, windows and panes across your hosts.
Bundled with Termix and enabled by default. It imports only
`@termix/plugin-sdk`.
## What it does
- **Overview**: sessions, windows and panes for every host with the monitor
enabled, refreshed on a poll, with per-pane CPU/memory/GPU metrics and a
search across pane scrollback.
- **Control**: create, rename and kill sessions and windows, split and kill
panes, and attach a real terminal to a pane in place.
- **Tags**: per-user labels on a session, saved per host so everyone who
monitors it can tag it differently.
- **`tmux.sessions`**: a service the ssh-terminal plugin consumes, optionally,
to detect tmux on connect, attach or create a session, and wait for a new
one to appear. The terminal keeps working without this plugin; it just
skips tmux attach.
Every command runs over a pooled connection through `ctx.ssh.withConnection`,
so it gets host key checks, auth providers, proxies and jump hosts like every
other transport.
## Layout
manifest.json id, capabilities and what this plugin contributes
src/backend/ activate(ctx), routes, the tmux exec helpers, the service
migrations/ one .sql per dialect, generated from src/backend/tables.ts
src/frontend/ the tab, host action, session tree and pane preview
locales/en.json its strings (English only; the rest are translated)
tests/backend/ vitest, node
tests/frontend/ vitest, jsdom
## Data
- `p_tmux_monitor_session_tags`, adopted from core's `tmux_session_tags`.
- Host setting `enableTmuxMonitor`, copied from the `ssh_data` column of the
same name by core's `tmux-monitor-settings-migration.ts`.
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
+103
View File
@@ -0,0 +1,103 @@
{
"hosts": {
"enableTmuxMonitor": "Enable Tmux Monitor",
"enableTmuxMonitorDesc": "Show this host in the Tmux Monitor and add its tmux actions to the sidebar",
"tabTmuxMonitor": "Tmux Monitor"
},
"nav": {
"tmuxMonitor": "Tmux Monitor"
},
"dashboard": {
"tmuxMonitor": "Tmux Monitor",
"tmuxMonitorDesc": "Monitor tmux sessions across your hosts"
},
"permissions": {
"use": {
"title": "Use Tmux Monitor",
"description": "Browse and control tmux sessions, windows and panes on hosts with the monitor enabled."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable Tmux Monitor"
}
}
},
"tmuxMonitor": {
"title": "Tmux Monitor",
"failedToLoadHosts": "Failed to load hosts",
"failedToLoad": "Failed to load tmux sessions",
"tmuxUnavailable": "tmux is not installed on this host",
"noSessions": "No tmux sessions on this host",
"noHostSelected": "No host selected",
"attached": "Attached",
"detached": "Detached",
"attach": "Attach",
"editTags": "Edit tags",
"tagsHint": "Comma-separated tags (e.g. YOLO, lab, training)",
"tagsSaved": "Tags saved",
"tagsSaveFailed": "Failed to save tags",
"searchPlaceholder": "Search output across sessions...",
"searchResults": "{{count}} matches",
"searchFailed": "Search failed",
"selectPaneHint": "Select a pane to preview its output",
"closePreview": "Close preview",
"newSession": "New session",
"newSessionHint": "Session name (letters, digits, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "Create",
"sessionCreated": "Session \"{{name}}\" created",
"sessionCreateFailed": "Failed to create session",
"splitRight": "Split right",
"splitDown": "Split down",
"splitFailed": "Failed to split pane",
"newWindow": "New window",
"windowCreateFailed": "Failed to create window",
"attachSessionTooltip": "Attach to {{session}}",
"refresh": "Refresh",
"refreshFailed": "Failed to refresh sessions",
"collapseAll": "Collapse all",
"expandAll": "Expand all",
"moreActions": "More actions",
"sessionStats": "Session stats",
"renameSessionTitle": "Rename session \"{{name}}\"",
"rename": "Rename",
"sessionRenamed": "Session renamed to \"{{name}}\"",
"sessionRenameFailed": "Failed to rename session",
"editTagsTitle": "Edit tags for \"{{name}}\"",
"killPane": "Kill pane",
"resizeTree": "Drag to resize — double-click to reset",
"reattach": "Re-attach (fixes a garbled view)",
"killWindow": "Kill window",
"killWindowTitle": "Kill window {{index}} of \"{{session}}\"?",
"killWindowBody": "Every pane and process in this window will be terminated. Killing the last window ends the session.",
"windowKillFailed": "Failed to kill window",
"statusActivity": "Activity",
"statusWindows": "Windows",
"statusPanes": "panes",
"statusTags": "Tags",
"killPaneTitle": "Kill pane {{id}}?",
"killPaneBody": "The process running in this pane will be terminated. Killing the last pane closes its window.",
"paneKillFailed": "Failed to kill pane",
"killSessionTitle": "Kill session \"{{name}}\"?",
"killSessionBody": "All windows and running processes in this session will be terminated. This cannot be undone.",
"kill": "Kill",
"sessionKilled": "Session \"{{name}}\" killed",
"sessionKillFailed": "Failed to kill session",
"hostUnreachable": "Could not connect to the host. Check that it is online and reachable.",
"noServer": "No tmux server is running on this host.",
"searchTruncated": "Partial results — search covers the last {{lines}} lines of each pane and at most {{panes}} panes.",
"closeSearchResults": "Close search results",
"retry": "Retry",
"noHosts": "No SSH hosts available",
"noHostsHint": "Enable the Tmux Monitor option on an SSH host (Host Manager, Tmux Monitor tab) to monitor its tmux sessions.",
"tmuxInstallHint": "Install it on the host with:",
"attachTooltip": "Open a terminal to {{host}}",
"attachTooltipPane": "Open a terminal to {{host}} — tmux session {{session}}",
"timeJustNow": "just now",
"timeMinutes": "{{count}}m ago",
"timeHours": "{{count}}h ago",
"timeDays": "{{count}}d ago"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux-monitor"
},
"hosts": {
"enableTmuxMonitor": "Aktiveer Tmux-monitor",
"enableTmuxMonitorDesc": "Wys hierdie gasheer in die Tmux Monitor en voeg sy tmux-aksies by die sybalk."
},
"tmuxMonitor": {
"title": "Tmux-monitor",
"failedToLoadHosts": "Kon nie gashere laai nie",
"failedToLoad": "Kon nie tmux-sessies laai nie",
"tmuxUnavailable": "tmux is nie op hierdie gasheer geïnstalleer nie",
"noSessions": "Geen tmux-sessies op hierdie gasheer nie",
"noHostSelected": "Geen gasheer gekies nie",
"attached": "Aangeheg",
"detached": "Losstaande",
"attach": "Heg aan",
"editTags": "Wysig etikette",
"tagsHint": "Komma-geskeide etikette (bv. YOLO, laboratorium, opleiding)",
"tagsSaved": "Etikette gestoor",
"tagsSaveFailed": "Kon nie etikette stoor nie",
"searchPlaceholder": "Soek uitset oor sessies heen...",
"searchResults": "{{count}} ooreenkomste",
"searchFailed": "Soektog het misluk",
"selectPaneHint": "Kies 'n paneel om die uitvoer daarvan te voorskou",
"closePreview": "Maak voorskou toe",
"newSession": "Nuwe sessie",
"newSessionHint": "Sessienaam (letters, syfers, _ @ % + = -)",
"newSessionPlaceholder": "my-sessie",
"create": "Skep",
"sessionCreated": "Sessie \"{{name}}\" geskep",
"sessionCreateFailed": "Kon nie sessie skep nie",
"splitRight": "Regs verdeel",
"splitDown": "Verdeel af",
"splitFailed": "Kon nie paneel verdeel nie",
"newWindow": "Nuwe venster",
"windowCreateFailed": "Kon nie venster skep nie",
"attachSessionTooltip": "Heg aan {{session}}",
"refresh": "Verfris",
"refreshFailed": "Kon nie sessies verfris nie",
"collapseAll": "Vou alles in",
"expandAll": "Vou alles uit",
"moreActions": "Meer aksies",
"sessionStats": "Sessiestatistieke",
"renameSessionTitle": "Hernoem sessie \"{{name}}\"",
"rename": "Hernoem",
"sessionRenamed": "Sessie hernoem na \"{{name}}\"",
"sessionRenameFailed": "Kon nie sessie hernoem nie",
"editTagsTitle": "Wysig etikette vir \"{{name}}\"",
"killPane": "Maak paneel dood",
"resizeTree": "Sleep om te verander — dubbelklik om terug te stel",
"reattach": "Herbevestig (maak 'n deurmekaar aansig reg)",
"killWindow": "Maak venster dood",
"killWindowTitle": "Maak venster {{index}} van \"{{session}} \" dood?",
"killWindowBody": "Elke paneel en proses in hierdie venster sal beëindig word. Deur die laaste venster dood te maak, word die sessie beëindig.",
"windowKillFailed": "Kon nie venster doodmaak nie",
"statusActivity": "Aktiwiteit",
"statusWindows": "Vensters",
"statusPanes": "ruite",
"statusTags": "Etikette",
"killPaneTitle": "Maak paneel dood {{id}}?",
"killPaneBody": "Die proses wat in hierdie paneel loop, sal beëindig word. As die laaste paneel doodgemaak word, word die venster daarvan toegemaak.",
"paneKillFailed": "Kon nie paneel doodmaak nie",
"killSessionTitle": "Maak sessie \"{{name}} \" dood?",
"killSessionBody": "Alle vensters en lopende prosesse in hierdie sessie sal beëindig word. Dit kan nie ongedaan gemaak word nie.",
"kill": "Doodmaak",
"sessionKilled": "Sessie \"{{name}}\" is doodgemaak",
"sessionKillFailed": "Kon nie sessie doodmaak nie",
"hostUnreachable": "Kon nie aan die gasheer koppel nie. Maak seker dat dit aanlyn en bereikbaar is.",
"noServer": "Geen tmux-bediener loop op hierdie gasheer nie.",
"searchTruncated": "Gedeeltelike resultate — soektog dek die laaste {{lines}} reëls van elke paneel en hoogstens {{panes}} panele.",
"closeSearchResults": "Maak soekresultate toe",
"retry": "Probeer weer",
"noHosts": "Geen SSH-gashere beskikbaar nie",
"noHostsHint": "Aktiveer die Tmux Monitor-opsie op 'n SSH-gasheer (Gasheerbestuurder → Terminal-oortjie) om sy tmux-sessies te monitor.",
"tmuxInstallHint": "Installeer dit op die gasheer met:",
"attachTooltip": "Maak 'n terminaal oop na {{host}}",
"attachTooltipPane": "Maak 'n terminaal oop na {{host}} — tmux sessie {{session}}",
"timeJustNow": "nou net",
"timeMinutes": "{{count}}m gelede",
"timeHours": "{{count}}uur gelede",
"timeDays": "{{count}}dae gelede"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "مراقب Tmux"
},
"hosts": {
"enableTmuxMonitor": "تمكين مراقب Tmux",
"enableTmuxMonitorDesc": "إظهار هذا المضيف في مراقب Tmux وإضافة إجراءات tmux الخاصة به إلى الشريط الجانبي"
},
"tmuxMonitor": {
"title": "مراقب Tmux",
"failedToLoadHosts": "فشل تحميل الأجهزة المضيفة",
"failedToLoad": "فشل تحميل جلسات tmux",
"tmuxUnavailable": "tmux غير مثبت على هذا الجهاز المضيف",
"noSessions": "لا توجد جلسات tmux على هذا الجهاز المضيف",
"noHostSelected": "لم يتم تحديد أي جهاز مضيف",
"attached": "متصلة",
"detached": "منفصلة",
"attach": "اتصال",
"editTags": "تعديل العلامات",
"tagsHint": "علامات مفصولة بفواصل (مثال: YOLO, lab, training)",
"tagsSaved": "تم حفظ العلامات",
"tagsSaveFailed": "فشل حفظ العلامات",
"searchPlaceholder": "بحث في المخرجات عبر الجلسات...",
"searchResults": "{{count}} مطابقة",
"searchFailed": "فشل البحث",
"selectPaneHint": "حدد إطارًا لمعاينة مخرجاته",
"closePreview": "إغلاق المعاينة",
"newSession": "جلسة جديدة",
"newSessionHint": "اسم الجلسة (حروف، أرقام، _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "إنشاء",
"sessionCreated": "تم إنشاء الجلسة \"{{name}}\"",
"sessionCreateFailed": "فشل إنشاء الجلسة",
"splitRight": "تقسيم لليمين",
"splitDown": "تقسيم للأسفل",
"splitFailed": "فشل تقسيم الإطار",
"newWindow": "نافذة جديدة",
"windowCreateFailed": "فشل إنشاء النافذة",
"attachSessionTooltip": "اتصل بـ {{session}}",
"refresh": "تحديث",
"refreshFailed": "فشل تحديث الجلسات",
"collapseAll": "طي الكل",
"expandAll": "توسيع الكل",
"moreActions": "إجراءات أخرى",
"sessionStats": "إحصائيات الجلسة",
"renameSessionTitle": "إعادة تسمية الجلسة \"{{name}}\"",
"rename": "إعادة تسمية",
"sessionRenamed": "تمت إعادة تسمية الجلسة إلى \"{{name}}\"",
"sessionRenameFailed": "فشل إعادة تسمية الجلسة",
"editTagsTitle": "تعديل العلامات لـ \"{{name}}\"",
"killPane": "إنهاء الجزء",
"resizeTree": "اسحب لتغيير الحجم — انقر نقرًا مزدوجًا لإعادة الضبط",
"reattach": "إعادة الارتباط (لإصلاح العرض المشوش)",
"killWindow": "إنهاء النافذة",
"killWindowTitle": "هل تريد إنهاء النافذة {{index}} من الجلسة \"{{session}}\"؟",
"killWindowBody": "سيتم إنهاء كل جزء وعملية في هذه النافذة. إنهاء آخر نافذة ينهي الجلسة.",
"windowKillFailed": "فشل إنهاء النافذة",
"statusActivity": "نشاط",
"statusWindows": "نوافذ",
"statusPanes": "أجزاء",
"statusTags": "وسوم",
"killPaneTitle": "هل تريد إنهاء الجزء {{id}}؟",
"killPaneBody": "سيتم إنهاء العملية الجارية في هذا الجزء. إنهاء آخر جزء يغلق نافذته.",
"paneKillFailed": "فشل إنهاء الجزء",
"killSessionTitle": "هل تريد إنهاء الجلسة \"{{name}}\"؟",
"killSessionBody": "سيتم إنهاء جميع النوافذ والعمليات الجارية في هذه الجلسة. لا يمكن التراجع عن ذلك.",
"kill": "إنهاء",
"sessionKilled": "تم إنهاء الجلسة \"{{name}}\"",
"sessionKillFailed": "فشل إنهاء الجلسة",
"hostUnreachable": "تعذر الاتصال بالمضيف. تأكد من أنه متصل وقابل للوصول.",
"noServer": "لا يوجد خادم tmux يعمل على هذا المضيف.",
"searchTruncated": "نتائج جزئية — يشمل البحث آخر {{lines}} سطرًا من كل جزء وبحد أقصى {{panes}} جزءًا.",
"closeSearchResults": "إغلاق نتائج البحث",
"retry": "إعادة المحاولة",
"noHosts": "لا توجد مضيفات SSH متاحة",
"noHostsHint": "قم بتمكين خيار Tmux Monitor على مضيف SSH (مدير المضيفات → تبويب الطرفية) لمراقبة جلسات tmux الخاصة به.",
"tmuxInstallHint": "قم بتثبيته على المضيف باستخدام:",
"attachTooltip": "افتح طرفية إلى {{host}}",
"attachTooltipPane": "افتح طرفية إلى {{host}} — جلسة tmux {{session}}",
"timeJustNow": "الآن",
"timeMinutes": "قبل {{count}}m",
"timeHours": "قبل {{count}}h",
"timeDays": "قبل {{count}}d"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux монитор"
},
"hosts": {
"enableTmuxMonitor": "Активиране на Tmux монитор",
"enableTmuxMonitorDesc": "Показване на този хост в Tmux монитора и добавяне на неговите tmux действия към страничната лента"
},
"tmuxMonitor": {
"title": "Tmux монитор",
"failedToLoadHosts": "Зареждането на хостовете не бе успешно",
"failedToLoad": "Зареждането на tmux сесиите не бе успешно",
"tmuxUnavailable": "tmux не е инсталиран на този хост",
"noSessions": "Няма tmux сесии на този хост",
"noHostSelected": "Няма избран хост",
"attached": "Прикачен",
"detached": "Отделен",
"attach": "Прикачи",
"editTags": "Редактиране на етикети",
"tagsHint": "Етикети, разделени със запетаи (напр. YOLO, лаборатория, обучение)",
"tagsSaved": "Етикетите са запазени",
"tagsSaveFailed": "Запазването на етикетите не бе успешно",
"searchPlaceholder": "Търсене на резултати в различни сесии...",
"searchResults": "{{count}} съвпадения",
"searchFailed": "Търсенето не беше успешно",
"selectPaneHint": "Изберете панел, за да прегледате резултата му",
"closePreview": "Затвори визуализацията",
"newSession": "Нова сесия",
"newSessionHint": "Име на сесията (букви, цифри, _ @ % + = -)",
"newSessionPlaceholder": "моята сесия",
"create": "Създаване",
"sessionCreated": "Сесия „{{name}}“ е създадена",
"sessionCreateFailed": "Създаването на сесия не бе успешно",
"splitRight": "Разделяне надясно",
"splitDown": "Разделете",
"splitFailed": "Разделянето на панела не бе успешно",
"newWindow": "Нов прозорец",
"windowCreateFailed": "Създаването на прозорец не бе успешно",
"attachSessionTooltip": "Прикачи към {{session}}",
"refresh": "Обновяване",
"refreshFailed": "Неуспешно обновяване на сесиите",
"collapseAll": "Свиване на всички",
"expandAll": "Разгъване на всички",
"moreActions": "Още действия",
"sessionStats": "Статистика за сесиите",
"renameSessionTitle": "Преименуване на сесия „{{name}}“",
"rename": "Преименуване",
"sessionRenamed": "Сесията е преименувана на „{{name}}“",
"sessionRenameFailed": "Преименуването на сесията не бе успешно",
"editTagsTitle": "Редактиране на етикети за „{{name}}“",
"killPane": "Панелът за убиване",
"resizeTree": "Плъзнете, за да промените размера — щракнете двукратно, за да нулирате",
"reattach": "Повторно прикрепване (коригира неправилен изглед)",
"killWindow": "Прозорец за затваряне",
"killWindowTitle": "Да завършим ли прозореца {{index}} от \"{{session}}\"?",
"killWindowBody": "Всеки панел и процес в този прозорец ще бъде прекратен. Затварянето на последния прозорец прекратява сесията.",
"windowKillFailed": "Неуспешно затваряне на прозореца",
"statusActivity": "Дейност",
"statusWindows": "Прозорци",
"statusPanes": "прозорци",
"statusTags": "Етикети",
"killPaneTitle": "Да се прекрати панелът {{id}}?",
"killPaneBody": "Процесът, изпълняващ се в този панел, ще бъде прекратен. Затварянето на последния панел затваря неговия прозорец.",
"paneKillFailed": "Неуспешно прекратяване на панела",
"killSessionTitle": "Да прекратя сесията „{{name}}“?",
"killSessionBody": "Всички прозорци и работещи процеси в тази сесия ще бъдат прекратени. Това не може да бъде отменено.",
"kill": "Убий",
"sessionKilled": "Сесия „{{name}}“ е прекратена",
"sessionKillFailed": "Неуспешно прекратяване на сесията",
"hostUnreachable": "Не можах да се свържа с хоста. Проверете дали е онлайн и достъпен.",
"noServer": "На този хост не работи tmux сървър.",
"searchTruncated": "Частични резултати — търсенето обхваща последните {{lines}} реда от всеки панел и най-много {{panes}} панела.",
"closeSearchResults": "Затвори резултатите от търсенето",
"retry": "Опитай отново",
"noHosts": "Няма налични SSH хостове",
"noHostsHint": "Активирайте опцията Tmux Monitor на SSH хост (Host Manager → раздел Terminal), за да наблюдавате неговите tmux сесии.",
"tmuxInstallHint": "Инсталирайте го на хоста с:",
"attachTooltip": "Отворете терминал до {{host}}",
"attachTooltipPane": "Отворете терминал към {{host}} — tmux сесия {{session}}",
"timeJustNow": "точно сега",
"timeMinutes": "преди {{count}}мин.",
"timeHours": "преди {{count}}ч",
"timeDays": "преди {{count}}дни"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux মনিটর"
},
"hosts": {
"enableTmuxMonitor": "Tmux মনিটর সক্রিয় করুন",
"enableTmuxMonitorDesc": "এই হোস্টটিকে Tmux মনিটরে দেখান এবং এর tmux অ্যাকশনগুলো সাইডবারে যোগ করুন।"
},
"tmuxMonitor": {
"title": "Tmux মনিটর",
"failedToLoadHosts": "হোস্ট লোড করতে ব্যর্থ হয়েছে",
"failedToLoad": "tmux সেশন লোড করতে ব্যর্থ হয়েছে",
"tmuxUnavailable": "এই হোস্টে tmux ইনস্টল করা নেই",
"noSessions": "এই হোস্টে কোনো tmux সেশন নেই",
"noHostSelected": "কোন হোস্ট নির্বাচন করা হয়নি",
"attached": "সংযুক্ত",
"detached": "বিচ্ছিন্ন",
"attach": "সংযুক্ত করুন",
"editTags": "ট্যাগ সম্পাদনা করুন",
"tagsHint": "কমা দ্বারা পৃথক করা ট্যাগ (যেমন YOLO, lab, training)",
"tagsSaved": "ট্যাগগুলি সংরক্ষিত হয়েছে",
"tagsSaveFailed": "ট্যাগ সংরক্ষণ করতে ব্যর্থ হয়েছে",
"searchPlaceholder": "বিভিন্ন সেশন জুড়ে অনুসন্ধানের ফলাফল...",
"searchResults": "{{count}} মিল",
"searchFailed": "অনুসন্ধান ব্যর্থ হয়েছে",
"selectPaneHint": "এর আউটপুট প্রিভিউ করতে একটি প্যান নির্বাচন করুন।",
"closePreview": "ক্লোজ প্রিভিউ",
"newSession": "নতুন অধিবেশন",
"newSessionHint": "সেশনের নাম (অক্ষর, সংখ্যা, _ @ % + = -)",
"newSessionPlaceholder": "আমার-সেশন",
"create": "তৈরি করুন",
"sessionCreated": "সেশন \"{{name}}\" তৈরি করা হয়েছে",
"sessionCreateFailed": "সেশন তৈরি করতে ব্যর্থ হয়েছে",
"splitRight": "ডানদিকে বিভক্ত",
"splitDown": "বিভক্ত করুন",
"splitFailed": "প্যান বিভক্ত করতে ব্যর্থ হয়েছে",
"newWindow": "নতুন জানালা",
"windowCreateFailed": "উইন্ডো তৈরি করতে ব্যর্থ হয়েছে",
"attachSessionTooltip": "{{session}} এর সাথে সংযুক্ত করুন",
"refresh": "রিফ্রেশ",
"refreshFailed": "সেশনগুলি রিফ্রেশ করতে ব্যর্থ হয়েছে",
"collapseAll": "সবগুলো সংকুচিত করুন",
"expandAll": "সবগুলো প্রসারিত করুন",
"moreActions": "আরও পদক্ষেপ",
"sessionStats": "সেশন পরিসংখ্যান",
"renameSessionTitle": "সেশনের নাম পরিবর্তন করুন \"{{name}}\"",
"rename": "পুনঃনামকরণ",
"sessionRenamed": "সেশনের নাম পরিবর্তন করে \"{{name}} \" রাখা হয়েছে",
"sessionRenameFailed": "সেশনের নাম পরিবর্তন করতে ব্যর্থ হয়েছে",
"editTagsTitle": "\"{{name}} \" এর জন্য ট্যাগ সম্পাদনা করুন",
"killPane": "কিল প্যান",
"resizeTree": "আকার পরিবর্তন করতে টেনে আনুন — রিসেট করতে ডাবল-ক্লিক করুন",
"reattach": "পুনরায় সংযুক্ত করুন (বিকৃত দৃশ্য ঠিক করে)",
"killWindow": "কিল উইন্ডো",
"killWindowTitle": "\"{{session}} \" উইন্ডোটি {{index}} বন্ধ করুন?",
"killWindowBody": "এই উইন্ডোর প্রতিটি প্যান এবং প্রসেস বন্ধ করে দেওয়া হবে। সর্বশেষ উইন্ডোটি বন্ধ করলে সেশনটি শেষ হয়ে যায়।",
"windowKillFailed": "উইন্ডো বন্ধ করতে ব্যর্থ হয়েছে",
"statusActivity": "কার্যকলাপ",
"statusWindows": "উইন্ডোজ",
"statusPanes": "প্যান",
"statusTags": "ট্যাগ",
"killPaneTitle": "প্যান বন্ধ করুন {{id}}?",
"killPaneBody": "এই প্যানে চলমান প্রসেসটি বন্ধ করে দেওয়া হবে। সর্বশেষ প্যানটি কিল করলে তার উইন্ডোটি বন্ধ হয়ে যায়।",
"paneKillFailed": "প্যানটি বন্ধ করতে ব্যর্থ হয়েছে",
"killSessionTitle": "সেশন বন্ধ করুন \"{{name}}\"?",
"killSessionBody": "এই সেশনের সমস্ত উইন্ডো এবং চলমান প্রসেস বন্ধ করে দেওয়া হবে। এটি পূর্বাবস্থায় ফেরানো যাবে না।",
"kill": "হত্যা করুন",
"sessionKilled": "সেশন \"{{name}}\" বাতিল করা হয়েছে",
"sessionKillFailed": "সেশন বন্ধ করতে ব্যর্থ হয়েছে",
"hostUnreachable": "হোস্টের সাথে সংযোগ স্থাপন করা যায়নি। এটি অনলাইন এবং অ্যাক্সেসযোগ্য আছে কিনা তা যাচাই করুন।",
"noServer": "এই হোস্টে কোনো tmux সার্ভার চালু নেই।",
"searchTruncated": "আংশিক ফলাফল — অনুসন্ধান প্রতিটি পেনের শেষ {{lines}} টি লাইন এবং সর্বাধিক {{panes}} টি পেন পর্যন্ত করা হয়।",
"closeSearchResults": "অনুসন্ধানের ফলাফল বন্ধ করুন",
"retry": "পুনরায় চেষ্টা করুন",
"noHosts": "কোন SSH হোস্ট উপলব্ধ নেই",
"noHostsHint": "একটি SSH হোস্টের tmux সেশনগুলো মনিটর করার জন্য, হোস্ট ম্যানেজার → টার্মিনাল ট্যাবে Tmux মনিটর অপশনটি সক্রিয় করুন।",
"tmuxInstallHint": "হোস্টে এটি ইনস্টল করুন:",
"attachTooltip": "{{host}} এ একটি টার্মিনাল খুলুন",
"attachTooltipPane": "টার্মিনাল খুলুন {{host}} — tmux সেশন {{session}}",
"timeJustNow": "এইমাত্র",
"timeMinutes": "{{count}}মিনিট আগে",
"timeHours": "{{count}}ঘণ্টা আগে",
"timeDays": "{{count}}দিন আগে"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Monitor Tmux"
},
"hosts": {
"enableTmuxMonitor": "Activa el monitor Tmux",
"enableTmuxMonitorDesc": "Mostra aquest host al monitor Tmux i afegeix les seves accions tmux a la barra lateral"
},
"tmuxMonitor": {
"title": "Monitor Tmux",
"failedToLoadHosts": "No s'han pogut carregar els amfitrions",
"failedToLoad": "No s'han pogut carregar les sessions tmux",
"tmuxUnavailable": "tmux no està instal·lat en aquest host",
"noSessions": "No hi ha sessions tmux en aquest host.",
"noHostSelected": "No s'ha seleccionat cap amfitrió",
"attached": "Adjuntat",
"detached": "Desconnectat",
"attach": "Adjunta",
"editTags": "Edita les etiquetes",
"tagsHint": "Etiquetes separades per comes (per exemple, YOLO, laboratori, formació)",
"tagsSaved": "Etiquetes desades",
"tagsSaveFailed": "No s'han pogut desar les etiquetes",
"searchPlaceholder": "Cerca resultats entre sessions...",
"searchResults": "coincidències {{count}}",
"searchFailed": "La cerca ha fallat",
"selectPaneHint": "Seleccioneu un panell per previsualitzar-ne el resultat",
"closePreview": "Tanca la vista prèvia",
"newSession": "Nova sessió",
"newSessionHint": "Nom de la sessió (lletres, dígits, _ @ % + = -)",
"newSessionPlaceholder": "la meva sessió",
"create": "Crea",
"sessionCreated": "S'ha creat la sessió \"{{name}}\"",
"sessionCreateFailed": "No s'ha pogut crear la sessió",
"splitRight": "Dividir a la dreta",
"splitDown": "Dividir",
"splitFailed": "No s'ha pogut dividir el panell",
"newWindow": "Nova finestra",
"windowCreateFailed": "No s'ha pogut crear la finestra",
"attachSessionTooltip": "Adjunta a {{session}}",
"refresh": "Actualitza",
"refreshFailed": "No s'han pogut actualitzar les sessions",
"collapseAll": "Replega-ho tot",
"expandAll": "Expandeix-ho tot",
"moreActions": "Més accions",
"sessionStats": "Estadístiques de sessió",
"renameSessionTitle": "Canvia el nom de la sessió \"{{name}}\"",
"rename": "Canvia el nom",
"sessionRenamed": "La sessió s'ha rebatejat com a \"{{name}}\"",
"sessionRenameFailed": "No s'ha pogut canviar el nom de la sessió",
"editTagsTitle": "Edita les etiquetes per a \"{{name}}\"",
"killPane": "Tauler de tancament",
"resizeTree": "Arrossega per canviar la mida — fes doble clic per restablir",
"reattach": "Torna a connectar (corregeix una vista distorsionada)",
"killWindow": "Finestra de tancament",
"killWindowTitle": "Finestra de tancament {{index}} de \"{{session}}\"?",
"killWindowBody": "Tots els panells i processos d'aquesta finestra finalitzaran. Tancar l'última finestra finalitza la sessió.",
"windowKillFailed": "No s'ha pogut tancar la finestra",
"statusActivity": "Activitat",
"statusWindows": "Finestres",
"statusPanes": "panells",
"statusTags": "Etiquetes",
"killPaneTitle": "Tanca el panell {{id}}?",
"killPaneBody": "El procés que s'executa en aquest panell finalitzarà. Si tanqueu l'últim panell, se'n tanca la finestra.",
"paneKillFailed": "No s'ha pogut tancar el panell",
"killSessionTitle": "Tancar sessió \"{{name}}\"?",
"killSessionBody": "Totes les finestres i els processos en execució d'aquesta sessió es tancaran. Això no es pot desfer.",
"kill": "Matar",
"sessionKilled": "La sessió \"{{name}}\" ha estat cancel·lada",
"sessionKillFailed": "No s'ha pogut tancar la sessió",
"hostUnreachable": "No s'ha pogut connectar amb l'amfitrió. Comproveu que estigui en línia i accessible.",
"noServer": "No hi ha cap servidor tmux en execució en aquest host.",
"searchTruncated": "Resultats parcials: la cerca cobreix les últimes {{lines}} línies de cada panell i com a màxim {{panes}} panells.",
"closeSearchResults": "Tanca els resultats de la cerca",
"retry": "Torna-ho a intentar",
"noHosts": "No hi ha hosts SSH disponibles",
"noHostsHint": "Activeu l'opció Monitor de Tmux en un host SSH (Gestor d'amfitrions → pestanya Terminal) per monitoritzar les seves sessions tmux.",
"tmuxInstallHint": "Instal·leu-lo a l'amfitrió amb:",
"attachTooltip": "Obre un terminal a {{host}}",
"attachTooltipPane": "Obre un terminal a {{host}} — sessió tmux {{session}}",
"timeJustNow": "ara mateix",
"timeMinutes": "fa {{count}}minuts",
"timeHours": "fa {{count}}h",
"timeDays": "fa {{count}}dies"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Monitor Tmux"
},
"hosts": {
"enableTmuxMonitor": "Povolit monitorování Tmux",
"enableTmuxMonitorDesc": "Zobrazit tohoto hostitele v Tmux Monitoru a přidat jeho tmux akce do postranního panelu"
},
"tmuxMonitor": {
"title": "Monitor Tmux",
"failedToLoadHosts": "Nepodařilo se načíst hostitele",
"failedToLoad": "Nepodařilo se načíst relace tmux",
"tmuxUnavailable": "tmux není na tomto hostiteli nainstalován",
"noSessions": "Na tomto hostiteli nejsou žádné relace tmux.",
"noHostSelected": "Nebyl vybrán žádný hostitel",
"attached": "Připojeno",
"detached": "Samostatně stojící",
"attach": "Připojit",
"editTags": "Upravit štítky",
"tagsHint": "Štítky oddělené čárkami (např. YOLO, lab, training)",
"tagsSaved": "Štítky uloženy",
"tagsSaveFailed": "Uložení štítků se nezdařilo",
"searchPlaceholder": "Vyhledávání výstupu napříč relacemi...",
"searchResults": "{{count}} shod",
"searchFailed": "Hledání se nezdařilo",
"selectPaneHint": "Vyberte podokno pro náhled jeho výstupu",
"closePreview": "Zavřít náhled",
"newSession": "Nová relace",
"newSessionHint": "Název relace (písmena, číslice, _ @ % + = -)",
"newSessionPlaceholder": "moje relace",
"create": "Vytvořit",
"sessionCreated": "Relace „{{name}}“ byla vytvořena",
"sessionCreateFailed": "Nepodařilo se vytvořit relaci",
"splitRight": "Rozdělit vpravo",
"splitDown": "Rozdělit",
"splitFailed": "Rozdělení panelu se nezdařilo",
"newWindow": "Nové okno",
"windowCreateFailed": "Nepodařilo se vytvořit okno",
"attachSessionTooltip": "Připojit k {{session}}",
"refresh": "Obnovit",
"refreshFailed": "Nepodařilo se obnovit relace",
"collapseAll": "Sbalit vše",
"expandAll": "Rozbalit vše",
"moreActions": "Další akce",
"sessionStats": "Statistiky relací",
"renameSessionTitle": "Přejmenovat relaci „{{name}}“",
"rename": "Přejmenovat",
"sessionRenamed": "Relace přejmenována na „{{name}}“",
"sessionRenameFailed": "Přejmenování relace se nezdařilo",
"editTagsTitle": "Upravit štítky pro „{{name}}“",
"killPane": "Zavřete panel",
"resizeTree": "Přetažením změníte velikost – dvojitým kliknutím resetujete",
"reattach": "Znovu připevnit (opravuje zkreslený obraz)",
"killWindow": "Okno pro ukončení",
"killWindowTitle": "Zavřít okno {{index}} z \"{{session}}\"?",
"killWindowBody": "Každé podokno a proces v tomto okně bude ukončeno. Zavřením posledního okna se relace ukončí.",
"windowKillFailed": "Nepodařilo se ukončit okno",
"statusActivity": "Aktivita",
"statusWindows": "Okna",
"statusPanes": "tabule",
"statusTags": "Štítky",
"killPaneTitle": "Zavřít panel {{id}}?",
"killPaneBody": "Proces spuštěný v tomto panelu bude ukončen. Ukončením posledního panelu se zavře jeho okno.",
"paneKillFailed": "Nepodařilo se ukončit podokno",
"killSessionTitle": "Ukončit relaci „{{name}}“?",
"killSessionBody": "Všechna okna a spuštěné procesy v této relaci budou ukončeny. Tuto akci nelze vrátit zpět.",
"kill": "Zabít",
"sessionKilled": "Relace „{{name}}“ byla ukončena",
"sessionKillFailed": "Nepodařilo se ukončit relaci",
"hostUnreachable": "Nepodařilo se připojit k hostiteli. Zkontrolujte, zda je online a dostupný.",
"noServer": "Na tomto hostiteli neběží žádný tmux server.",
"searchTruncated": "Částečné výsledky – vyhledávání pokrývá posledních {{lines}} řádků každého panelu a maximálně {{panes}} panelů.",
"closeSearchResults": "Zavřít výsledky vyhledávání",
"retry": "Zkusit znovu",
"noHosts": "Žádní SSH hostitelé nejsou k dispozici",
"noHostsHint": "Povolte možnost Monitor Tmux na hostiteli SSH (Správce hostitelů → záložka Terminál) pro sledování jeho relací tmux.",
"tmuxInstallHint": "Nainstalujte jej na hostitele pomocí:",
"attachTooltip": "Otevřete terminál pro {{host}}",
"attachTooltipPane": "Otevřete terminál pro {{host}} — relace tmux {{session}}",
"timeJustNow": "právě teď",
"timeMinutes": "{{count}}před m",
"timeHours": "{{count}}před hodinami",
"timeDays": "Před {{count}}dny"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux-skærm"
},
"hosts": {
"enableTmuxMonitor": "Aktivér Tmux-skærm",
"enableTmuxMonitorDesc": "Vis denne vært i Tmux Monitor og tilføj dens tmux-handlinger til sidebjælken"
},
"tmuxMonitor": {
"title": "Tmux-skærm",
"failedToLoadHosts": "Kunne ikke indlæse værter",
"failedToLoad": "Kunne ikke indlæse tmux-sessioner",
"tmuxUnavailable": "tmux er ikke installeret på denne vært",
"noSessions": "Ingen tmux-sessioner på denne vært",
"noHostSelected": "Ingen vært valgt",
"attached": "Vedhæftet",
"detached": "Fritliggende",
"attach": "Vedhæft",
"editTags": "Rediger tags",
"tagsHint": "Kommaseparerede tags (f.eks. YOLO, lab, træning)",
"tagsSaved": "Tags gemt",
"tagsSaveFailed": "Kunne ikke gemme tags",
"searchPlaceholder": "Søg i output på tværs af sessioner...",
"searchResults": "{{count}} matcher",
"searchFailed": "Søgningen mislykkedes",
"selectPaneHint": "Vælg en rude for at få vist dens output",
"closePreview": "Luk forhåndsvisning",
"newSession": "Ny session",
"newSessionHint": "Sessionsnavn (bogstaver, cifre, _ @ % + = -)",
"newSessionPlaceholder": "min-session",
"create": "Skabe",
"sessionCreated": "Sessionen \"{{name}}\" blev oprettet",
"sessionCreateFailed": "Kunne ikke oprette sessionen",
"splitRight": "Del højre",
"splitDown": "Opdel ned",
"splitFailed": "Kunne ikke opdele ruden",
"newWindow": "Nyt vindue",
"windowCreateFailed": "Kunne ikke oprette vindue",
"attachSessionTooltip": "Vedhæft til {{session}}",
"refresh": "Opfriske",
"refreshFailed": "Kunne ikke opdatere sessioner",
"collapseAll": "Skjul alle",
"expandAll": "Udvid alle",
"moreActions": "Flere handlinger",
"sessionStats": "Sessionsstatistik",
"renameSessionTitle": "Omdøb sessionen \"{{name}}\"",
"rename": "Omdøb",
"sessionRenamed": "Sessionen er omdøbt til \"{{name}}\"",
"sessionRenameFailed": "Kunne ikke omdøbe sessionen",
"editTagsTitle": "Rediger tags for \"{{name}}\"",
"killPane": "Dræb ruden",
"resizeTree": "Træk for at ændre størrelse — dobbeltklik for at nulstille",
"reattach": "Genmonter (rettelser på en forvrænget visning)",
"killWindow": "Dræb vinduet",
"killWindowTitle": "Luk vindue {{index}} af \"{{session}}\"?",
"killWindowBody": "Alle ruder og processer i dette vindue vil blive afsluttet. Hvis det sidste vindue lukkes, afsluttes sessionen.",
"windowKillFailed": "Kunne ikke lukke vinduet",
"statusActivity": "Aktivitet",
"statusWindows": "Vinduer",
"statusPanes": "ruder",
"statusTags": "Tags",
"killPaneTitle": "Afbryd rude {{id}}?",
"killPaneBody": "Processen, der kører i denne rude, vil blive afsluttet. Hvis du afslutter den sidste rude, lukkes dens vindue.",
"paneKillFailed": "Kunne ikke afslutte ruden",
"killSessionTitle": "Afslut sessionen \"{{name}}\"?",
"killSessionBody": "Alle vinduer og kørende processer i denne session vil blive afsluttet. Dette kan ikke fortrydes.",
"kill": "Dræbe",
"sessionKilled": "Sessionen \"{{name}}\" blev afbrudt",
"sessionKillFailed": "Kunne ikke afslutte sessionen",
"hostUnreachable": "Kunne ikke oprette forbindelse til værten. Kontroller, at den er online og tilgængelig.",
"noServer": "Der kører ingen tmux-server på denne vært.",
"searchTruncated": "Delvise resultater — søgningen dækker de sidste {{lines}} linjer i hver rude og højst {{panes}} ruder.",
"closeSearchResults": "Luk søgeresultater",
"retry": "Prøv igen",
"noHosts": "Ingen SSH-værter tilgængelige",
"noHostsHint": "Aktivér Tmux Monitor-indstillingen på en SSH-vært (Værtsadministrator → Terminal-fanen) for at overvåge dens tmux-sessioner.",
"tmuxInstallHint": "Installer det på værten med:",
"attachTooltip": "Åbn en terminal til {{host}}",
"attachTooltipPane": "Åbn en terminal til {{host}} — tmux-session {{session}}",
"timeJustNow": "lige nu",
"timeMinutes": "For {{count}}minutter siden",
"timeHours": "For {{count}}timer siden",
"timeDays": "for {{count}}dage siden"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux-Monitor"
},
"hosts": {
"enableTmuxMonitor": "Tmux-Monitor aktivieren",
"enableTmuxMonitorDesc": "Diesen Host im Tmux-Monitor anzeigen und seine tmux-Aktionen zur Seitenleiste hinzufügen"
},
"tmuxMonitor": {
"title": "Tmux-Monitor",
"failedToLoadHosts": "Laden der Hosts fehlgeschlagen",
"failedToLoad": "Laden der tmux-Sitzungen fehlgeschlagen",
"tmuxUnavailable": "tmux ist auf diesem Host nicht installiert",
"noSessions": "Keine tmux-Sitzungen auf diesem Host",
"noHostSelected": "Kein Host ausgewählt",
"attached": "Verbunden",
"detached": "Getrennt",
"attach": "Verbinden",
"editTags": "Tags bearbeiten",
"tagsHint": "Durch Kommas getrennte Tags (z. B. YOLO, lab, training)",
"tagsSaved": "Tags gespeichert",
"tagsSaveFailed": "Fehler beim Speichern der Tags",
"searchPlaceholder": "Ausgabe in allen Sitzungen durchsuchen...",
"searchResults": "{{count}} Treffer",
"searchFailed": "Suche fehlgeschlagen",
"selectPaneHint": "Wählen Sie einen Bereich aus, um dessen Ausgabe anzuzeigen",
"closePreview": "Vorschau schließen",
"newSession": "Neue Sitzung",
"newSessionHint": "Sitzungsname (Buchstaben, Ziffern, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "Erstellen",
"sessionCreated": "Sitzung \"{{name}}\" erstellt",
"sessionCreateFailed": "Fehler beim Erstellen der Sitzung",
"splitRight": "Rechts teilen",
"splitDown": "Unten teilen",
"splitFailed": "Fehler beim Teilen des Bereichs",
"newWindow": "Neues Fenster",
"windowCreateFailed": "Fehler beim Erstellen des Fensters",
"attachSessionTooltip": "Mit {{session}} verbinden",
"refresh": "Aktualisieren",
"refreshFailed": "Fehler beim Aktualisieren der Sitzungen",
"collapseAll": "Alle einklappen",
"expandAll": "Alle ausklappen",
"moreActions": "Weitere Aktionen",
"sessionStats": "Sitzungsstatistiken",
"renameSessionTitle": "Sitzung \"{{name}}\" umbenennen",
"rename": "Umbenennen",
"sessionRenamed": "Sitzung umbenannt in \"{{name}}\"",
"sessionRenameFailed": "Fehler beim Umbenennen der Sitzung",
"editTagsTitle": "Tags für \"{{name}}\" bearbeiten",
"killPane": "Pane beenden",
"resizeTree": "Ziehen zum Ändern der Größe — Doppelklick zum Zurücksetzen",
"reattach": "Erneut anhängen (behebt eine verzerrte Darstellung)",
"killWindow": "Fenster beenden",
"killWindowTitle": "Fenster {{index}} von \"{{session}}\" beenden?",
"killWindowBody": "Alle Panes und Prozesse in diesem Fenster werden beendet. Wird das letzte Fenster beendet, wird die Sitzung beendet.",
"windowKillFailed": "Fenster konnte nicht beendet werden",
"statusActivity": "Aktivität",
"statusWindows": "Fenster",
"statusPanes": "Panes",
"statusTags": "Tags",
"killPaneTitle": "Pane {{id}} beenden?",
"killPaneBody": "Der in dieser Pane laufende Prozess wird beendet. Wird die letzte Pane beendet, wird das Fenster geschlossen.",
"paneKillFailed": "Pane konnte nicht beendet werden",
"killSessionTitle": "Sitzung \"{{name}}\" beenden?",
"killSessionBody": "Alle Fenster und laufenden Prozesse in dieser Sitzung werden beendet. Dies kann nicht rückgängig gemacht werden.",
"kill": "Beenden",
"sessionKilled": "Sitzung \"{{name}}\" beendet",
"sessionKillFailed": "Sitzung konnte nicht beendet werden",
"hostUnreachable": "Verbindung zum Host konnte nicht hergestellt werden. Überprüfen Sie, ob er online und erreichbar ist.",
"noServer": "Auf diesem Host läuft kein tmux-Server.",
"searchTruncated": "Teilergebnisse — die Suche umfasst die letzten {{lines}} Zeilen jeder Pane und maximal {{panes}} Panes.",
"closeSearchResults": "Suchergebnisse schließen",
"retry": "Erneut versuchen",
"noHosts": "Keine SSH-Hosts verfügbar",
"noHostsHint": "Aktivieren Sie die Option Tmux Monitor für einen SSH-Host (Host-Manager → Terminal-Tab), um dessen tmux-Sitzungen zu überwachen.",
"tmuxInstallHint": "Installieren Sie es auf dem Host mit:",
"attachTooltip": "Terminal zu {{host}} öffnen",
"attachTooltipPane": "Terminal zu {{host}} öffnen — tmux-Sitzung {{session}}",
"timeJustNow": "gerade eben",
"timeMinutes": "vor {{count}} Min.",
"timeHours": "vor {{count}} Std.",
"timeDays": "vor {{count}} T."
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Παρακολούθηση Tmux"
},
"hosts": {
"enableTmuxMonitor": "Ενεργοποίηση παρακολούθησης Tmux",
"enableTmuxMonitorDesc": "Εμφάνιση αυτού του κεντρικού υπολογιστή στην οθόνη Tmux και προσθήκη των ενεργειών tmux του στην πλαϊνή μπάρα"
},
"tmuxMonitor": {
"title": "Παρακολούθηση Tmux",
"failedToLoadHosts": "Αποτυχία φόρτωσης κεντρικών υπολογιστών",
"failedToLoad": "Αποτυχία φόρτωσης συνεδριών tmux",
"tmuxUnavailable": "Το tmux δεν είναι εγκατεστημένο σε αυτόν τον κεντρικό υπολογιστή",
"noSessions": "Δεν υπάρχουν συνεδρίες tmux σε αυτόν τον κεντρικό υπολογιστή",
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
"attached": "Συνημμένο",
"detached": "Απομονωμένος",
"attach": "Συνδέω",
"editTags": "Επεξεργασία ετικετών",
"tagsHint": "Ετικέτες διαχωρισμένες με κόμμα (π.χ. YOLO, εργαστήριο, εκπαίδευση)",
"tagsSaved": "Οι ετικέτες αποθηκεύτηκαν",
"tagsSaveFailed": "Η αποθήκευση των ετικετών απέτυχε",
"searchPlaceholder": "Αναζήτηση εξόδου σε όλες τις συνεδρίες...",
"searchResults": "{{count}} αγώνες",
"searchFailed": "Η αναζήτηση απέτυχε",
"selectPaneHint": "Επιλέξτε ένα παράθυρο για να κάνετε προεπισκόπηση της εξόδου του",
"closePreview": "Κλείσιμο προεπισκόπησης",
"newSession": "Νέα συνεδρία",
"newSessionHint": "Όνομα συνεδρίας (γράμματα, ψηφία, _ @ % + = -)",
"newSessionPlaceholder": "η-συνεδρία μου",
"create": "Δημιουργώ",
"sessionCreated": "Συνεδρία \"{{name}}\" δημιουργήθηκε",
"sessionCreateFailed": "Αποτυχία δημιουργίας συνεδρίας",
"splitRight": "Διαίρεση δεξιά",
"splitDown": "Διαχωρισμός",
"splitFailed": "Αποτυχία διαίρεσης παραθύρου",
"newWindow": "Νέο παράθυρο",
"windowCreateFailed": "Η δημιουργία παραθύρου απέτυχε",
"attachSessionTooltip": "Επισύναψη σε {{session}}",
"refresh": "Φρεσκάρω",
"refreshFailed": "Η ανανέωση των περιόδων σύνδεσης απέτυχε",
"collapseAll": "Σύμπτυξη όλων",
"expandAll": "Ανάπτυξη όλων",
"moreActions": "Περισσότερες ενέργειες",
"sessionStats": "Στατιστικά περιόδου σύνδεσης",
"renameSessionTitle": "Μετονομασία συνεδρίας \"{{name}}\"",
"rename": "Μετονομάζω",
"sessionRenamed": "Η συνεδρία μετονομάστηκε σε \"{{name}}\"",
"sessionRenameFailed": "Η μετονομασία της συνεδρίας απέτυχε",
"editTagsTitle": "Επεξεργασία ετικετών για \"{{name}}\"",
"killPane": "Κλείσιμο παραθύρου",
"resizeTree": "Σύρετε για αλλαγή μεγέθους — κάντε διπλό κλικ για επαναφορά",
"reattach": "Επανασύνδεση (διορθώνει μια παραμορφωμένη προβολή)",
"killWindow": "Σκότωμα παραθύρου",
"killWindowTitle": "Να τερματιστεί το παράθυρο {{index}} του \"{{session}}\";",
"killWindowBody": "Κάθε παράθυρο και διεργασία σε αυτό το παράθυρο θα τερματιστεί. Η διακοπή του τελευταίου παραθύρου τερματίζει την περίοδο λειτουργίας.",
"windowKillFailed": "Αποτυχία τερματισμού του παραθύρου",
"statusActivity": "Δραστηριότητα",
"statusWindows": "Παράθυρα",
"statusPanes": "τζάμια",
"statusTags": "Ετικέτες",
"killPaneTitle": "Κλείσιμο παραθύρου {{id}}?",
"killPaneBody": "Η διεργασία που εκτελείται σε αυτό το παράθυρο θα τερματιστεί. Ο τερματισμός του τελευταίου παραθύρου κλείνει το παράθυρό του.",
"paneKillFailed": "Αποτυχία τερματισμού του παραθύρου",
"killSessionTitle": "Διακοπή συνεδρίας \"{{name}}\";",
"killSessionBody": "Όλα τα παράθυρα και οι διεργασίες που εκτελούνται σε αυτήν την περίοδο λειτουργίας θα τερματιστούν. Αυτό δεν μπορεί να αναιρεθεί.",
"kill": "Σκοτώνω",
"sessionKilled": "Η συνεδρία \"{{name}}\" τερματίστηκε",
"sessionKillFailed": "Αποτυχία τερματισμού της συνεδρίας",
"hostUnreachable": "Δεν ήταν δυνατή η σύνδεση με τον κεντρικό υπολογιστή. Ελέγξτε ότι είναι συνδεδεμένος στο διαδίκτυο και προσβάσιμος.",
"noServer": "Δεν εκτελείται διακομιστής tmux σε αυτόν τον κεντρικό υπολογιστή.",
"searchTruncated": "Μερικά αποτελέσματα — η αναζήτηση καλύπτει τις τελευταίες {{lines}} γραμμές κάθε παραθύρου και το πολύ {{panes}} παραθύρους.",
"closeSearchResults": "Κλείσιμο αποτελεσμάτων αναζήτησης",
"retry": "Δοκιμάζω πάλι",
"noHosts": "Δεν υπάρχουν διαθέσιμοι κεντρικοί υπολογιστές SSH",
"noHostsHint": "Ενεργοποιήστε την επιλογή Tmux Monitor σε έναν κεντρικό υπολογιστή SSH (Καρτέλα Host Manager → Terminal) για να παρακολουθείτε τις συνεδρίες tmux του.",
"tmuxInstallHint": "Εγκαταστήστε το στον κεντρικό υπολογιστή με:",
"attachTooltip": "Ανοίξτε ένα τερματικό για να {{host}}",
"attachTooltipPane": "Ανοίξτε ένα τερματικό για να {{host}} — συνεδρία tmux {{session}}",
"timeJustNow": "μόλις τώρα",
"timeMinutes": "{{count}}πριν από λεπτά",
"timeHours": "{{count}}πριν από ώρες",
"timeDays": "{{count}}πριν από ημέρες"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Monitor de Tmux"
},
"hosts": {
"enableTmuxMonitor": "Activar Tmux Monitor",
"enableTmuxMonitorDesc": "Mostrar este host en el Tmux Monitor y añadir sus acciones de tmux a la barra lateral."
},
"tmuxMonitor": {
"title": "Monitor Tmux",
"failedToLoadHosts": "Error al cargar los hosts",
"failedToLoad": "Error al cargar las sesiones de tmux",
"tmuxUnavailable": "tmux no está instalado en este host",
"noSessions": "No hay sesiones de tmux en este host",
"noHostSelected": "Ningún host seleccionado",
"attached": "Conectado",
"detached": "Desconectado",
"attach": "Conectar",
"editTags": "Editar etiquetas",
"tagsHint": "Etiquetas separadas por comas (ej. YOLO, lab, training)",
"tagsSaved": "Etiquetas guardadas",
"tagsSaveFailed": "Error al guardar etiquetas",
"searchPlaceholder": "Buscar en la salida de todas las sesiones...",
"searchResults": "{{count}} coincidencias",
"searchFailed": "Búsqueda fallida",
"selectPaneHint": "Seleccione un panel para previsualizar su salida",
"closePreview": "Cerrar previsualización",
"newSession": "Nueva sesión",
"newSessionHint": "Nombre de la sesión (letras, dígitos, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "Crear",
"sessionCreated": "Sesión \"{{name}}\" creada",
"sessionCreateFailed": "Error al crear la sesión",
"splitRight": "Dividir a la derecha",
"splitDown": "Dividir abajo",
"splitFailed": "Error al dividir el panel",
"newWindow": "Nueva ventana",
"windowCreateFailed": "Error al crear la ventana",
"attachSessionTooltip": "Conectar a {{session}}",
"refresh": "Actualizar",
"refreshFailed": "Error al actualizar las sesiones",
"collapseAll": "Colapsar todo",
"expandAll": "Expandir todo",
"moreActions": "Más acciones",
"sessionStats": "Estadísticas de la sesión",
"renameSessionTitle": "Renombrar sesión \"{{name}}\"",
"rename": "Renombrar",
"sessionRenamed": "Sesión renombrada a \"{{name}}\"",
"sessionRenameFailed": "Error al renombrar la sesión",
"editTagsTitle": "Editar etiquetas para \"{{name}}\"",
"killPane": "Finalizar panel",
"resizeTree": "Arrastrar para redimensionar — doble clic para restablecer",
"reattach": "Reconectar (soluciona una vista distorsionada)",
"killWindow": "Finalizar ventana",
"killWindowTitle": "¿Finalizar ventana {{index}} de \"{{session}}\"?",
"killWindowBody": "Todos los paneles y procesos de esta ventana serán terminados. Finalizar la última ventana finaliza la sesión.",
"windowKillFailed": "Error al finalizar la ventana",
"statusActivity": "Actividad",
"statusWindows": "Ventanas",
"statusPanes": "paneles",
"statusTags": "Etiquetas",
"killPaneTitle": "¿Finalizar panel {{id}}?",
"killPaneBody": "El proceso en ejecución en este panel será terminado. Finalizar el último panel cierra su ventana.",
"paneKillFailed": "Error al finalizar el panel",
"killSessionTitle": "¿Finalizar sesión \"{{name}}\"?",
"killSessionBody": "Todas las ventanas y procesos en ejecución de esta sesión serán terminados. Esta acción no se puede deshacer.",
"kill": "Finalizar",
"sessionKilled": "Sesión \"{{name}}\" finalizada",
"sessionKillFailed": "Error al finalizar la sesión",
"hostUnreachable": "No se pudo conectar al host. Compruebe que esté en línea y accesible.",
"noServer": "No hay ningún servidor tmux en ejecución en este host.",
"searchTruncated": "Resultados parciales — la búsqueda cubre las últimas {{lines}} líneas de cada panel y un máximo de {{panes}} paneles.",
"closeSearchResults": "Cerrar resultados de búsqueda",
"retry": "Reintentar",
"noHosts": "No hay hosts SSH disponibles",
"noHostsHint": "Habilite la opción Tmux Monitor en un host SSH (Gestor de hosts → pestaña Terminal) para monitorizar sus sesiones tmux.",
"tmuxInstallHint": "Instálelo en el host con:",
"attachTooltip": "Abrir un terminal en {{host}}",
"attachTooltipPane": "Abrir un terminal en {{host}} — sesión tmux {{session}}",
"timeJustNow": "ahora mismo",
"timeMinutes": "hace {{count}}m",
"timeHours": "hace {{count}}h",
"timeDays": "hace {{count}}d"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux-näyttö"
},
"hosts": {
"enableTmuxMonitor": "Ota Tmux-monitori käyttöön",
"enableTmuxMonitorDesc": "Näytä tämä isäntä Tmux-monitorissa ja lisää sen tmux-toiminnot sivupalkkiin"
},
"tmuxMonitor": {
"title": "Tmux-näyttö",
"failedToLoadHosts": "Isäntien lataaminen epäonnistui",
"failedToLoad": "tmux-istuntojen lataaminen epäonnistui",
"tmuxUnavailable": "tmuxia ei ole asennettu tälle koneelle",
"noSessions": "Ei tmux-istuntoja tällä isännöinnillä",
"noHostSelected": "Ei valittua isäntää",
"attached": "Liitteenä",
"detached": "Erillinen",
"attach": "Liittää",
"editTags": "Muokkaa tunnisteita",
"tagsHint": "Pilkuilla erotetut tunnisteet (esim. YOLO, lab, training)",
"tagsSaved": "Tagit tallennettu",
"tagsSaveFailed": "Tunnisteiden tallentaminen epäonnistui",
"searchPlaceholder": "Hae tulostetta istuntojen välillä...",
"searchResults": "{{count}} osumaa",
"searchFailed": "Haku epäonnistui",
"selectPaneHint": "Valitse ruutu esikatsellaksesi sen tulosta",
"closePreview": "Sulje esikatselu",
"newSession": "Uusi istunto",
"newSessionHint": "Istunnon nimi (kirjaimet, numerot, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "Luoda",
"sessionCreated": "Istunto \"{{name}}\" luotu",
"sessionCreateFailed": "Istunnon luominen epäonnistui",
"splitRight": "Jaa oikealle",
"splitDown": "Jaa alas",
"splitFailed": "Ruudun jakaminen epäonnistui",
"newWindow": "Uusi ikkuna",
"windowCreateFailed": "Ikkunan luominen epäonnistui",
"attachSessionTooltip": "Liitä kohtaan {{session}}",
"refresh": "Päivitä",
"refreshFailed": "Istuntojen päivittäminen epäonnistui",
"collapseAll": "Pienennä kaikki",
"expandAll": "Laajenna kaikki",
"moreActions": "Lisää toimintoja",
"sessionStats": "Istuntotilastot",
"renameSessionTitle": "Nimeä istunto uudelleen \"{{name}}\"",
"rename": "Nimeä uudelleen",
"sessionRenamed": "Istunto nimettiin uudelleen muotoon \"{{name}}\"",
"sessionRenameFailed": "Istunnon uudelleennimeäminen epäonnistui",
"editTagsTitle": "Muokkaa tunnisteita kohteelle \"{{name}}\"",
"killPane": "Tappaa ruutu",
"resizeTree": "Muuta kokoa vetämällä – kaksoisnapsauttamalla voit palauttaa sen",
"reattach": "Kiinnitä uudelleen (korjaa vääristyneen näkymän)",
"killWindow": "Tapa ikkuna",
"killWindowTitle": "Tappaa ikkuna {{index}} kohteelle \"{{session}}\"?",
"killWindowBody": "Jokainen tässä ikkunassa oleva ruutu ja prosessi lopetetaan. Viimeisen ikkunan sulkeminen lopettaa istunnon.",
"windowKillFailed": "Ikkunan sulkeminen epäonnistui",
"statusActivity": "Toiminta",
"statusWindows": "Ikkunat",
"statusPanes": "ruudut",
"statusTags": "Tunnisteet",
"killPaneTitle": "Tappaa ruutu {{id}}?",
"killPaneBody": "Tässä ruudussa käynnissä oleva prosessi lopetetaan. Viimeisen ruudun sulkeminen sulkee sen ikkunan.",
"paneKillFailed": "Ruudun sulkeminen epäonnistui",
"killSessionTitle": "Lopeta istunto \"{{name}}\"?",
"killSessionBody": "Kaikki tämän istunnon ikkunat ja käynnissä olevat prosessit lopetetaan. Tätä ei voi perua.",
"kill": "Tappaa",
"sessionKilled": "Istunto \"{{name}}\" lopetettu",
"sessionKillFailed": "Istunnon lopettaminen epäonnistui",
"hostUnreachable": "Yhteyden muodostaminen isäntään ei onnistu. Tarkista, että se on verkossa ja tavoitettavissa.",
"noServer": "Tällä isännöinnillä ei ole käynnissä tmux-palvelinta.",
"searchTruncated": "Osittaisia tuloksia — haku kattaa kunkin ruudun viimeiset {{lines}} rivit ja enintään {{panes}} ruutua.",
"closeSearchResults": "Sulje hakutulokset",
"retry": "Yritä uudelleen",
"noHosts": "Ei SSH-isäntiä saatavilla",
"noHostsHint": "Ota Tmux Monitor -asetus käyttöön SSH-isännässä (Isäntähallinta → Pääte-välilehti) sen tmux-istuntojen valvomiseksi.",
"tmuxInstallHint": "Asenna se isäntäkoneelle komennolla:",
"attachTooltip": "Avaa pääte ja kohdista {{host}}",
"attachTooltipPane": "Avaa pääteohjelma ja kohdista {{host}} — tmux-istunto {{session}}",
"timeJustNow": "juuri nyt",
"timeMinutes": "{{count}}minuuttia sitten",
"timeHours": "{{count}}tuntia sitten",
"timeDays": "{{count}}d sitten"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Moniteur Tmux"
},
"hosts": {
"enableTmuxMonitor": "Activer le Moniteur Tmux",
"enableTmuxMonitorDesc": "Afficher cet hôte dans le Moniteur Tmux et ajouter ses actions tmux à la barre latérale"
},
"tmuxMonitor": {
"title": "Moniteur tmux",
"failedToLoadHosts": "Échec du chargement des hôtes",
"failedToLoad": "Échec du chargement des sessions tmux",
"tmuxUnavailable": "tmux n'est pas installé sur cet hôte",
"noSessions": "Aucune session tmux sur cet hôte",
"noHostSelected": "Aucun hôte sélectionné",
"attached": "Attaché",
"detached": "Détaché",
"attach": "Attacher",
"editTags": "Modifier les tags",
"tagsHint": "Tags séparés par des virgules (ex. : YOLO, lab, training)",
"tagsSaved": "Tags enregistrés",
"tagsSaveFailed": "Échec de l'enregistrement des tags",
"searchPlaceholder": "Rechercher dans les sorties des sessions...",
"searchResults": "{{count}} correspondances",
"searchFailed": "Échec de la recherche",
"selectPaneHint": "Sélectionnez un volet pour prévisualiser sa sortie",
"closePreview": "Fermer l'aperçu",
"newSession": "Nouvelle session",
"newSessionHint": "Nom de session (lettres, chiffres, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "Créer",
"sessionCreated": "Session « {{name}} » créée",
"sessionCreateFailed": "Échec de la création de la session",
"splitRight": "Diviser à droite",
"splitDown": "Diviser en bas",
"splitFailed": "Échec de la division du volet",
"newWindow": "Nouvelle fenêtre",
"windowCreateFailed": "Échec de la création de la fenêtre",
"attachSessionTooltip": "Attacher à {{session}}",
"refresh": "Actualiser",
"refreshFailed": "Échec de l'actualisation des sessions",
"collapseAll": "Réduire tout",
"expandAll": "Développer tout",
"moreActions": "Plus d'actions",
"sessionStats": "Statistiques de session",
"renameSessionTitle": "Renommer la session « {{name}} »",
"rename": "Renommer",
"sessionRenamed": "Session renommée en « {{name}} »",
"sessionRenameFailed": "Échec du renommage de la session",
"editTagsTitle": "Modifier les tags pour « {{name}} »",
"killPane": "Fermer le volet",
"resizeTree": "Faire glisser pour redimensionner — double-cliquer pour réinitialiser",
"reattach": "Rattacher (corrige un affichage brouillé)",
"killWindow": "Fermer la fenêtre",
"killWindowTitle": "Fermer la fenêtre {{index}} de « {{session}} » ?",
"killWindowBody": "Tous les volets et processus de cette fenêtre seront terminés. La fermeture de la dernière fenêtre met fin à la session.",
"windowKillFailed": "Échec de la fermeture de la fenêtre",
"statusActivity": "Activité",
"statusWindows": "Fenêtres",
"statusPanes": "Volets",
"statusTags": "Étiquettes",
"killPaneTitle": "Fermer le volet {{id}} ?",
"killPaneBody": "Le processus s’exécutant dans ce volet sera terminé. La fermeture du dernier volet ferme sa fenêtre.",
"paneKillFailed": "Échec de la fermeture du volet",
"killSessionTitle": "Fermer la session « {{name}} » ?",
"killSessionBody": "Toutes les fenêtres et processus en cours dans cette session seront terminés. Cette action est irréversible.",
"kill": "Fermer",
"sessionKilled": "Session « {{name}} » fermée",
"sessionKillFailed": "Échec de la fermeture de la session",
"hostUnreachable": "Impossible de se connecter à l’hôte. Vérifiez qu’il est en ligne et accessible.",
"noServer": "Aucun serveur tmux n’est exécuté sur cet hôte.",
"searchTruncated": "Résultats partiels — la recherche couvre les {{lines}} dernières lignes de chaque volet et jusqu’à {{panes}} volets.",
"closeSearchResults": "Fermer les résultats de recherche",
"retry": "Réessayer",
"noHosts": "Aucun hôte SSH disponible",
"noHostsHint": "Activez l’option Tmux Monitor sur un hôte SSH (Gestionnaire d’hôtes → onglet Terminal) pour surveiller ses sessions tmux.",
"tmuxInstallHint": "Installez-le sur l’hôte avec :",
"attachTooltip": "Ouvrir un terminal sur {{host}}",
"attachTooltipPane": "Ouvrir un terminal sur {{host}} — session tmux {{session}}",
"timeJustNow": "à l’instant",
"timeMinutes": "il y a {{count}} min",
"timeHours": "il y a {{count}} h",
"timeDays": "il y a {{count}} j"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "צג Tmux"
},
"hosts": {
"enableTmuxMonitor": "הפעל את צג Tmux",
"enableTmuxMonitorDesc": "הצג מארח זה בצג Tmux והוסף את פעולות ה-tmux שלו לסרגל הצד"
},
"tmuxMonitor": {
"title": "צג Tmux",
"failedToLoadHosts": "טעינת המארחים נכשלה",
"failedToLoad": "טעינת סשנים של tmux נכשלה",
"tmuxUnavailable": "tmux אינו מותקן על מחשב זה",
"noSessions": "אין הפעלות tmux על מארח זה",
"noHostSelected": "לא נבחר מארח",
"attached": "מְצוֹרָף",
"detached": "מְנוּתָק",
"attach": "לְצַרֵף",
"editTags": "עריכת תגיות",
"tagsHint": "תגיות מופרדות בפסיקים (למשל YOLO, מעבדה, הדרכה)",
"tagsSaved": "תגיות נשמרו",
"tagsSaveFailed": "שמירת התגים נכשלה",
"searchPlaceholder": "חיפוש פלט בין סשנים...",
"searchResults": "{{count}} התאמות",
"searchFailed": "החיפוש נכשל",
"selectPaneHint": "בחר חלונית כדי להציג תצוגה מקדימה של הפלט שלה",
"closePreview": "סגור את התצוגה המקדימה",
"newSession": "סשן חדש",
"newSessionHint": "שם סשן (אותיות, ספרות, _ @ % + = -)",
"newSessionPlaceholder": "הסשן שלי",
"create": "לִיצוֹר",
"sessionCreated": "סשן \"{{name}}\" נוצר",
"sessionCreateFailed": "יצירת הסשן נכשלה",
"splitRight": "פיצול ימינה",
"splitDown": "התפצלו למטה",
"splitFailed": "נכשל פיצול החלונית",
"newWindow": "חלון חדש",
"windowCreateFailed": "נכשלה יצירת החלון",
"attachSessionTooltip": "צרף אל {{session}}",
"refresh": "לְרַעֲנֵן",
"refreshFailed": "רענון ההפעלות נכשל",
"collapseAll": "כווץ הכל",
"expandAll": "הרחב הכל",
"moreActions": "פעולות נוספות",
"sessionStats": "סטטיסטיקות של סשן",
"renameSessionTitle": "שינוי שם הסשן \"{{name}}\"",
"rename": "שינוי שם",
"sessionRenamed": "שם הסשן הוא \"{{name}}\"",
"sessionRenameFailed": "שינוי שם ההפעלה נכשל",
"editTagsTitle": "עריכת תגיות עבור \"{{name}}\"",
"killPane": "חלונית הרג",
"resizeTree": "גרור לשינוי גודל - לחץ פעמיים לאיפוס",
"reattach": "חבר מחדש (מתקן תצוגה מעוותת)",
"killWindow": "להרוג את החלון",
"killWindowTitle": "להפסיק את החלון {{index}} של \"{{session}}\"?",
"killWindowBody": "כל חלונית ותהליך בחלון זה יופסק. סגירת החלון האחרון תסיים את ההפעלה.",
"windowKillFailed": "נכשלה הסגת החלון",
"statusActivity": "פְּעִילוּת",
"statusWindows": "חלונות",
"statusPanes": "חלוניות",
"statusTags": "תגיות",
"killPaneTitle": "להרוג את החלונית {{id}}?",
"killPaneBody": "התהליך הפועל בחלונית זו יופסק. סגירת החלונית האחרונה תסגור את החלון שלה.",
"paneKillFailed": "נכשלה הסגת החלונית",
"killSessionTitle": "להפסיק את הסשן \"{{name}}\"?",
"killSessionBody": "כל החלונות והתהליכים הפועלים בהפעלה זו ייסגרו. לא ניתן לבטל פעולה זו.",
"kill": "לַהֲרוֹג",
"sessionKilled": "הסשן \"{{name}}\" נסגר",
"sessionKillFailed": "נכשלה הסיום",
"hostUnreachable": "לא ניתן היה להתחבר למארח. ודא שהוא מחובר ונגיש.",
"noServer": "אף שרת tmux לא פועל על מחשב אחסון זה.",
"searchTruncated": "תוצאות חלקיות - החיפוש מכסה את השורות האחרונות {{lines}} של כל חלונית ולכל היותר {{panes}} חלונות.",
"closeSearchResults": "סגור את תוצאות החיפוש",
"retry": "נסה שוב",
"noHosts": "אין מארחי SSH זמינים",
"noHostsHint": "הפעל את האפשרות Tmux Monitor במארח SSH (מנהל מארח → לשונית טרמינל) כדי לנטר את הפעלות ה-tmux שלו.",
"tmuxInstallHint": "התקן אותו על המארח עם:",
"attachTooltip": "פתח טרמינל אל {{host}}",
"attachTooltipPane": "פתח טרמינל אל {{host}} — tmux session {{session}}",
"timeJustNow": "זֶה עַתָה",
"timeMinutes": "לפני {{count}}דקות",
"timeHours": "{{count}}h ago",
"timeDays": "{{count}}d ago"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux मॉनिटर"
},
"hosts": {
"enableTmuxMonitor": "Tmux मॉनिटर सक्षम करें",
"enableTmuxMonitorDesc": "इस होस्ट को Tmux मॉनिटर में दिखाएँ और इसकी tmux क्रियाओं को साइडबार में जोड़ें"
},
"tmuxMonitor": {
"title": "Tmux मॉनिटर",
"failedToLoadHosts": "होस्ट लोड करने में विफल",
"failedToLoad": "tmux सत्र लोड करने में विफल",
"tmuxUnavailable": "इस होस्ट पर tmux इंस्टॉल नहीं है",
"noSessions": "इस होस्ट पर कोई tmux सत्र नहीं है",
"noHostSelected": "कोई होस्ट चयनित नहीं है",
"attached": "अटैच्ड",
"detached": "डिटैच्ड",
"attach": "अटैच करें",
"editTags": "टैग संपादित करें",
"tagsHint": "अल्पविराम से अलग टैग (जैसे YOLO, lab, training)",
"tagsSaved": "टैग सहेजे गए",
"tagsSaveFailed": "टैग सहेजने में विफल",
"searchPlaceholder": "सत्रों में आउटपुट खोजें...",
"searchResults": "{{count}} मिलान",
"searchFailed": "खोज विफल",
"selectPaneHint": "इसके आउटपुट का पूर्वावलोकन करने के लिए एक पैन चुनें",
"closePreview": "पूर्वावलोकन बंद करें",
"newSession": "नया सत्र",
"newSessionHint": "सत्र का नाम (अक्षर, अंक, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "बनाएं",
"sessionCreated": "सत्र \"{{name}}\" बनाया गया",
"sessionCreateFailed": "सत्र बनाने में विफल",
"splitRight": "दाएं विभाजित करें",
"splitDown": "नीचे विभाजित करें",
"splitFailed": "पैन विभाजित करने में विफल",
"newWindow": "नई विंडो",
"windowCreateFailed": "विंडो बनाने में विफल",
"attachSessionTooltip": "{{session}} में अटैच करें",
"refresh": "ताज़ा करें",
"refreshFailed": "सत्र ताज़ा करने में विफल",
"collapseAll": "सभी संकुचित करें",
"expandAll": "सभी विस्तृत करें",
"moreActions": "अधिक कार्रवाइयां",
"sessionStats": "सत्र आँकड़े",
"renameSessionTitle": "सत्र \"{{name}}\" का नाम बदलें",
"rename": "नाम बदलें",
"sessionRenamed": "सत्र का नाम बदलकर \"{{name}}\" कर दिया गया",
"sessionRenameFailed": "सत्र का नाम बदलने में विफल",
"editTagsTitle": "\"{{name}}\" के लिए टैग संपादित करें",
"killPane": "पेन नष्ट करें",
"resizeTree": "आकार बदलने के लिए खींचें — रीसेट करने के लिए डबल-क्लिक करें",
"reattach": "पुनः अटैच करें (विकृत दृश्य को ठीक करता है)",
"killWindow": "विंडो नष्ट करें",
"killWindowTitle": "क्या \"{{session}}\" की विंडो {{index}} नष्ट करें?",
"killWindowBody": "इस विंडो के सभी पेन और प्रक्रियाएँ समाप्त हो जाएँगी। अंतिम विंडो नष्ट करने से सत्र समाप्त हो जाता है।",
"windowKillFailed": "विंडो नष्ट करने में विफल",
"statusActivity": "गतिविधि",
"statusWindows": "विंडोज़",
"statusPanes": "पेन",
"statusTags": "टैग",
"killPaneTitle": "क्या पेन {{id}} नष्ट करें?",
"killPaneBody": "इस पेन में चल रही प्रक्रिया समाप्त हो जाएगी। अंतिम पेन नष्ट करने पर इसकी विंडो बंद हो जाती है।",
"paneKillFailed": "पेन नष्ट करने में विफल",
"killSessionTitle": "क्या सत्र \"{{name}}\" नष्ट करें?",
"killSessionBody": "इस सत्र की सभी विंडोज़ और चल रही प्रक्रियाएँ समाप्त हो जाएँगी। इसे पूर्ववत नहीं किया जा सकता।",
"kill": "नष्ट करें",
"sessionKilled": "सत्र \"{{name}}\" नष्ट किया गया",
"sessionKillFailed": "सत्र नष्ट करने में विफल",
"hostUnreachable": "होस्ट से कनेक्ट नहीं हो सका। जाँचें कि यह ऑनलाइन और पहुँच योग्य है।",
"noServer": "इस होस्ट पर कोई tmux सर्वर नहीं चल रहा है।",
"searchTruncated": "आंशिक परिणाम — खोज प्रत्येक पेन की अंतिम {{lines}} पंक्तियों और अधिकतम {{panes}} पेनों में की गई है।",
"closeSearchResults": "खोज परिणाम बंद करें",
"retry": "पुनः प्रयास करें",
"noHosts": "कोई SSH होस्ट उपलब्ध नहीं है",
"noHostsHint": "किसी SSH होस्ट पर Tmux Monitor विकल्प सक्षम करें (होस्ट प्रबंधक → टर्मिनल टैब) ताकि उसके tmux सत्रों की निगरानी की जा सके।",
"tmuxInstallHint": "इसे होस्ट पर इंस्टॉल करें:",
"attachTooltip": "{{host}} पर टर्मिनल खोलें",
"attachTooltipPane": "{{host}} पर टर्मिनल खोलें — tmux सत्र {{session}}",
"timeJustNow": "अभी",
"timeMinutes": "{{count}}m पहले",
"timeHours": "{{count}}h पहले",
"timeDays": "{{count}}d पहले"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux monitor"
},
"hosts": {
"enableTmuxMonitor": "Tmux monitor engedélyezése",
"enableTmuxMonitorDesc": "Mutassa meg ezt a gépet a Tmux Monitorban, és adja hozzá a tmux műveleteit az oldalsávhoz"
},
"tmuxMonitor": {
"title": "Tmux monitor",
"failedToLoadHosts": "Nem sikerült betölteni a gazdagépeket",
"failedToLoad": "Nem sikerült betölteni a tmux munkameneteket",
"tmuxUnavailable": "A tmux nincs telepítve erre a gépre",
"noSessions": "Nincsenek tmux munkamenetek ezen a gépen",
"noHostSelected": "Nincs kiválasztva gazdagép",
"attached": "Csatolt",
"detached": "Különálló",
"attach": "Csatolás",
"editTags": "Címkék szerkesztése",
"tagsHint": "Vesszővel elválasztott címkék (pl. YOLO, labor, képzés)",
"tagsSaved": "Címkék mentve",
"tagsSaveFailed": "Nem sikerült menteni a címkéket",
"searchPlaceholder": "Keresési kimenet munkamenetek között...",
"searchResults": "{{count}} találat",
"searchFailed": "Keresés sikertelen",
"selectPaneHint": "Válasszon ki egy panelt a kimenetének megtekintéséhez",
"closePreview": "Előnézet bezárása",
"newSession": "Új munkamenet",
"newSessionHint": "Munkamenet neve (betűk, számjegyek, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "Teremt",
"sessionCreated": "\"{{name}}\" munkamenet létrehozva",
"sessionCreateFailed": "Nem sikerült létrehozni a munkamenetet",
"splitRight": "Jobbra osztás",
"splitDown": "Felosztás",
"splitFailed": "Nem sikerült felosztani az ablaktáblát",
"newWindow": "Új ablak",
"windowCreateFailed": "Nem sikerült létrehozni az ablakot",
"attachSessionTooltip": "Csatlakozás ehhez: {{session}}",
"refresh": "Frissítés",
"refreshFailed": "Nem sikerült frissíteni a munkameneteket",
"collapseAll": "Összes összecsukása",
"expandAll": "Összes kibontása",
"moreActions": "További műveletek",
"sessionStats": "Munkamenet-statisztikák",
"renameSessionTitle": "Munkamenet átnevezése \"{{name}}\"",
"rename": "Átnevezés",
"sessionRenamed": "Munkamenet átnevezése erre: \"{{name}}\"",
"sessionRenameFailed": "Nem sikerült átnevezni a munkamenetet",
"editTagsTitle": "A(z) „{{name}} ” címkéinek szerkesztése",
"killPane": "Ablak bezárása",
"resizeTree": "Húzással átméretezhető – dupla kattintással visszaállítható",
"reattach": "Újracsatolás (kijavítja a torz képet)",
"killWindow": "Ablak ölése",
"killWindowTitle": "A(z) \"{{session}} \" ablakának {{index}} leállítása?",
"killWindowBody": "Az ablakban található összes panel és folyamat leáll. Az utolsó ablak bezárása a munkamenet végét jelenti.",
"windowKillFailed": "Nem sikerült bezárni az ablakot",
"statusActivity": "Tevékenység",
"statusWindows": "Ablakok",
"statusPanes": "ablaktáblák",
"statusTags": "Címkék",
"killPaneTitle": "Ablak bezárása {{id}}?",
"killPaneBody": "Az ebben a panelben futó folyamat leáll. Az utolsó panel bezárása bezárja az ablakát.",
"paneKillFailed": "Nem sikerült bezárni az ablaktáblát",
"killSessionTitle": "Munkamenet leállítása: \"{{name}}\"?",
"killSessionBody": "Ebben a munkamenetben az összes ablak és futó folyamat leáll. Ez a művelet nem vonható vissza.",
"kill": "Megöl",
"sessionKilled": "A(z) \"{{name}}\" munkamenet leállt",
"sessionKillFailed": "Nem sikerült leállítani a munkamenetet",
"hostUnreachable": "Nem sikerült csatlakozni a gazdagéphez. Ellenőrizze, hogy online van-e és elérhető-e.",
"noServer": "Nem fut tmux szerver ezen a gépen.",
"searchTruncated": "Részleges eredmények — a keresés az egyes panelek utolsó {{lines}} soraira, és legfeljebb {{panes}} panelre terjed ki.",
"closeSearchResults": "Keresési eredmények bezárása",
"retry": "Újrapróbálkozás",
"noHosts": "Nincsenek elérhető SSH-hosztok",
"noHostsHint": "Engedélyezze a Tmux Monitor opciót egy SSH hoszton (Host Manager → Terminal fül) a tmux munkamenetek monitorozásához.",
"tmuxInstallHint": "Telepítse a gazdagépre a következő paranccsal:",
"attachTooltip": "Nyisson meg egy terminált a következőhöz: {{host}}",
"attachTooltipPane": "Nyiss meg egy terminált a következőhöz: {{host}} — tmux session {{session}}",
"timeJustNow": "éppen most",
"timeMinutes": "{{count}}perccel ezelőtt",
"timeHours": "{{count}}órával ezelőtt",
"timeDays": "{{count}}nappal ezelőtt"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Monitor Tmux"
},
"hosts": {
"enableTmuxMonitor": "Aktifkan Monitor Tmux",
"enableTmuxMonitorDesc": "Tampilkan host ini di Tmux Monitor dan tambahkan aksi tmux-nya ke sidebar."
},
"tmuxMonitor": {
"title": "Monitor Tmux",
"failedToLoadHosts": "Gagal memuat host",
"failedToLoad": "Gagal memuat sesi tmux",
"tmuxUnavailable": "tmux tidak terinstal pada host ini",
"noSessions": "Tidak ada sesi tmux di host ini.",
"noHostSelected": "Tidak ada pembawa acara yang dipilih.",
"attached": "Terlampir",
"detached": "Terpisah",
"attach": "Menempel",
"editTags": "Edit tag",
"tagsHint": "Tag yang dipisahkan koma (misalnya YOLO, lab, training)",
"tagsSaved": "Tag tersimpan",
"tagsSaveFailed": "Gagal menyimpan tag.",
"searchPlaceholder": "Hasil pencarian di seluruh sesi...",
"searchResults": "{{count}} cocok",
"searchFailed": "Pencarian gagal",
"selectPaneHint": "Pilih panel untuk melihat pratinjau hasilnya.",
"closePreview": "Tutup pratinjau",
"newSession": "Sesi baru",
"newSessionHint": "Nama sesi (huruf, angka, _ @ % + = -)",
"newSessionPlaceholder": "sesi saya",
"create": "Membuat",
"sessionCreated": "Sesi \"{{name}}\" dibuat",
"sessionCreateFailed": "Gagal membuat sesi",
"splitRight": "Belah kanan",
"splitDown": "Terbagi",
"splitFailed": "Gagal membagi panel",
"newWindow": "Jendela baru",
"windowCreateFailed": "Gagal membuat jendela",
"attachSessionTooltip": "Lampirkan ke {{session}}",
"refresh": "Menyegarkan",
"refreshFailed": "Gagal memperbarui sesi",
"collapseAll": "Ciutkan semua",
"expandAll": "Perluas semua",
"moreActions": "Tindakan lainnya",
"sessionStats": "Statistik sesi",
"renameSessionTitle": "Ganti nama sesi \"{{name}}\"",
"rename": "Ganti nama",
"sessionRenamed": "Sesi diubah namanya menjadi \"{{name}}\"",
"sessionRenameFailed": "Gagal mengganti nama sesi",
"editTagsTitle": "Edit tag untuk \"{{name}}\"",
"killPane": "Panel pembunuh",
"resizeTree": "Seret untuk mengubah ukuran — klik dua kali untuk mengatur ulang",
"reattach": "Pasangkan kembali (memperbaiki tampilan yang kacau)",
"killWindow": "Jendela pembunuhan",
"killWindowTitle": "Hapus jendela {{index}} dari \"{{session}}\"?",
"killWindowBody": "Semua panel dan proses di jendela ini akan dihentikan. Menghentikan jendela terakhir akan mengakhiri sesi.",
"windowKillFailed": "Gagal menutup jendela",
"statusActivity": "Aktivitas",
"statusWindows": "Windows",
"statusPanes": "panel",
"statusTags": "Tag",
"killPaneTitle": "Hapus panel {{id}}?",
"killPaneBody": "Proses yang berjalan di panel ini akan dihentikan. Menghentikan proses di panel terakhir akan menutup jendelanya.",
"paneKillFailed": "Gagal mematikan panel",
"killSessionTitle": "Hentikan sesi \"{{name}}\"?",
"killSessionBody": "Semua jendela dan proses yang berjalan dalam sesi ini akan dihentikan. Tindakan ini tidak dapat dibatalkan.",
"kill": "Membunuh",
"sessionKilled": "Sesi \"{{name}}\" dihentikan",
"sessionKillFailed": "Gagal mengakhiri sesi",
"hostUnreachable": "Tidak dapat terhubung ke host. Periksa apakah host tersebut online dan dapat dijangkau.",
"noServer": "Tidak ada server tmux yang berjalan di host ini.",
"searchTruncated": "Hasil parsial — pencarian mencakup {{lines}} baris terakhir dari setiap panel dan paling banyak {{panes}} panel.",
"closeSearchResults": "Tutup hasil pencarian",
"retry": "Mencoba kembali",
"noHosts": "Tidak ada host SSH yang tersedia.",
"noHostsHint": "Aktifkan opsi Tmux Monitor pada host SSH (Host Manager → tab Terminal) untuk memantau sesi tmux-nya.",
"tmuxInstallHint": "Instal di host dengan cara:",
"attachTooltip": "Buka terminal ke {{host}}",
"attachTooltipPane": "Buka terminal ke {{host}} — sesi tmux {{session}}",
"timeJustNow": "baru saja",
"timeMinutes": "{{count}}m lalu",
"timeHours": "{{count}}h lalu",
"timeDays": "{{count}}d lalu"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Monitor Tmux"
},
"hosts": {
"enableTmuxMonitor": "Abilita Monitor Tmux",
"enableTmuxMonitorDesc": "Mostra questo host nel Monitor Tmux e aggiungi le sue azioni tmux alla barra laterale"
},
"tmuxMonitor": {
"title": "Monitor Tmux",
"failedToLoadHosts": "Impossibile caricare gli host",
"failedToLoad": "Impossibile caricare le sessioni tmux",
"tmuxUnavailable": "tmux non è installato su questo host",
"noSessions": "Nessuna sessione tmux su questo host",
"noHostSelected": "Nessun host selezionato",
"attached": "Connesso",
"detached": "Scollegato",
"attach": "Connetti",
"editTags": "Modifica tag",
"tagsHint": "Tag separati da virgole (es. YOLO, lab, training)",
"tagsSaved": "Tag salvati",
"tagsSaveFailed": "Salvataggio dei tag non riuscito",
"searchPlaceholder": "Cerca output tra le sessioni...",
"searchResults": "{{count}} corrispondenze",
"searchFailed": "Ricerca non riuscita",
"selectPaneHint": "Seleziona un pannello per visualizzare l'anteprima dell'output",
"closePreview": "Chiudi anteprima",
"newSession": "Nuova sessione",
"newSessionHint": "Nome sessione (lettere, cifre, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "Crea",
"sessionCreated": "Sessione \"{{name}}\" creata",
"sessionCreateFailed": "Creazione della sessione non riuscita",
"splitRight": "Dividi a destra",
"splitDown": "Dividi in basso",
"splitFailed": "Divisione del pannello non riuscita",
"newWindow": "Nuova finestra",
"windowCreateFailed": "Creazione della finestra non riuscita",
"attachSessionTooltip": "Connetti a {{session}}",
"refresh": "Aggiorna",
"refreshFailed": "Aggiornamento delle sessioni non riuscito",
"collapseAll": "Comprimi tutto",
"expandAll": "Espandi tutto",
"moreActions": "Altre azioni",
"sessionStats": "Statistiche sessione",
"renameSessionTitle": "Rinomina sessione \"{{name}}\"",
"rename": "Rinomina",
"sessionRenamed": "Sessione rinominata in \"{{name}}\"",
"sessionRenameFailed": "Rinomina della sessione non riuscita",
"editTagsTitle": "Modifica tag per \"{{name}}\"",
"killPane": "Termina riquadro",
"resizeTree": "Trascina per ridimensionare — doppio clic per ripristinare",
"reattach": "Ricollega (risolve una visualizzazione alterata)",
"killWindow": "Termina finestra",
"killWindowTitle": "Terminare la finestra {{index}} di \"{{session}}\"?",
"killWindowBody": "Tutti i riquadri e i processi in questa finestra verranno terminati. Terminare l'ultima finestra chiude la sessione.",
"windowKillFailed": "Terminazione della finestra non riuscita",
"statusActivity": "Attività",
"statusWindows": "Finestre",
"statusPanes": "riquadri",
"statusTags": "Tag",
"killPaneTitle": "Terminare il riquadro {{id}}?",
"killPaneBody": "Il processo in esecuzione in questo riquadro verrà terminato. Terminare l'ultimo riquadro chiude la sua finestra.",
"paneKillFailed": "Terminazione del riquadro non riuscita",
"killSessionTitle": "Terminare la sessione \"{{name}}\"?",
"killSessionBody": "Tutte le finestre e i processi in esecuzione in questa sessione verranno terminati. Questa operazione non può essere annullata.",
"kill": "Termina",
"sessionKilled": "Sessione \"{{name}}\" terminata",
"sessionKillFailed": "Terminazione della sessione non riuscita",
"hostUnreachable": "Impossibile connettersi all'host. Verifica che sia online e raggiungibile.",
"noServer": "Nessun server tmux in esecuzione su questo host.",
"searchTruncated": "Risultati parziali — la ricerca copre le ultime {{lines}} righe di ogni riquadro e al massimo {{panes}} riquadri.",
"closeSearchResults": "Chiudi risultati ricerca",
"retry": "Riprova",
"noHosts": "Nessun host SSH disponibile",
"noHostsHint": "Abilita l'opzione Monitor Tmux su un host SSH (Gestione host → scheda Terminale) per monitorare le sue sessioni tmux.",
"tmuxInstallHint": "Installalo sull'host con:",
"attachTooltip": "Apri un terminale su {{host}}",
"attachTooltipPane": "Apri un terminale su {{host}} — sessione tmux {{session}}",
"timeJustNow": "adesso",
"timeMinutes": "{{count}}m fa",
"timeHours": "{{count}}h fa",
"timeDays": "{{count}}d fa"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "tmuxモニター"
},
"hosts": {
"enableTmuxMonitor": "Tmuxモニターを有効にする",
"enableTmuxMonitorDesc": "Tmuxモニターにこのホストを表示し、tmuxアクションをサイドバーに追加します。"
},
"tmuxMonitor": {
"title": "Tmux モニター",
"failedToLoadHosts": "ホストの読み込みに失敗しました",
"failedToLoad": "Tmux セッションの読み込みに失敗しました",
"tmuxUnavailable": "このホストに tmux がインストールされていません",
"noSessions": "このホストに tmux セッションがありません",
"noHostSelected": "ホストが選択されていません",
"attached": "アタッチ済み",
"detached": "デタッチ済み",
"attach": "アタッチ",
"editTags": "タグを編集",
"tagsHint": "カンマ区切りのタグ (例: YOLO, lab, training)",
"tagsSaved": "タグを保存しました",
"tagsSaveFailed": "タグの保存に失敗しました",
"searchPlaceholder": "セッション間で出力を検索...",
"searchResults": "{{count}}件の一致",
"searchFailed": "検索に失敗しました",
"selectPaneHint": "出力をプレビューするには、ペインを選択してください",
"closePreview": "プレビューを閉じる",
"newSession": "新規セッション",
"newSessionHint": "セッション名 (英数字, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "作成",
"sessionCreated": "セッション \"{{name}}\" を作成しました",
"sessionCreateFailed": "セッションの作成に失敗しました",
"splitRight": "右に分割",
"splitDown": "下に分割",
"splitFailed": "ペインの分割に失敗しました",
"newWindow": "新規ウィンドウ",
"windowCreateFailed": "ウィンドウの作成に失敗しました",
"attachSessionTooltip": "{{session}}にアタッチ",
"refresh": "更新",
"refreshFailed": "セッションの更新に失敗しました",
"collapseAll": "すべて折りたたむ",
"expandAll": "すべて展開",
"moreActions": "その他の操作",
"sessionStats": "セッション統計",
"renameSessionTitle": "セッション \"{{name}}\" の名前変更",
"rename": "名前変更",
"sessionRenamed": "セッション名を \"{{name}}\" に変更しました",
"sessionRenameFailed": "セッションの名前変更に失敗しました",
"editTagsTitle": "\"{{name}}\" のタグを編集",
"killPane": "ペインを終了",
"resizeTree": "ドラッグでリサイズ — ダブルクリックでリセット",
"reattach": "再接続 (表示の乱れを修正)",
"killWindow": "ウィンドウを終了",
"killWindowTitle": "ウィンドウ {{index}} (\"{{session}}\") を終了しますか?",
"killWindowBody": "このウィンドウ内のすべてのペインとプロセスが終了されます。最後のウィンドウを終了するとセッションが終了します。",
"windowKillFailed": "ウィンドウの終了に失敗しました",
"statusActivity": "アクティビティ",
"statusWindows": "ウィンドウ",
"statusPanes": "ペイン",
"statusTags": "タグ",
"killPaneTitle": "ペイン {{id}} を終了しますか?",
"killPaneBody": "このペインで実行中のプロセスが終了されます。最後のペインを終了するとウィンドウが閉じます。",
"paneKillFailed": "ペインの終了に失敗しました",
"killSessionTitle": "セッション \"{{name}}\" を終了しますか?",
"killSessionBody": "このセッション内のすべてのウィンドウと実行中のプロセスが終了されます。この操作は取り消せません。",
"kill": "終了",
"sessionKilled": "セッション \"{{name}}\" を終了しました",
"sessionKillFailed": "セッションの終了に失敗しました",
"hostUnreachable": "ホストに接続できませんでした。オンラインで到達可能か確認してください。",
"noServer": "このホストで tmux サーバーが実行されていません。",
"searchTruncated": "一部の結果 — 各ペインの直近 {{lines}} 行と最大 {{panes}} ペインを検索対象としています。",
"closeSearchResults": "検索結果を閉じる",
"retry": "再試行",
"noHosts": "利用可能な SSH ホストがありません",
"noHostsHint": "SSH ホストで Tmux モニターオプションを有効にすると(ホストマネージャー → 端末タブ)、tmux セッションを監視できます。",
"tmuxInstallHint": "ホストにインストールするには:",
"attachTooltip": "{{host}} にターミナルを開く",
"attachTooltipPane": "{{host}} にターミナルを開く — tmux セッション {{session}}",
"timeJustNow": "たった今",
"timeMinutes": "{{count}}分前",
"timeHours": "{{count}}時間前",
"timeDays": "{{count}}日前"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux 모니터"
},
"hosts": {
"enableTmuxMonitor": "Tmux 모니터 활성화",
"enableTmuxMonitorDesc": "Tmux 모니터에 이 호스트를 표시하고 tmux 작업을 사이드바에 추가합니다."
},
"tmuxMonitor": {
"title": "Tmux 모니터",
"failedToLoadHosts": "호스트 목록을 불러오지 못했습니다",
"failedToLoad": "tmux 세션을 불러오지 못했습니다",
"tmuxUnavailable": "이 호스트에 tmux가 설치되어 있지 않습니다",
"noSessions": "이 호스트에 tmux 세션이 없습니다",
"noHostSelected": "선택된 호스트가 없습니다",
"attached": "연결됨",
"detached": "분리됨",
"attach": "연결",
"editTags": "태그 편집",
"tagsHint": "쉼표로 구분된 태그 (예: YOLO, lab, training)",
"tagsSaved": "태그 저장됨",
"tagsSaveFailed": "태그 저장 실패",
"searchPlaceholder": "세션 전체에서 출력 검색...",
"searchResults": "{{count}}개 일치",
"searchFailed": "검색 실패",
"selectPaneHint": "출력을 미리 보려면 패널을 선택하세요",
"closePreview": "미리보기 닫기",
"newSession": "새 세션",
"newSessionHint": "세션 이름 (영문자, 숫자, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "생성",
"sessionCreated": "세션 \"{{name}}\" 생성됨",
"sessionCreateFailed": "세션 생성 실패",
"splitRight": "오른쪽으로 분할",
"splitDown": "아래로 분할",
"splitFailed": "패널 분할 실패",
"newWindow": "새 창",
"windowCreateFailed": "창 생성 실패",
"attachSessionTooltip": "{{session}}에 연결",
"refresh": "새로고침",
"refreshFailed": "세션 새로고침 실패",
"collapseAll": "모두 접기",
"expandAll": "모두 펼치기",
"moreActions": "더 보기",
"sessionStats": "세션 통계",
"renameSessionTitle": "세션 \"{{name}}\" 이름 바꾸기",
"rename": "이름 바꾸기",
"sessionRenamed": "세션 이름이 \"{{name}}\"(으)로 변경됨",
"sessionRenameFailed": "세션 이름 변경 실패",
"editTagsTitle": "\"{{name}}\"의 태그 편집",
"killPane": "Pane 종료",
"resizeTree": "드래그로 크기 조정 — 더블클릭하여 초기화",
"reattach": "재연결 (화면 깨짐 복구)",
"killWindow": "창 종료",
"killWindowTitle": "{{session}}의 창 {{index}}을(를) 종료하시겠습니까?",
"killWindowBody": "이 창의 모든 Pane과 프로세스가 종료됩니다. 마지막 창을 종료하면 세션이 끝납니다.",
"windowKillFailed": "창 종료 실패",
"statusActivity": "활동",
"statusWindows": "Windows",
"statusPanes": "Pane",
"statusTags": "태그",
"killPaneTitle": "Pane {{id}}을(를) 종료하시겠습니까?",
"killPaneBody": "이 Pane에서 실행 중인 프로세스가 종료됩니다. 마지막 Pane을 종료하면 해당 창이 닫힙니다.",
"paneKillFailed": "Pane 종료 실패",
"killSessionTitle": "세션 \"{{name}}\"을(를) 종료하시겠습니까?",
"killSessionBody": "이 세션의 모든 창과 실행 중인 프로세스가 종료됩니다. 이 작업은 취소할 수 없습니다.",
"kill": "종료",
"sessionKilled": "세션 \"{{name}}\"이(가) 종료되었습니다.",
"sessionKillFailed": "세션 종료 실패",
"hostUnreachable": "호스트에 연결할 수 없습니다. 온라인 상태이고 연결 가능한지 확인하세요.",
"noServer": "이 호스트에서 tmux 서버가 실행 중이 아닙니다.",
"searchTruncated": "일부 결과 — 각 Pane의 마지막 {{lines}}줄과 최대 {{panes}}개의 Pane을 검색합니다.",
"closeSearchResults": "검색 결과 닫기",
"retry": "재시도",
"noHosts": "사용 가능한 SSH 호스트가 없습니다.",
"noHostsHint": "SSH 호스트에서 Tmux Monitor 옵션을 활성화하여 (호스트 관리자 → 터미널 탭) tmux 세션을 모니터링하세요.",
"tmuxInstallHint": "호스트에 설치하려면:",
"attachTooltip": "{{host}}에 터미널 열기",
"attachTooltipPane": "{{host}}에 터미널 열기 — tmux 세션 {{session}}",
"timeJustNow": "방금",
"timeMinutes": "{{count}}분 전",
"timeHours": "{{count}}시간 전",
"timeDays": "{{count}}일 전"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux Monitor"
},
"hosts": {
"enableTmuxMonitor": "Tmux Monitor inschakelen",
"enableTmuxMonitorDesc": "Toon deze host in de Tmux Monitor en voeg de bijbehorende Tmux-acties toe aan de zijbalk."
},
"tmuxMonitor": {
"title": "Tmux Monitor",
"failedToLoadHosts": "Het laden van hosts is mislukt.",
"failedToLoad": "Het laden van tmux-sessies is mislukt.",
"tmuxUnavailable": "Tmux is niet geïnstalleerd op deze host.",
"noSessions": "Er zijn geen tmux-sessies op deze host.",
"noHostSelected": "Geen host geselecteerd",
"attached": "Bijgevoegd",
"detached": "Losgemaakt",
"attach": "Bijvoegen",
"editTags": "Labels bewerken",
"tagsHint": "Tags gescheiden door komma's (bijv. YOLO, lab, training)",
"tagsSaved": "Tags opgeslagen",
"tagsSaveFailed": "Het opslaan van tags is mislukt.",
"searchPlaceholder": "Zoekresultaten over meerdere sessies heen...",
"searchResults": "{{count}} overeenkomsten",
"searchFailed": "Zoeken mislukt",
"selectPaneHint": "Selecteer een venster om de uitvoer ervan te bekijken.",
"closePreview": "Voorbeeld sluiten",
"newSession": "Nieuwe sessie",
"newSessionHint": "Sessienaam (letters, cijfers, _ @ % + = -)",
"newSessionPlaceholder": "mijn sessie",
"create": "Creëren",
"sessionCreated": "Sessie \"{{name}}\" aangemaakt",
"sessionCreateFailed": "Het aanmaken van de sessie is mislukt.",
"splitRight": "Rechts gesplitst",
"splitDown": "Splitsen",
"splitFailed": "Het splitsen van het venster is mislukt.",
"newWindow": "Nieuw venster",
"windowCreateFailed": "Het is niet gelukt om het venster te maken.",
"attachSessionTooltip": "Bevestigen aan {{session}}",
"refresh": "Vernieuwen",
"refreshFailed": "Sessies konden niet worden vernieuwd.",
"collapseAll": "Alles inklappen",
"expandAll": "Alles uitvouwen",
"moreActions": "Meer acties",
"sessionStats": "Sessiestatistieken",
"renameSessionTitle": "Sessie hernoemen \"{{name}}\"",
"rename": "Naam wijzigen",
"sessionRenamed": "Sessie hernoemd naar \"{{name}}\"",
"sessionRenameFailed": "Het hernoemen van de sessie is mislukt.",
"editTagsTitle": "Bewerk tags voor \"{{name}}\"",
"killPane": "Doodvenster",
"resizeTree": "Sleep om het formaat aan te passen — dubbelklik om terug te zetten naar de beginpositie",
"reattach": "Opnieuw koppelen (corrigeert een vervormde weergave)",
"killWindow": "Dood venster",
"killWindowTitle": "Venster {{index}} van \"{{session}} \" verwijderen?",
"killWindowBody": "Alle vensters en processen in dit venster worden beëindigd. Het sluiten van het laatste venster beëindigt de sessie.",
"windowKillFailed": "Het is niet gelukt om het venster te sluiten.",
"statusActivity": "Activiteit",
"statusWindows": "Windows",
"statusPanes": "ruiten",
"statusTags": "Tags",
"killPaneTitle": "Kill pane {{id}}?",
"killPaneBody": "Het proces dat in dit venster wordt uitgevoerd, wordt beëindigd. Door het laatste venster te sluiten, wordt het bijbehorende venster gesloten.",
"paneKillFailed": "Het is niet gelukt om het venster te sluiten.",
"killSessionTitle": "Sessie beëindigen \"{{name}}\"?",
"killSessionBody": "Alle vensters en actieve processen in deze sessie worden beëindigd. Dit kan niet ongedaan worden gemaakt.",
"kill": "Doden",
"sessionKilled": "Sessie \"{{name}}\" is beëindigd",
"sessionKillFailed": "Sessie kon niet worden beëindigd",
"hostUnreachable": "Er kon geen verbinding met de host worden gemaakt. Controleer of deze online en bereikbaar is.",
"noServer": "Er draait geen tmux-server op deze host.",
"searchTruncated": "Gedeeltelijke resultaten — de zoekopdracht omvat de laatste {{lines}} regels van elk venster en maximaal {{panes}} vensters.",
"closeSearchResults": "Zoekresultaten sluiten",
"retry": "Opnieuw proberen",
"noHosts": "Geen SSH-hosts beschikbaar",
"noHostsHint": "Schakel de Tmux Monitor-optie in op een SSH-host (Host Manager → Terminal-tabblad) om de tmux-sessies te bewaken.",
"tmuxInstallHint": "Installeer het op de host met:",
"attachTooltip": "Open een terminal naar {{host}}",
"attachTooltipPane": "Open een terminal naar {{host}} — tmux-sessie {{session}}",
"timeJustNow": "zojuist",
"timeMinutes": "{{count}}m geleden",
"timeHours": "{{count}}uur geleden",
"timeDays": "{{count}}dagen geleden"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux-skjerm"
},
"hosts": {
"enableTmuxMonitor": "Aktiver Tmux-monitor",
"enableTmuxMonitorDesc": "Vis denne verten i Tmux-monitoren og legg til tmux-handlingene i sidefeltet"
},
"tmuxMonitor": {
"title": "Tmux-skjerm",
"failedToLoadHosts": "Kunne ikke laste inn verter",
"failedToLoad": "Kunne ikke laste inn tmux-økter",
"tmuxUnavailable": "tmux er ikke installert på denne verten",
"noSessions": "Ingen tmux-økter på denne verten",
"noHostSelected": "Ingen vert valgt",
"attached": "Vedlagt",
"detached": "Frittstående",
"attach": "Feste",
"editTags": "Rediger tagger",
"tagsHint": "Kommaseparerte tagger (f.eks. YOLO, lab, trening)",
"tagsSaved": "Tagger lagret",
"tagsSaveFailed": "Kunne ikke lagre taggene",
"searchPlaceholder": "Søk i utdata på tvers av økter...",
"searchResults": "{{count}} treff",
"searchFailed": "Søket mislyktes",
"selectPaneHint": "Velg en rute for å forhåndsvise resultatet",
"closePreview": "Lukk forhåndsvisning",
"newSession": "Ny økt",
"newSessionHint": "Øktnavn (bokstaver, sifre, _ @ % + = -)",
"newSessionPlaceholder": "min-økt",
"create": "Skape",
"sessionCreated": "Økt «{{name}}» opprettet",
"sessionCreateFailed": "Kunne ikke opprette økten",
"splitRight": "Del høyre",
"splitDown": "Del ned",
"splitFailed": "Kunne ikke dele ruten",
"newWindow": "Nytt vindu",
"windowCreateFailed": "Kunne ikke opprette vinduet",
"attachSessionTooltip": "Fest til {{session}}",
"refresh": "Forfriske",
"refreshFailed": "Kunne ikke oppdatere øktene",
"collapseAll": "Skjul alle",
"expandAll": "Utvid alle",
"moreActions": "Flere handlinger",
"sessionStats": "Øktstatistikk",
"renameSessionTitle": "Gi økten nytt navn «{{name}}»",
"rename": "Gi nytt navn",
"sessionRenamed": "Økten har fått nytt navn til «{{name}}»",
"sessionRenameFailed": "Kunne ikke gi økten nytt navn",
"editTagsTitle": "Rediger tagger for «{{name}}»",
"killPane": "Drep ruten",
"resizeTree": "Dra for å endre størrelse – dobbeltklikk for å tilbakestille",
"reattach": "Fest på nytt (fikser en forvrengt visning)",
"killWindow": "Drep vinduet",
"killWindowTitle": "Drep vinduet {{index}} av «{{session}}»?",
"killWindowBody": "Alle ruter og prosesser i dette vinduet vil bli avsluttet. Hvis du avslutter det siste vinduet, avsluttes økten.",
"windowKillFailed": "Klarte ikke å avslutte vinduet",
"statusActivity": "Aktivitet",
"statusWindows": "Vinduer",
"statusPanes": "ruter",
"statusTags": "Tagger",
"killPaneTitle": "Drep ruten {{id}}?",
"killPaneBody": "Prosessen som kjører i denne ruten vil bli avsluttet. Hvis du avslutter den siste ruten, lukkes vinduet.",
"paneKillFailed": "Kunne ikke avslutte ruten",
"killSessionTitle": "Avslutt økten «{{name}}»?",
"killSessionBody": "Alle vinduer og kjørende prosesser i denne økten vil bli avsluttet. Dette kan ikke angres.",
"kill": "Drepe",
"sessionKilled": "Økten «{{name}}» ble drept",
"sessionKillFailed": "Klarte ikke å avslutte økten",
"hostUnreachable": "Kunne ikke koble til verten. Sjekk at den er online og tilgjengelig.",
"noServer": "Ingen tmux-server kjører på denne verten.",
"searchTruncated": "Delvise resultater — søket dekker de siste {{lines}} linjene i hver rute og maksimalt {{panes}} ruter.",
"closeSearchResults": "Lukk søkeresultatene",
"retry": "Prøv på nytt",
"noHosts": "Ingen SSH-verter tilgjengelig",
"noHostsHint": "Aktiver Tmux Monitor-alternativet på en SSH-vert (Vertsadministrator → Terminal-fanen) for å overvåke tmux-øktene.",
"tmuxInstallHint": "Installer den på verten med:",
"attachTooltip": "Åpne en terminal til {{host}}",
"attachTooltipPane": "Åpne en terminal til {{host}} — tmux-økt {{session}}",
"timeJustNow": "akkurat nå",
"timeMinutes": "For {{count}}minutter siden",
"timeHours": "for {{count}}t siden",
"timeDays": "for {{count}}dager siden"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Monitor Tmux"
},
"hosts": {
"enableTmuxMonitor": "Włącz monitor Tmux",
"enableTmuxMonitorDesc": "Pokaż tego hosta w monitorze Tmux i dodaj jego akcje tmux do paska bocznego"
},
"tmuxMonitor": {
"title": "Monitor Tmux",
"failedToLoadHosts": "Nie udało się załadować hostów",
"failedToLoad": "Nie udało się załadować sesji tmux",
"tmuxUnavailable": "tmux nie jest zainstalowany na tym hoście",
"noSessions": "Brak sesji tmux na tym hoście",
"noHostSelected": "Nie wybrano hosta",
"attached": "Dołączona",
"detached": "Odłączona",
"attach": "Dołącz",
"editTags": "Edytuj tagi",
"tagsHint": "Tagi rozdzielone przecinkami (np. YOLO, laboratorium, szkolenie)",
"tagsSaved": "Zapisano tagi",
"tagsSaveFailed": "Nie udało się zapisać tagów",
"searchPlaceholder": "Przeszukaj wyjście we wszystkich sesjach...",
"searchResults": "{{count}} dopasowań",
"searchFailed": "Wyszukiwanie nie powiodło się",
"selectPaneHint": "Wybierz panel, aby zobaczyć podgląd jego wyjścia",
"closePreview": "Zamknij podgląd",
"newSession": "Nowa sesja",
"newSessionHint": "Nazwa sesji (litery, cyfry, _ @ % + = -)",
"newSessionPlaceholder": "moja-sesja",
"create": "Utwórz",
"sessionCreated": "Utworzono sesję „{{name}}”",
"sessionCreateFailed": "Nie udało się utworzyć sesji",
"splitRight": "Podziel w prawo",
"splitDown": "Podziel w dół",
"splitFailed": "Nie udało się podzielić panelu",
"newWindow": "Nowe okno",
"windowCreateFailed": "Nie udało się utworzyć okna",
"attachSessionTooltip": "Dołącz do {{session}}",
"refresh": "Odśwież",
"refreshFailed": "Nie udało się odświeżyć sesji",
"collapseAll": "Zwiń wszystko",
"expandAll": "Rozwiń wszystko",
"moreActions": "Więcej akcji",
"sessionStats": "Statystyki sesji",
"renameSessionTitle": "Zmień nazwę sesji „{{name}}”",
"rename": "Zmień nazwę",
"sessionRenamed": "Zmieniono nazwę sesji na „{{name}}”",
"sessionRenameFailed": "Nie udało się zmienić nazwy sesji",
"editTagsTitle": "Edytuj tagi dla „{{name}}”",
"killPane": "Zakończ panel",
"resizeTree": "Przeciągnij, aby zmienić rozmiar; kliknij dwukrotnie, aby zresetować",
"reattach": "Dołącz ponownie (naprawia zniekształcony widok)",
"killWindow": "Zakończ okno",
"killWindowTitle": "Zakończyć okno {{index}} sesji „{{session}}”?",
"killWindowBody": "Wszystkie panele i procesy w tym oknie zostaną zakończone. Zakończenie ostatniego okna kończy sesję.",
"windowKillFailed": "Nie udało się zakończyć okna",
"statusActivity": "Aktywność",
"statusWindows": "Okna",
"statusPanes": "panele",
"statusTags": "Tagi",
"killPaneTitle": "Zakończyć panel {{id}}?",
"killPaneBody": "Proces działający w tym panelu zostanie zakończony. Zakończenie ostatniego panelu zamyka jego okno.",
"paneKillFailed": "Nie udało się zakończyć panelu",
"killSessionTitle": "Zakończyć sesję „{{name}}”?",
"killSessionBody": "Wszystkie okna i działające procesy w tej sesji zostaną zakończone. Tej operacji nie można cofnąć.",
"kill": "Zakończ",
"sessionKilled": "Sesja „{{name}}” została zakończona",
"sessionKillFailed": "Nie udało się zakończyć sesji",
"hostUnreachable": "Nie udało się połączyć z hostem. Sprawdź, czy jest on online i osiągalny.",
"noServer": "Na tym hoście nie działa żaden serwer tmux.",
"searchTruncated": "Wyniki częściowe: wyszukiwanie obejmuje ostatnie {{lines}} wierszy każdego panelu i maksymalnie {{panes}} paneli.",
"closeSearchResults": "Zamknij wyniki wyszukiwania",
"retry": "Ponów",
"noHosts": "Brak dostępnych hostów SSH",
"noHostsHint": "Włącz opcję Monitor Tmux na hoście SSH (Menedżer hostów → karta Terminal), aby monitorować jego sesje tmux.",
"tmuxInstallHint": "Zainstaluj go na hoście za pomocą:",
"attachTooltip": "Otwórz terminal do {{host}}",
"attachTooltipPane": "Otwórz terminal do {{host}}, sesja tmux {{session}}",
"timeJustNow": "przed chwilą",
"timeMinutes": "{{count}} min temu",
"timeHours": "{{count}} godz. temu",
"timeDays": "{{count}} dni temu"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Monitor Tmux"
},
"hosts": {
"enableTmuxMonitor": "Ativar monitor Tmux",
"enableTmuxMonitorDesc": "Exiba este host no Monitor Tmux e adicione suas ações tmux à barra lateral."
},
"tmuxMonitor": {
"title": "Monitor Tmux",
"failedToLoadHosts": "Falha ao carregar os hosts",
"failedToLoad": "Falha ao carregar as sessões do tmux",
"tmuxUnavailable": "O tmux não está instalado neste host.",
"noSessions": "Não há sessões tmux neste host.",
"noHostSelected": "Nenhum host selecionado",
"attached": "Apegado",
"detached": "Desapegado",
"attach": "Anexar",
"editTags": "Editar etiquetas",
"tagsHint": "Etiquetas separadas por vírgulas (ex: YOLO, laboratório, treinamento)",
"tagsSaved": "Etiquetas salvas",
"tagsSaveFailed": "Falha ao salvar as tags",
"searchPlaceholder": "Resultados da pesquisa em todas as sessões...",
"searchResults": "{{count}} correspondências",
"searchFailed": "A busca falhou",
"selectPaneHint": "Selecione um painel para visualizar sua saída.",
"closePreview": "Fechar pré-visualização",
"newSession": "Nova sessão",
"newSessionHint": "Nome da sessão (letras, dígitos, _ @ % + = -)",
"newSessionPlaceholder": "minha-sessão",
"create": "Criar",
"sessionCreated": "Sessão \"{{name}}\" criada",
"sessionCreateFailed": "Falha ao criar sessão",
"splitRight": "Dividir à direita",
"splitDown": "Dividir em partes",
"splitFailed": "Falha ao dividir o painel",
"newWindow": "Nova janela",
"windowCreateFailed": "Falha ao criar a janela",
"attachSessionTooltip": "Anexe a {{session}}",
"refresh": "Atualizar",
"refreshFailed": "Falha ao atualizar as sessões",
"collapseAll": "Recolher tudo",
"expandAll": "Expandir tudo",
"moreActions": "Mais ações",
"sessionStats": "Estatísticas da sessão",
"renameSessionTitle": "Renomear sessão \"{{name}}\"",
"rename": "Renomear",
"sessionRenamed": "Sessão renomeada para \"{{name}}\"",
"sessionRenameFailed": "Falha ao renomear a sessão",
"editTagsTitle": "Editar tags para \"{{name}}\"",
"killPane": "Painel de eliminação",
"resizeTree": "Arraste para redimensionar — clique duas vezes para redefinir",
"reattach": "Reconectar (corrige uma visualização distorcida)",
"killWindow": "Janela de eliminação",
"killWindowTitle": "Fechar janela {{index}} de \"{{session}}\"?",
"killWindowBody": "Todos os painéis e processos nesta janela serão encerrados. Fechar a última janela finaliza a sessão.",
"windowKillFailed": "Falha ao fechar a janela",
"statusActivity": "Atividade",
"statusWindows": "Windows",
"statusPanes": "painéis",
"statusTags": "Etiquetas",
"killPaneTitle": "Painel de encerramento {{id}}?",
"killPaneBody": "O processo em execução neste painel será encerrado. Fechar o último painel fecha a janela correspondente.",
"paneKillFailed": "Falha ao desligar o painel",
"killSessionTitle": "Sessão de encerramento \"{{name}}\"?",
"killSessionBody": "Todas as janelas e processos em execução nesta sessão serão encerrados. Esta ação é irreversível.",
"kill": "Matar",
"sessionKilled": "Sessão \"{{name}}\" encerrada",
"sessionKillFailed": "Falha ao encerrar a sessão",
"hostUnreachable": "Não foi possível conectar ao host. Verifique se ele está online e acessível.",
"noServer": "Não há nenhum servidor tmux em execução neste host.",
"searchTruncated": "Resultados parciais — a pesquisa abrange as últimas {{lines}} linhas de cada painel e no máximo {{panes}} painéis.",
"closeSearchResults": "Fechar resultados da pesquisa",
"retry": "Tentar novamente",
"noHosts": "Não há hosts SSH disponíveis.",
"noHostsHint": "Habilite a opção Monitor do Tmux em um host SSH (Gerenciador de Hosts → guia Terminal) para monitorar suas sessões do tmux.",
"tmuxInstallHint": "Instale-o no host com:",
"attachTooltip": "Abra um terminal para {{host}}",
"attachTooltipPane": "Abra um terminal para {{host}} — sessão tmux {{session}}",
"timeJustNow": "agora mesmo",
"timeMinutes": "{{count}}m atrás",
"timeHours": "{{count}}h atrás",
"timeDays": "{{count}}há alguns dias"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Monitor Tmux"
},
"hosts": {
"enableTmuxMonitor": "Ativar Monitor Tmux",
"enableTmuxMonitorDesc": "Mostrar este host no Monitor Tmux e adicionar as suas ações tmux à barra lateral"
},
"tmuxMonitor": {
"title": "Monitor Tmux",
"failedToLoadHosts": "Falha ao carregar anfitriões",
"failedToLoad": "Falha ao carregar sessões tmux",
"tmuxUnavailable": "tmux não está instalado neste anfitrião",
"noSessions": "Sem sessões tmux neste anfitrião",
"noHostSelected": "Nenhum anfitrião selecionado",
"attached": "Anexado",
"detached": "Desanexado",
"attach": "Anexar",
"editTags": "Editar etiquetas",
"tagsHint": "Etiquetas separadas por vírgulas (ex: YOLO, lab, training)",
"tagsSaved": "Etiquetas guardadas",
"tagsSaveFailed": "Falha ao guardar etiquetas",
"searchPlaceholder": "Pesquisar saída em todas as sessões...",
"searchResults": "{{count}} correspondências",
"searchFailed": "Pesquisa falhou",
"selectPaneHint": "Selecione um painel para pré-visualizar a sua saída",
"closePreview": "Fechar pré-visualização",
"newSession": "Nova sessão",
"newSessionHint": "Nome da sessão (letras, dígitos, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "Criar",
"sessionCreated": "Sessão \"{{name}}\" criada",
"sessionCreateFailed": "Falha ao criar sessão",
"splitRight": "Dividir à direita",
"splitDown": "Dividir para baixo",
"splitFailed": "Falha ao dividir painel",
"newWindow": "Nova janela",
"windowCreateFailed": "Falha ao criar janela",
"attachSessionTooltip": "Anexar a {{session}}",
"refresh": "Atualizar",
"refreshFailed": "Falha ao atualizar sessões",
"collapseAll": "Recolher tudo",
"expandAll": "Expandir tudo",
"moreActions": "Mais ações",
"sessionStats": "Estatísticas da sessão",
"renameSessionTitle": "Renomear sessão \"{{name}}\"",
"rename": "Renomear",
"sessionRenamed": "Sessão renomeada para \"{{name}}\"",
"sessionRenameFailed": "Falha ao renomear sessão",
"editTagsTitle": "Editar etiquetas para \"{{name}}\"",
"killPane": "Terminar painel",
"resizeTree": "Arraste para redimensionar — clique duplo para repor",
"reattach": "Reanexar (corrige uma visualização distorcida)",
"killWindow": "Terminar janela",
"killWindowTitle": "Terminar janela {{index}} de \"{{session}}\"?",
"killWindowBody": "Todos os painéis e processos nesta janela serão terminados. Terminar a última janela encerra a sessão.",
"windowKillFailed": "Falha ao terminar janela",
"statusActivity": "Atividade",
"statusWindows": "Janelas",
"statusPanes": "painéis",
"statusTags": "Etiquetas",
"killPaneTitle": "Terminar painel {{id}}?",
"killPaneBody": "O processo em execução neste painel será terminado. Terminar o último painel fecha a sua janela.",
"paneKillFailed": "Falha ao terminar painel",
"killSessionTitle": "Terminar sessão \"{{name}}\"?",
"killSessionBody": "Todas as janelas e processos em execução nesta sessão serão terminados. Isto não pode ser desfeito.",
"kill": "Terminar",
"sessionKilled": "Sessão \"{{name}}\" terminada",
"sessionKillFailed": "Falha ao terminar sessão",
"hostUnreachable": "Não foi possível ligar ao anfitrião. Verifique se está online e acessível.",
"noServer": "Não há nenhum servidor tmux em execução neste anfitrião.",
"searchTruncated": "Resultados parciais — a pesquisa abrange as últimas {{lines}} linhas de cada painel e, no máximo, {{panes}} painéis.",
"closeSearchResults": "Fechar resultados da pesquisa",
"retry": "Tentar novamente",
"noHosts": "Nenhum anfitrião SSH disponível",
"noHostsHint": "Ative a opção Monitor Tmux num anfitrião SSH (Gestor de Anfitriões → separador Terminal) para monitorizar as suas sessões tmux.",
"tmuxInstallHint": "Instale-o no anfitrião com:",
"attachTooltip": "Abrir um terminal para {{host}}",
"attachTooltipPane": "Abrir um terminal para {{host}} — sessão tmux {{session}}",
"timeJustNow": "agora mesmo",
"timeMinutes": "há {{count}} min",
"timeHours": "há {{count}} h",
"timeDays": "há {{count}} d"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Monitorul Tmux"
},
"hosts": {
"enableTmuxMonitor": "Activează monitorul Tmux",
"enableTmuxMonitorDesc": "Afișează această gazdă în Monitorul Tmux și adaugă acțiunile sale tmux în bara laterală"
},
"tmuxMonitor": {
"title": "Monitorul Tmux",
"failedToLoadHosts": "Nu s-au putut încărca gazdele",
"failedToLoad": "Nu s-au putut încărca sesiunile tmux",
"tmuxUnavailable": "tmux nu este instalat pe această gazdă",
"noSessions": "Nicio sesiune tmux pe această gazdă",
"noHostSelected": "Nicio gazdă selectată",
"attached": "Ataşat",
"detached": "Detașat",
"attach": "Atașați",
"editTags": "Editați etichetele",
"tagsHint": "Etichete separate prin virgulă (de exemplu, YOLO, laborator, antrenament)",
"tagsSaved": "Etichete salvate",
"tagsSaveFailed": "Nu s-au putut salva etichetele",
"searchPlaceholder": "Rezultatul căutării în sesiuni...",
"searchResults": "{{count}} potriviri",
"searchFailed": "Căutarea a eșuat",
"selectPaneHint": "Selectați un panou pentru a previzualiza rezultatul acestuia",
"closePreview": "Închide previzualizarea",
"newSession": "Sesiune nouă",
"newSessionHint": "Numele sesiunii (litere, cifre, _ @ % + = -)",
"newSessionPlaceholder": "sesiunea mea",
"create": "Crea",
"sessionCreated": "Sesiunea „{{name}}” a fost creată",
"sessionCreateFailed": "Nu s-a putut crea sesiunea",
"splitRight": "Împărțire la dreapta",
"splitDown": "Împărțiți",
"splitFailed": "Nu s-a putut diviza panoul",
"newWindow": "Fereastră nouă",
"windowCreateFailed": "Nu s-a putut crea fereastra",
"attachSessionTooltip": "Atașați la {{session}}",
"refresh": "Reîmprospăta",
"refreshFailed": "Nu s-au putut actualiza sesiunile",
"collapseAll": "Restrângeți tot",
"expandAll": "Extindeți tot",
"moreActions": "Mai multe acțiuni",
"sessionStats": "Statistici ale sesiunii",
"renameSessionTitle": "Redenumiți sesiunea „{{name}}”",
"rename": "Redenumire",
"sessionRenamed": "Sesiunea a fost redenumită „{{name}}”",
"sessionRenameFailed": "Nu s-a putut redenumi sesiunea",
"editTagsTitle": "Editați etichetele pentru „{{name}}”",
"killPane": "Panou de închidere",
"resizeTree": "Trageți pentru redimensionare — faceți dublu clic pentru resetare",
"reattach": "Reatașare (corectează o vizualizare deformată)",
"killWindow": "Fereastră de închidere",
"killWindowTitle": "Fereastră de închidere {{index}} a ferestrei „{{session}}”?",
"killWindowBody": "Fiecare panou și proces din această fereastră va fi terminat. Închiderea ultimei ferestre încheie sesiunea.",
"windowKillFailed": "Nu s-a putut închide fereastra",
"statusActivity": "Activitate",
"statusWindows": "Ferestre",
"statusPanes": "geamuri",
"statusTags": "Etichete",
"killPaneTitle": "Panou de închidere {{id}}?",
"killPaneBody": "Procesul care rulează în acest panou va fi terminat. Închiderea ultimului panou îi închide fereastra.",
"paneKillFailed": "Nu s-a putut închide panoul",
"killSessionTitle": "Închide sesiunea „{{name}}”?",
"killSessionBody": "Toate ferestrele și procesele care rulează în această sesiune vor fi terminate. Această acțiune nu poate fi anulată.",
"kill": "Ucide",
"sessionKilled": "Sesiunea „{{name}}” a fost închisă",
"sessionKillFailed": "Sesiunea nu a putut fi închisă",
"hostUnreachable": "Nu s-a putut conecta la gazdă. Verificați dacă este online și accesibilă.",
"noServer": "Niciun server tmux nu rulează pe această gazdă.",
"searchTruncated": "Rezultate parțiale — căutarea acoperă ultimele {{lines}} linii ale fiecărui panou și cel mult {{panes}} panouri.",
"closeSearchResults": "Închide rezultatele căutării",
"retry": "Reîncercați",
"noHosts": "Nu sunt disponibile gazde SSH",
"noHostsHint": "Activați opțiunea Monitor Tmux pe o gazdă SSH (fila Manager gazdă → Terminal) pentru a monitoriza sesiunile tmux ale acesteia.",
"tmuxInstallHint": "Instalați-l pe gazdă cu:",
"attachTooltip": "Deschideți un terminal cu {{host}}",
"attachTooltipPane": "Deschideți un terminal cu {{host}} — sesiune tmux {{session}}",
"timeJustNow": "chiar acum",
"timeMinutes": "{{count}}acum câteva minute",
"timeHours": "{{count}}acum o oră",
"timeDays": "{{count}}zile în urmă"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Монитор Tmux"
},
"hosts": {
"enableTmuxMonitor": "Включить монитор Tmux",
"enableTmuxMonitorDesc": "Показывать этот хост в Мониторе Tmux и добавлять действия tmux в боковую панель"
},
"tmuxMonitor": {
"title": "Монитор Tmux",
"failedToLoadHosts": "Ошибка загрузки хостов",
"failedToLoad": "Ошибка загрузки сессий tmux",
"tmuxUnavailable": "tmux не установлен на этом хосте",
"noSessions": "Нет сессий tmux на этом хосте",
"noHostSelected": "Хост не выбран",
"attached": "Присоединён",
"detached": "Отсоединён",
"attach": "Присоединить",
"editTags": "Изменить теги",
"tagsHint": "Теги через запятую (например, YOLO, lab, training)",
"tagsSaved": "Теги сохранены",
"tagsSaveFailed": "Не удалось сохранить теги",
"searchPlaceholder": "Поиск вывода по сессиям...",
"searchResults": "{{count}} совпадений",
"searchFailed": "Поиск не удался",
"selectPaneHint": "Выберите панель для предпросмотра её вывода",
"closePreview": "Закрыть предпросмотр",
"newSession": "Новая сессия",
"newSessionHint": "Имя сессии (буквы, цифры, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "Создать",
"sessionCreated": "Сессия \"{{name}}\" создана",
"sessionCreateFailed": "Не удалось создать сессию",
"splitRight": "Разделить вправо",
"splitDown": "Разделить вниз",
"splitFailed": "Не удалось разделить панель",
"newWindow": "Новое окно",
"windowCreateFailed": "Не удалось создать окно",
"attachSessionTooltip": "Присоединиться к {{session}}",
"refresh": "Обновить",
"refreshFailed": "Не удалось обновить сессии",
"collapseAll": "Свернуть всё",
"expandAll": "Развернуть всё",
"moreActions": "Дополнительные действия",
"sessionStats": "Статистика сессии",
"renameSessionTitle": "Переименовать сессию \"{{name}}\"",
"rename": "Переименовать",
"sessionRenamed": "Сессия переименована в \"{{name}}\"",
"sessionRenameFailed": "Не удалось переименовать сессию",
"editTagsTitle": "Изменить теги для \"{{name}}\"",
"killPane": "Завершить панель",
"resizeTree": "Перетащите, чтобы изменить размер — двойной клик для сброса",
"reattach": "Переподключиться (исправляет искажённое отображение)",
"killWindow": "Завершить окно",
"killWindowTitle": "Завершить окно {{index}} сессии \"{{session}}\"?",
"killWindowBody": "Все панели и процессы в этом окне будут завершены. Завершение последнего окна завершит сессию.",
"windowKillFailed": "Не удалось завершить окно",
"statusActivity": "Активность",
"statusWindows": "Окна",
"statusPanes": "панели",
"statusTags": "Теги",
"killPaneTitle": "Завершить панель {{id}}?",
"killPaneBody": "Процесс, запущенный в этой панели, будет завершён. Завершение последней панели закроет её окно.",
"paneKillFailed": "Не удалось завершить панель",
"killSessionTitle": "Завершить сессию \"{{name}}\"?",
"killSessionBody": "Все окна и запущенные процессы в этой сессии будут завершены. Это действие нельзя отменить.",
"kill": "Завершить",
"sessionKilled": "Сессия \"{{name}}\" завершена",
"sessionKillFailed": "Не удалось завершить сессию",
"hostUnreachable": "Не удалось подключиться к хосту. Проверьте, что он онлайн и доступен.",
"noServer": "На этом хосте не запущен сервер tmux.",
"searchTruncated": "Частичные результаты — поиск охватывает последние {{lines}} строк каждой панели и не более {{panes}} панелей.",
"closeSearchResults": "Закрыть результаты поиска",
"retry": "Повторить",
"noHosts": "Нет доступных SSH-хостов",
"noHostsHint": "Включите опцию Tmux Monitor для SSH-хоста (Host Manager → вкладка Terminal), чтобы отслеживать его tmux-сессии.",
"tmuxInstallHint": "Установите его на хост с помощью:",
"attachTooltip": "Открыть терминал на {{host}}",
"attachTooltipPane": "Открыть терминал на {{host}} — tmux-сессия {{session}}",
"timeJustNow": "только что",
"timeMinutes": "{{count}}м назад",
"timeHours": "{{count}}ч назад",
"timeDays": "{{count}}д назад"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux монитор"
},
"hosts": {
"enableTmuxMonitor": "Омогући Tmux монитор",
"enableTmuxMonitorDesc": "Прикажи овај хост у Tmux монитору и додај његове tmux акције у бочну траку"
},
"tmuxMonitor": {
"title": "Tmux монитор",
"failedToLoadHosts": "Учитавање хостова није успело",
"failedToLoad": "Учитавање tmux сесија није успело",
"tmuxUnavailable": "tmux није инсталиран на овом хосту",
"noSessions": "Нема tmux сесија на овом хосту",
"noHostSelected": "Није изабран домаћин",
"attached": "Приложено",
"detached": "Одвојено",
"attach": "Приложи",
"editTags": "Уреди ознаке",
"tagsHint": "Ознаке одвојене зарезима (нпр. YOLO, лабораторија, обука)",
"tagsSaved": "Ознаке сачуване",
"tagsSaveFailed": "Чување ознака није успело",
"searchPlaceholder": "Претрага резултата у свим сесијама...",
"searchResults": "{{count}} подударања",
"searchFailed": "Претрага није успела",
"selectPaneHint": "Изаберите окно да бисте прегледали његов резултат",
"closePreview": "Затвори преглед",
"newSession": "Нова сесија",
"newSessionHint": "Назив сесије (слова, цифре, _ @ % + = -)",
"newSessionPlaceholder": "моја-сесија",
"create": "Креирај",
"sessionCreated": "Сесија „{{name}}“ је креирана",
"sessionCreateFailed": "Није успело креирање сесије",
"splitRight": "Подели десно",
"splitDown": "Подели",
"splitFailed": "Подела окна није успела",
"newWindow": "Нови прозор",
"windowCreateFailed": "Није успело креирање прозора",
"attachSessionTooltip": "Приложи уз {{session}}",
"refresh": "Освежи",
"refreshFailed": "Освежавање сесија није успело",
"collapseAll": "Скупи све",
"expandAll": "Прошири све",
"moreActions": "Више акција",
"sessionStats": "Статистика сесије",
"renameSessionTitle": "Преименуј сесију „{{name}}“",
"rename": "Преименуј",
"sessionRenamed": "Сесија је преименована у „{{name}}“",
"sessionRenameFailed": "Преименовање сесије није успело",
"editTagsTitle": "Уреди ознаке за „{{name}}“",
"killPane": "Окно за заустављање",
"resizeTree": "Превуците да бисте променили величину — двапут кликните да бисте ресетовали",
"reattach": "Поново причврстите (поправља искривљени приказ)",
"killWindow": "Затвори прозор",
"killWindowTitle": "Да ли да затворим прозор {{index}} од „{{session}}“?",
"killWindowBody": "Сваки панел и процес у овом прозору биће завршени. Затварањем последњег прозора завршава се сесија.",
"windowKillFailed": "Затварање прозора није успело",
"statusActivity": "Активност",
"statusWindows": "Прозори",
"statusPanes": "окна",
"statusTags": "Ознаке",
"killPaneTitle": "Затвори окно {{id}}?",
"killPaneBody": "Процес који се извршава у овом окну биће прекинут. Затварањем последњег окна затвара се његов прозор.",
"paneKillFailed": "Није успело затварање окна",
"killSessionTitle": "Укинути сесију „{{name}}“?",
"killSessionBody": "Сви прозори и покренути процеси у овој сесији биће прекинути. Ова радња се не може поништити.",
"kill": "Убити",
"sessionKilled": "Сесија „{{name}}“ је завршена",
"sessionKillFailed": "Завршавање сесије није успело",
"hostUnreachable": "Није могуће повезати се са хостом. Проверите да ли је онлајн и доступан.",
"noServer": "Ниједан tmux сервер не ради на овом хосту.",
"searchTruncated": "Делимични резултати — претрага обухвата последњих {{lines}} редова сваког панела и највише {{panes}} панела.",
"closeSearchResults": "Затвори резултате претраге",
"retry": "Покушај поново",
"noHosts": "Нема доступних SSH хостова",
"noHostsHint": "Омогућите опцију Tmux Monitor на SSH хосту (Host Manager → картица Terminal) да бисте пратили његове tmux сесије.",
"tmuxInstallHint": "Инсталирајте га на хост помоћу:",
"attachTooltip": "Отворите терминал за {{host}}",
"attachTooltipPane": "Отворите терминал за {{host}} — tmux сесију {{session}}",
"timeJustNow": "управо сада",
"timeMinutes": "{{count}}пре мин",
"timeHours": "{{count}}пре сати",
"timeDays": "{{count}}пре д"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux-monitor"
},
"hosts": {
"enableTmuxMonitor": "Aktivera Tmux-monitorn",
"enableTmuxMonitorDesc": "Visa denna värd i Tmux Monitor och lägg till dess tmux-åtgärder i sidofältet"
},
"tmuxMonitor": {
"title": "Tmux-monitor",
"failedToLoadHosts": "Misslyckades med att ladda värdar",
"failedToLoad": "Misslyckades med att ladda tmux-sessioner",
"tmuxUnavailable": "tmux är inte installerat på den här värddatorn",
"noSessions": "Inga tmux-sessioner på den här värden",
"noHostSelected": "Ingen värd vald",
"attached": "Bunden",
"detached": "Fristående",
"attach": "Bifoga",
"editTags": "Redigera taggar",
"tagsHint": "Kommaavgränsade taggar (t.ex. YOLO, lab, utbildning)",
"tagsSaved": "Taggar sparade",
"tagsSaveFailed": "Det gick inte att spara taggarna",
"searchPlaceholder": "Sök utdata över flera sessioner...",
"searchResults": "{{count}} matchningar",
"searchFailed": "Sökningen misslyckades",
"selectPaneHint": "Välj en ruta för att förhandsgranska dess utdata",
"closePreview": "Stäng förhandsgranskningen",
"newSession": "Ny session",
"newSessionHint": "Sessionsnamn (bokstäver, siffror, _ @ % + = -)",
"newSessionPlaceholder": "min-session",
"create": "Skapa",
"sessionCreated": "Sessionen \"{{name}}\" skapades",
"sessionCreateFailed": "Misslyckades med att skapa sessionen",
"splitRight": "Dela höger",
"splitDown": "Dela upp",
"splitFailed": "Misslyckades med att dela rutan",
"newWindow": "Nytt fönster",
"windowCreateFailed": "Misslyckades med att skapa fönstret",
"attachSessionTooltip": "Bifoga till {{session}}",
"refresh": "Uppdatera",
"refreshFailed": "Det gick inte att uppdatera sessionerna",
"collapseAll": "Komprimera alla",
"expandAll": "Expandera alla",
"moreActions": "Fler åtgärder",
"sessionStats": "Sessionsstatistik",
"renameSessionTitle": "Byt namn på sessionen \"{{name}}\"",
"rename": "Döpa om",
"sessionRenamed": "Sessionen har bytt namn till \"{{name}}\"",
"sessionRenameFailed": "Misslyckades med att byta namn på sessionen",
"editTagsTitle": "Redigera taggar för \"{{name}}\"",
"killPane": "Döda rutan",
"resizeTree": "Dra för att ändra storlek — dubbelklicka för att återställa",
"reattach": "Sätt tillbaka (åtgärdar en förvrängd bild)",
"killWindow": "Döda fönstret",
"killWindowTitle": "Avsluta fönstret {{index}} för \"{{session}}\"?",
"killWindowBody": "Varje ruta och process i det här fönstret avslutas. Om du avslutar det sista fönstret avslutas sessionen.",
"windowKillFailed": "Misslyckades med att avsluta fönstret",
"statusActivity": "Aktivitet",
"statusWindows": "Fönster",
"statusPanes": "rutor",
"statusTags": "Taggar",
"killPaneTitle": "Avsluta rutan {{id}}?",
"killPaneBody": "Processen som körs i den här rutan kommer att avslutas. Om du avslutar den sista rutan stängs dess fönster.",
"paneKillFailed": "Misslyckades med att avsluta rutan",
"killSessionTitle": "Avsluta sessionen \"{{name}}\"?",
"killSessionBody": "Alla fönster och processer som körs i den här sessionen kommer att avslutas. Detta kan inte ångras.",
"kill": "Döda",
"sessionKilled": "Sessionen \"{{name}}\" avstängd",
"sessionKillFailed": "Misslyckades med att avsluta sessionen",
"hostUnreachable": "Kunde inte ansluta till värden. Kontrollera att den är online och nåbar.",
"noServer": "Ingen tmux-server körs på den här värden.",
"searchTruncated": "Delresultat — sökningen täcker de sista {{lines}} raderna i varje ruta och högst {{panes}} rutor.",
"closeSearchResults": "Stäng sökresultaten",
"retry": "Försöka igen",
"noHosts": "Inga SSH-värdar tillgängliga",
"noHostsHint": "Aktivera alternativet Tmux Monitor på en SSH-värd (Värdhanterare → Terminal-fliken) för att övervaka dess tmux-sessioner.",
"tmuxInstallHint": "Installera det på värden med:",
"attachTooltip": "Öppna en terminal till {{host}}",
"attachTooltipPane": "Öppna en terminal till {{host}} — tmux-session {{session}}",
"timeJustNow": "just nu",
"timeMinutes": "för {{count}}minuter sedan",
"timeHours": "för {{count}}timmar sedan",
"timeDays": "för {{count}}dagar sedan"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "มอนิเตอร์ Tmux"
},
"hosts": {
"enableTmuxMonitor": "เปิดใช้งาน Tmux Monitor",
"enableTmuxMonitorDesc": "แสดงโฮสต์นี้ใน Tmux Monitor และเพิ่มการดำเนินการ tmux ของโฮสต์นี้ลงในแถบด้านข้าง"
},
"tmuxMonitor": {
"title": "มอนิเตอร์ Tmux",
"failedToLoadHosts": "ไม่สามารถโหลดโฮสต์ได้",
"failedToLoad": "ไม่สามารถโหลดเซสชัน tmux ได้",
"tmuxUnavailable": "tmux ไม่ได้ติดตั้งอยู่ในโฮสต์นี้",
"noSessions": "ไม่มีเซสชัน tmux บนโฮสต์นี้",
"noHostSelected": "ไม่ได้เลือกโฮสต์",
"attached": "ที่แนบมา",
"detached": "แยกออก",
"attach": "แนบ",
"editTags": "แก้ไขแท็ก",
"tagsHint": "แท็กที่คั่นด้วยเครื่องหมายจุลภาค (เช่น YOLO, lab, training)",
"tagsSaved": "แท็กถูกบันทึกแล้ว",
"tagsSaveFailed": "ไม่สามารถบันทึกแท็กได้",
"searchPlaceholder": "ผลการค้นหาข้ามเซสชัน...",
"searchResults": "{{count}} ตรงกัน",
"searchFailed": "การค้นหาล้มเหลว",
"selectPaneHint": "เลือกบานหน้าต่างเพื่อดูตัวอย่างผลลัพธ์",
"closePreview": "ปิดการแสดงตัวอย่าง",
"newSession": "เซสชั่นใหม่",
"newSessionHint": "ชื่อเซสชัน (ตัวอักษร ตัวเลข _ @ % + = -)",
"newSessionPlaceholder": "เซสชั่นของฉัน",
"create": "สร้าง",
"sessionCreated": "เซสชัน \"{{name}}\" ถูกสร้างขึ้น",
"sessionCreateFailed": "ไม่สามารถสร้างเซสชันได้",
"splitRight": "แยกขวา",
"splitDown": "แยกออก",
"splitFailed": "ไม่สามารถแบ่งหน้าต่างได้",
"newWindow": "หน้าต่างใหม่",
"windowCreateFailed": "ไม่สามารถสร้างหน้าต่างได้",
"attachSessionTooltip": "แนบกับ {{session}}",
"refresh": "รีเฟรช",
"refreshFailed": "ไม่สามารถรีเฟรชเซสชันได้",
"collapseAll": "ยุบทั้งหมด",
"expandAll": "ขยายทั้งหมด",
"moreActions": "การดำเนินการเพิ่มเติม",
"sessionStats": "สถิติเซสชั่น",
"renameSessionTitle": "เปลี่ยนชื่อเซสชัน \"{{name}}\"",
"rename": "เปลี่ยนชื่อ",
"sessionRenamed": "เปลี่ยนชื่อเซสชันเป็น \"{{name}}\"",
"sessionRenameFailed": "ไม่สามารถเปลี่ยนชื่อเซสชันได้",
"editTagsTitle": "แก้ไขแท็กสำหรับ \"{{name}}\"",
"killPane": "หน้าต่างปิดการทำงาน",
"resizeTree": "ลากเพื่อปรับขนาด — ดับเบิ้ลคลิกเพื่อรีเซ็ต",
"reattach": "เชื่อมต่อใหม่ (แก้ไขภาพที่แสดงผลผิดเพี้ยน)",
"killWindow": "หน้าต่างสังหาร",
"killWindowTitle": "ปิดหน้าต่าง {{index}} ของ \"{{session}}\"?",
"killWindowBody": "หน้าต่างและกระบวนการทั้งหมดในหน้าต่างนี้จะถูกยุติ การปิดหน้าต่างสุดท้ายจะเป็นการสิ้นสุดเซสชัน",
"windowKillFailed": "ไม่สามารถปิดหน้าต่างได้",
"statusActivity": "กิจกรรม",
"statusWindows": "วินโดวส์",
"statusPanes": "บานหน้าต่าง",
"statusTags": "แท็ก",
"killPaneTitle": "แผงปิดการทำงาน {{id}}?",
"killPaneBody": "กระบวนการที่กำลังทำงานอยู่ในบานหน้าต่างนี้จะถูกยุติ การยุติบานหน้าต่างสุดท้ายจะปิดหน้าต่างนั้นลง",
"paneKillFailed": "ไม่สามารถปิดหน้าต่างได้",
"killSessionTitle": "เซสชั่นสังหาร \"{{name}}\"?",
"killSessionBody": "หน้าต่างและกระบวนการทำงานทั้งหมดในเซสชันนี้จะถูกปิดลง การดำเนินการนี้ไม่สามารถย้อนกลับได้",
"kill": "ฆ่า",
"sessionKilled": "เซสชั่น \"{{name}}\" ถูกฆ่า",
"sessionKillFailed": "ไม่สามารถยุติเซสชันได้",
"hostUnreachable": "ไม่สามารถเชื่อมต่อกับโฮสต์ได้ โปรดตรวจสอบว่าโฮสต์ออนไลน์และสามารถเข้าถึงได้",
"noServer": "ไม่มีเซิร์ฟเวอร์ tmux ทำงานอยู่บนโฮสต์นี้",
"searchTruncated": "ผลลัพธ์บางส่วน — การค้นหาครอบคลุมบรรทัดสุดท้าย {{lines}} บรรทัดของแต่ละบานหน้าต่าง และอย่างมากที่สุด {{panes}} บานหน้าต่าง",
"closeSearchResults": "ปิดผลการค้นหา",
"retry": "ลองใหม่อีกครั้ง",
"noHosts": "ไม่มีโฮสต์ SSH ให้บริการ",
"noHostsHint": "เปิดใช้งานตัวเลือก Tmux Monitor บนโฮสต์ SSH (Host Manager → แท็บ Terminal) เพื่อตรวจสอบเซสชัน tmux ของโฮสต์นั้น",
"tmuxInstallHint": "ติดตั้งบนเครื่องโฮสต์ด้วยคำสั่ง:",
"attachTooltip": "เปิดเทอร์มินัลไปที่ {{host}}",
"attachTooltipPane": "เปิดเทอร์มินัลไปที่ {{host}} — เซสชัน tmux {{session}}",
"timeJustNow": "เมื่อสักครู่นี้เอง",
"timeMinutes": "{{count}}นาทีที่แล้ว",
"timeHours": "{{count}}ชั่วโมงที่แล้ว",
"timeDays": "{{count}}วันก่อน"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux İzleyicisi"
},
"hosts": {
"enableTmuxMonitor": "Tmux İzleyiciyi Etkinleştir",
"enableTmuxMonitorDesc": "Bu ana bilgisayarı Tmux İzleyicide göster ve tmux eylemlerini kenar çubuğuna ekle"
},
"tmuxMonitor": {
"title": "Tmux İzleyici",
"failedToLoadHosts": "Sunucular yüklenemedi",
"failedToLoad": "Tmux oturumları yüklenemedi",
"tmuxUnavailable": "Bu sunucuda tmux yüklü değil",
"noSessions": "Bu sunucuda tmux oturumu yok",
"noHostSelected": "Sunucu seçilmedi",
"attached": "Bağlı",
"detached": "Bağlantısız",
"attach": "Bağlan",
"editTags": "Etiketleri düzenle",
"tagsHint": "Virgülle ayrılmış etiketler (örn. YOLO, lab, training)",
"tagsSaved": "Etiketler kaydedildi",
"tagsSaveFailed": "Etiketler kaydedilemedi",
"searchPlaceholder": "Oturumlar arasında çıktı ara...",
"searchResults": "{{count}} eşleşme",
"searchFailed": "Arama başarısız",
"selectPaneHint": "Çıktısını önizlemek için bir bölme seçin",
"closePreview": "Önizlemeyi kapat",
"newSession": "Yeni oturum",
"newSessionHint": "Oturum adı (harfler, rakamlar, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "Oluştur",
"sessionCreated": "Oturum \"{{name}}\" oluşturuldu",
"sessionCreateFailed": "Oturum oluşturulamadı",
"splitRight": "Sağa böl",
"splitDown": "Aşağı böl",
"splitFailed": "Bölme başarısız",
"newWindow": "Yeni pencere",
"windowCreateFailed": "Pencere oluşturulamadı",
"attachSessionTooltip": "{{session}} oturumuna bağlan",
"refresh": "Yenile",
"refreshFailed": "Yenileme başarısız",
"collapseAll": "Tümünü daralt",
"expandAll": "Tümünü genişlet",
"moreActions": "Diğer işlemler",
"sessionStats": "Oturum istatistikleri",
"renameSessionTitle": "Oturumu yeniden adlandır \"{{name}}\"",
"rename": "Yeniden adlandır",
"sessionRenamed": "Oturum \"{{name}}\" olarak yeniden adlandırıldı",
"sessionRenameFailed": "Oturum yeniden adlandırılamadı",
"editTagsTitle": "\"{{name}}\" için etiketleri düzenle",
"killPane": "Paneyi sonlandır",
"resizeTree": "Yeniden boyutlandırmak için sürükle — sıfırlamak için çift tıkla",
"reattach": "Yeniden bağlan (bozuk görünümü düzeltir)",
"killWindow": "Pencereyi sonlandır",
"killWindowTitle": "\"{{session}}\" oturumundaki {{index}} numaralı pencere sonlandırılsın mı?",
"killWindowBody": "Bu penceredeki tüm paneler ve işlemler sonlandırılacak. Son pencereyi sonlandırmak oturumu sona erdirir.",
"windowKillFailed": "Pencere sonlandırılamadı",
"statusActivity": "Etkinlik",
"statusWindows": "Pencereler",
"statusPanes": "Paneler",
"statusTags": "Etiketler",
"killPaneTitle": "{{id}} numaralı pane sonlandırılsın mı?",
"killPaneBody": "Bu panede çalışan işlem sonlandırılacak. Son paneyi sonlandırmak penceresini kapatır.",
"paneKillFailed": "Pane sonlandırılamadı",
"killSessionTitle": "\"{{name}}\" oturumu sonlandırılsın mı?",
"killSessionBody": "Bu oturumdaki tüm pencereler ve çalışan işlemler sonlandırılacak. Bu işlem geri alınamaz.",
"kill": "Sonlandır",
"sessionKilled": "\"{{name}}\" oturumu sonlandırıldı",
"sessionKillFailed": "Oturum sonlandırılamadı",
"hostUnreachable": "Ana bilgisayara bağlanılamadı. Çevrimiçi ve erişilebilir olduğunu kontrol edin.",
"noServer": "Bu ana bilgisayarda tmux sunucusu çalışmıyor.",
"searchTruncated": "Kısmi sonuçlar — arama her panenin son {{lines}} satırını ve en fazla {{panes}} paneyi kapsar.",
"closeSearchResults": "Arama sonuçlarını kapat",
"retry": "Yeniden dene",
"noHosts": "Kullanılabilir SSH ana bilgisayarı yok",
"noHostsHint": "Tmux oturumlarını izlemek için bir SSH ana bilgisayarında Tmux Monitor seçeneğini etkinleştirin (Ana Bilgisayar Yöneticisi → Terminal sekmesi).",
"tmuxInstallHint": "Ana bilgisayara şu komutla yükleyin:",
"attachTooltip": "{{host}} için bir terminal aç",
"attachTooltipPane": "{{host}} için bir terminal aç — tmux oturumu {{session}}",
"timeJustNow": "az önce",
"timeMinutes": "{{count}} dakika önce",
"timeHours": "{{count}} saat önce",
"timeDays": "{{count}} gün önce"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Монітор Tmux"
},
"hosts": {
"enableTmuxMonitor": "Увімкнути монітор Tmux",
"enableTmuxMonitorDesc": "Показати цей хост у моніторі Tmux та додати його дії tmux до бічної панелі"
},
"tmuxMonitor": {
"title": "Монітор Tmux",
"failedToLoadHosts": "Не вдалося завантажити хости",
"failedToLoad": "Не вдалося завантажити сеанси tmux",
"tmuxUnavailable": "tmux не встановлено на цьому хості",
"noSessions": "На цьому хості немає сеансів tmux",
"noHostSelected": "Хост не вибрано",
"attached": "Додано",
"detached": "Окремий",
"attach": "Прикріпити",
"editTags": "Редагувати теги",
"tagsHint": "Теги, розділені комами (наприклад, YOLO, лабораторія, навчання)",
"tagsSaved": "Теги збережено",
"tagsSaveFailed": "Не вдалося зберегти теги",
"searchPlaceholder": "Пошук результатів у сеансах...",
"searchResults": "{{count}} збігів",
"searchFailed": "Пошук не вдався",
"selectPaneHint": "Виберіть панель для попереднього перегляду її виводу",
"closePreview": "Закрити попередній перегляд",
"newSession": "Новий сеанс",
"newSessionHint": "Назва сеансу (літери, цифри, _ @ % + = -)",
"newSessionPlaceholder": "моя сесія",
"create": "Створити",
"sessionCreated": "Сеанс «{{name}}» створено",
"sessionCreateFailed": "Не вдалося створити сеанс",
"splitRight": "Розділити праворуч",
"splitDown": "Розділити",
"splitFailed": "Не вдалося розділити панель",
"newWindow": "Нове вікно",
"windowCreateFailed": "Не вдалося створити вікно",
"attachSessionTooltip": "Прикріпити до {{session}}",
"refresh": "Оновити",
"refreshFailed": "Не вдалося оновити сеанси",
"collapseAll": "Згорнути все",
"expandAll": "Розгорнути все",
"moreActions": "Більше дій",
"sessionStats": "Статистика сеансу",
"renameSessionTitle": "Перейменувати сеанс \"{{name}}\"",
"rename": "Перейменувати",
"sessionRenamed": "Сеанс перейменовано на «{{name}}»",
"sessionRenameFailed": "Не вдалося перейменувати сеанс",
"editTagsTitle": "Редагувати теги для \"{{name}}\"",
"killPane": "Панель \"Вийти\"",
"resizeTree": "Перетягніть, щоб змінити розмір — двічі клацніть, щоб скинути налаштування",
"reattach": "Повторно прикріпити (виправляє спотворене зображення)",
"killWindow": "Вікно для знищення",
"killWindowTitle": "Закрити вікно {{index}} з \"{{session}}\"?",
"killWindowBody": "Кожна панель і процес у цьому вікні будуть завершені. Закриття останнього вікна завершує сеанс.",
"windowKillFailed": "Не вдалося завершити роботу вікна",
"statusActivity": "Активність",
"statusWindows": "Вікна",
"statusPanes": "панелі",
"statusTags": "Теги",
"killPaneTitle": "Закрити панель {{id}}?",
"killPaneBody": "Процес, що виконується в цій панелі, буде завершено. Закриття останньої панелі закриває її вікно.",
"paneKillFailed": "Не вдалося завершити роботу панелі",
"killSessionTitle": "Закінчити сеанс \"{{name}}\"?",
"killSessionBody": "Усі вікна та запущені процеси в цьому сеансі будуть завершені. Цю дію неможливо скасувати.",
"kill": "Вбити",
"sessionKilled": "Сеанс «{{name}}» завершено",
"sessionKillFailed": "Не вдалося завершити сеанс",
"hostUnreachable": "Не вдалося підключитися до хоста. Перевірте, чи він онлайн і доступний.",
"noServer": "На цьому хості не працює жоден сервер tmux.",
"searchTruncated": "Часткові результати — пошук охоплює останні {{lines}} рядків кожної панелі та щонайбільше {{panes}} панелей.",
"closeSearchResults": "Закрити результати пошуку",
"retry": "Повторити спробу",
"noHosts": "Немає доступних SSH-хостів",
"noHostsHint": "Увімкніть опцію моніторингу Tmux на хості SSH (Менеджер хостів → вкладка Термінал), щоб контролювати його сеанси tmux.",
"tmuxInstallHint": "Встановіть його на хост за допомогою:",
"attachTooltip": "Відкрийте термінал для {{host}}",
"attachTooltipPane": "Відкрийте термінал для {{host}} — сеанс tmux {{session}}",
"timeJustNow": "щойно",
"timeMinutes": "{{count}}хв тому",
"timeHours": "{{count}}год тому",
"timeDays": "{{count}}днів тому"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Giám sát Tmux"
},
"hosts": {
"enableTmuxMonitor": "Bật Tmux Monitor",
"enableTmuxMonitorDesc": "Hiển thị máy chủ này trong Tmux Monitor và thêm các hành động tmux của nó vào thanh bên"
},
"tmuxMonitor": {
"title": "Giám sát Tmux",
"failedToLoadHosts": "Tải danh sách máy chủ thất bại",
"failedToLoad": "Tải phiên tmux thất bại",
"tmuxUnavailable": "tmux chưa được cài đặt trên máy chủ này",
"noSessions": "Không có phiên tmux nào trên máy chủ này",
"noHostSelected": "Chưa chọn máy chủ",
"attached": "Đã đính kèm",
"detached": "Đã tách rời",
"attach": "Đính kèm",
"editTags": "Sửa nhãn",
"tagsHint": "Nhãn phân cách bằng dấu phẩy (ví dụ: YOLO, lab, training)",
"tagsSaved": "Nhãn đã lưu",
"tagsSaveFailed": "Lưu nhãn thất bại",
"searchPlaceholder": "Tìm kiếm đầu ra qua các phiên...",
"searchResults": "{{count}} kết quả khớp",
"searchFailed": "Tìm kiếm thất bại",
"selectPaneHint": "Chọn một pane để xem trước đầu ra",
"closePreview": "Đóng xem trước",
"newSession": "Phiên mới",
"newSessionHint": "Tên phiên (chữ cái, chữ số, _ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "Tạo",
"sessionCreated": "Đã tạo phiên \"{{name}}\"",
"sessionCreateFailed": "Tạo phiên thất bại",
"splitRight": "Chia sang phải",
"splitDown": "Chia xuống dưới",
"splitFailed": "Chia pane thất bại",
"newWindow": "Cửa sổ mới",
"windowCreateFailed": "Tạo cửa sổ thất bại",
"attachSessionTooltip": "Đính kèm vào {{session}}",
"refresh": "Làm mới",
"refreshFailed": "Làm mới phiên thất bại",
"collapseAll": "Thu gọn tất cả",
"expandAll": "Mở rộng tất cả",
"moreActions": "Thao tác khác",
"sessionStats": "Thống kê phiên",
"renameSessionTitle": "Đổi tên phiên \"{{name}}\"",
"rename": "Đổi tên",
"sessionRenamed": "Đã đổi tên phiên thành \"{{name}}\"",
"sessionRenameFailed": "Đổi tên phiên thất bại",
"editTagsTitle": "Sửa nhãn cho \"{{name}}\"",
"killPane": "Hủy pane",
"resizeTree": "Kéo để thay đổi kích thước — nhấn đúp để đặt lại",
"reattach": "Gắn lại (sửa hiển thị bị rối)",
"killWindow": "Hủy cửa sổ",
"killWindowTitle": "Hủy cửa sổ {{index}} của \"{{session}}\"?",
"killWindowBody": "Mọi pane và tiến trình trong cửa sổ này sẽ bị kết thúc. Hủy cửa sổ cuối cùng sẽ kết thúc phiên.",
"windowKillFailed": "Không thể hủy cửa sổ",
"statusActivity": "Hoạt động",
"statusWindows": "Cửa sổ",
"statusPanes": "Pane",
"statusTags": "Thẻ",
"killPaneTitle": "Hủy pane {{id}}?",
"killPaneBody": "Tiến trình đang chạy trong pane này sẽ bị kết thúc. Hủy pane cuối cùng sẽ đóng cửa sổ của nó.",
"paneKillFailed": "Không thể hủy pane",
"killSessionTitle": "Hủy phiên \"{{name}}\"?",
"killSessionBody": "Tất cả cửa sổ và tiến trình đang chạy trong phiên này sẽ bị kết thúc. Hành động này không thể hoàn tác.",
"kill": "Hủy",
"sessionKilled": "Phiên \"{{name}}\" đã bị hủy",
"sessionKillFailed": "Không thể hủy phiên",
"hostUnreachable": "Không thể kết nối đến máy chủ. Kiểm tra xem máy chủ có trực tuyến và có thể truy cập được không.",
"noServer": "Không có máy chủ tmux nào đang chạy trên máy chủ này.",
"searchTruncated": "Kết quả bị cắt bớt — tìm kiếm bao gồm {{lines}} dòng cuối của mỗi pane và tối đa {{panes}} pane.",
"closeSearchResults": "Đóng kết quả tìm kiếm",
"retry": "Thử lại",
"noHosts": "Không có máy chủ SSH nào",
"noHostsHint": "Bật tùy chọn Tmux Monitor trên máy chủ SSH (Host Manager → tab Terminal) để giám sát phiên tmux của nó.",
"tmuxInstallHint": "Cài đặt nó trên máy chủ bằng lệnh:",
"attachTooltip": "Mở terminal đến {{host}}",
"attachTooltipPane": "Mở terminal đến {{host}} — phiên tmux {{session}}",
"timeJustNow": "vừa xong",
"timeMinutes": "{{count}} phút trước",
"timeHours": "{{count}} giờ trước",
"timeDays": "{{count}} ngày trước"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux 监视器"
},
"hosts": {
"enableTmuxMonitor": "启用 Tmux 监控",
"enableTmuxMonitorDesc": "在 Tmux 监控中显示此主机,并将其 tmux 操作添加到侧边栏"
},
"tmuxMonitor": {
"title": "Tmux 监视器",
"failedToLoadHosts": "加载主机失败",
"failedToLoad": "加载tmux会话失败",
"tmuxUnavailable": "此主机未安装tmux",
"noSessions": "此主机上没有tmux会话",
"noHostSelected": "未选择主机",
"attached": "已连接",
"detached": "已断开",
"attach": "连接",
"editTags": "编辑标签",
"tagsHint": "逗号分隔的标签(例如:YOLO, lab, training)",
"tagsSaved": "标签已保存",
"tagsSaveFailed": "标签保存失败",
"searchPlaceholder": "跨会话搜索输出...",
"searchResults": "{{count}} 个匹配",
"searchFailed": "搜索失败",
"selectPaneHint": "选择一个窗格以预览其输出",
"closePreview": "关闭预览",
"newSession": "新建会话",
"newSessionHint": "会话名称(字母、数字、_ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "创建",
"sessionCreated": "会话 \"{{name}}\" 已创建",
"sessionCreateFailed": "创建会话失败",
"splitRight": "向右拆分",
"splitDown": "向下拆分",
"splitFailed": "拆分窗格失败",
"newWindow": "新建窗口",
"windowCreateFailed": "创建窗口失败",
"attachSessionTooltip": "附加到 {{session}}",
"refresh": "刷新",
"refreshFailed": "刷新会话失败",
"collapseAll": "全部折叠",
"expandAll": "全部展开",
"moreActions": "更多操作",
"sessionStats": "会话统计",
"renameSessionTitle": "重命名会话 \"{{name}}\"",
"rename": "重命名",
"sessionRenamed": "会话已重命名为 \"{{name}}\"",
"sessionRenameFailed": "重命名会话失败",
"editTagsTitle": "编辑 \"{{name}}\" 的标签",
"killPane": "终止窗格",
"resizeTree": "拖动调整大小 — 双击重置",
"reattach": "重新附加(修复显示错乱)",
"killWindow": "终止窗口",
"killWindowTitle": "终止会话 \"{{session}}\" 的窗口 {{index}}?",
"killWindowBody": "该窗口中的所有窗格和进程都将被终止。终止最后一个窗口将结束会话。",
"windowKillFailed": "终止窗口失败",
"statusActivity": "活动",
"statusWindows": "窗口",
"statusPanes": "窗格",
"statusTags": "标签",
"killPaneTitle": "终止窗格 {{id}}?",
"killPaneBody": "该窗格中运行的进程将被终止。终止最后一个窗格将关闭其窗口。",
"paneKillFailed": "终止窗格失败",
"killSessionTitle": "终止会话 \"{{name}}\"?",
"killSessionBody": "该会话中的所有窗口和运行中的进程都将被终止。此操作无法撤销。",
"kill": "终止",
"sessionKilled": "会话 \"{{name}}\" 已终止",
"sessionKillFailed": "终止会话失败",
"hostUnreachable": "无法连接到主机。请检查主机是否在线且可访问。",
"noServer": "此主机上未运行 tmux 服务器。",
"searchTruncated": "部分结果 — 搜索覆盖每个窗格的最近 {{lines}} 行,最多 {{panes}} 个窗格。",
"closeSearchResults": "关闭搜索结果",
"retry": "重试",
"noHosts": "没有可用的 SSH 主机",
"noHostsHint": "在 SSH 主机上启用 Tmux Monitor 选项(主机管理器 → 终端标签页)以监控其 tmux 会话。",
"tmuxInstallHint": "使用以下命令在主机上安装:",
"attachTooltip": "打开到 {{host}} 的终端",
"attachTooltipPane": "打开到 {{host}} 的终端 — tmux 会话 {{session}}",
"timeJustNow": "刚刚",
"timeMinutes": "{{count}} 分钟前",
"timeHours": "{{count}} 小时前",
"timeDays": "{{count}} 天前"
}
}
@@ -0,0 +1,85 @@
{
"nav": {
"tmuxMonitor": "Tmux 監控器"
},
"hosts": {
"enableTmuxMonitor": "啟用 Tmux 監控器",
"enableTmuxMonitorDesc": "在 Tmux 監控器中顯示此主機,並將其 tmux 操作新增至側邊欄"
},
"tmuxMonitor": {
"title": "Tmux 監視器",
"failedToLoadHosts": "載入主機失敗",
"failedToLoad": "載入 tmux 工作階段失敗",
"tmuxUnavailable": "此主機未安裝 tmux",
"noSessions": "此主機上沒有 tmux 工作階段",
"noHostSelected": "未選取主機",
"attached": "已附加",
"detached": "已分離",
"attach": "附加",
"editTags": "編輯標籤",
"tagsHint": "以逗號分隔的標籤(例如:YOLO, lab, training)",
"tagsSaved": "標籤已儲存",
"tagsSaveFailed": "標籤儲存失敗",
"searchPlaceholder": "在所有會話中搜尋輸出...",
"searchResults": "{{count}} 個符合項",
"searchFailed": "搜尋失敗",
"selectPaneHint": "選取一個窗格以預覽其輸出",
"closePreview": "關閉預覽",
"newSession": "新會話",
"newSessionHint": "會話名稱(字母、數字、_ @ % + = -)",
"newSessionPlaceholder": "my-session",
"create": "建立",
"sessionCreated": "會話「{{name}}」已建立",
"sessionCreateFailed": "建立會話失敗",
"splitRight": "向右分割",
"splitDown": "向下分割",
"splitFailed": "分割窗格失敗",
"newWindow": "新視窗",
"windowCreateFailed": "建立視窗失敗",
"attachSessionTooltip": "附加至 {{session}}",
"refresh": "重新整理",
"refreshFailed": "重新整理會話失敗",
"collapseAll": "全部收合",
"expandAll": "全部展開",
"moreActions": "更多動作",
"sessionStats": "會話統計",
"renameSessionTitle": "重新命名會話「{{name}}」",
"rename": "重新命名",
"sessionRenamed": "會話已重新命名為「{{name}}」",
"sessionRenameFailed": "重新命名會話失敗",
"editTagsTitle": "編輯「{{name}}」的標籤",
"killPane": "終止窗格",
"resizeTree": "拖曳調整大小 — 雙擊重設",
"reattach": "重新附加(修復亂碼畫面)",
"killWindow": "終止視窗",
"killWindowTitle": "終止「{{session}}」的視窗 {{index}}?",
"killWindowBody": "此視窗中的所有窗格和程序將被終止。終止最後一個視窗會結束該工作階段。",
"windowKillFailed": "無法終止視窗",
"statusActivity": "活動",
"statusWindows": "視窗",
"statusPanes": "窗格",
"statusTags": "標籤",
"killPaneTitle": "終止窗格 {{id}}?",
"killPaneBody": "在此窗格中執行的程序將被終止。終止最後一個窗格會關閉其視窗。",
"paneKillFailed": "無法終止窗格",
"killSessionTitle": "終止工作階段「{{name}}」?",
"killSessionBody": "此工作階段中的所有視窗和執行中的程序將被終止。此操作無法復原。",
"kill": "終止",
"sessionKilled": "工作階段「{{name}}」已終止",
"sessionKillFailed": "無法終止工作階段",
"hostUnreachable": "無法連線至主機。請檢查主機是否在線上且可連線。",
"noServer": "此主機上沒有正在執行的 tmux 伺服器。",
"searchTruncated": "部分結果 — 搜尋範圍涵蓋每個窗格的最後 {{lines}} 行,且最多 {{panes}} 個窗格。",
"closeSearchResults": "關閉搜尋結果",
"retry": "重試",
"noHosts": "沒有可用的 SSH 主機",
"noHostsHint": "在 SSH 主機上啟用 Tmux 監控選項(主機管理員 → 終端機頁籤),以監控其 tmux 工作階段。",
"tmuxInstallHint": "在主機上使用以下指令安裝:",
"attachTooltip": "開啟連至 {{host}} 的終端機",
"attachTooltipPane": "開啟連至 {{host}} 的終端機 — tmux 工作階段 {{session}}",
"timeJustNow": "剛剛",
"timeMinutes": "{{count}} 分鐘前",
"timeHours": "{{count}} 小時前",
"timeDays": "{{count}} 天前"
}
}
+64
View File
@@ -0,0 +1,64 @@
{
"id": "tmux-monitor",
"name": "Tmux Monitor",
"version": "1.0.0",
"description": "Browse and control tmux sessions, windows and panes across your hosts.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Terminal",
"icon": "Layers",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"hosts:read",
"credentials:use",
"ssh:connect",
"db:own",
"network:serve",
"ui:surface"
],
"provides": [
{
"service": "tmux.sessions",
"version": "1.0.0",
"permission": "tmux-monitor.use"
}
],
"contributes": {
"tabs": [
{
"id": "tmux_monitor",
"titleKey": "nav.tmuxMonitor",
"icon": "Layers",
"openFrom": ["host-context-menu", "palette"]
}
],
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
],
"settings": {
"host": {
"enableKey": "enableTmuxMonitor",
"enableLabelKey": "settings.host.enable.label",
"fields": []
}
},
"hostCapability": {
"key": "enableTmuxMonitor",
"labelKey": "hosts.tabTmuxMonitor",
"editorTab": "tmux-monitor"
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,14 @@
-- tmux-monitor 0001: adopt_tmux_session_tags
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `tmux_session_tags` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`host_id` int NOT NULL,
`session_name` text NOT NULL,
`tag` text NOT NULL,
`created_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 `tmux_session_tags` TO `p_tmux_monitor_session_tags`;
@@ -0,0 +1,14 @@
-- tmux-monitor 0001: adopt_tmux_session_tags
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "tmux_session_tags" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"host_id" integer NOT NULL,
"session_name" text NOT NULL,
"tag" text NOT NULL,
"created_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
);
ALTER TABLE "tmux_session_tags" RENAME TO "p_tmux_monitor_session_tags";
@@ -0,0 +1,38 @@
{
"version": 1,
"tables": {
"session_tags": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"sessionName": {
"type": "text",
"notNull": true
},
"tag": {
"type": "text",
"notNull": true
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [],
"adopts": "tmux_session_tags"
}
}
}
@@ -0,0 +1,14 @@
-- tmux-monitor 0001: adopt_tmux_session_tags
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "tmux_session_tags" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"host_id" integer NOT NULL,
"session_name" text NOT NULL,
"tag" text NOT NULL,
"created_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
);
ALTER TABLE "tmux_session_tags" RENAME TO "p_tmux_monitor_session_tags";
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/tmux-monitor",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { sessionTags } from "./tables.js";
import { createSessionTagRepository } from "./repository.js";
import { createTmuxMonitorRoutes } from "./routes.js";
import { createTmuxSessionsService } from "./service.js";
export type { TmuxDetection, TmuxSessionsV1 } from "./service.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(sessionTags);
const tags = createSessionTagRepository(ctx.db, table);
ctx.http.router<Router>().use(createTmuxMonitorRoutes(ctx, tags));
ctx.services.provide("tmux.sessions", createTmuxSessionsService(ctx));
ctx.log.info("Tmux Monitor routes mounted at /plugin-api/tmux-monitor");
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
@@ -0,0 +1,95 @@
import { and, eq } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// The table comes from ctx.db.define, which the SDK hands 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 type SessionTagRepository = ReturnType<
typeof createSessionTagRepository
>;
/**
* A user's tags on a tmux session, per host. Sessions are shared on the host
* (any user monitoring it sees the same tmux sessions), but tags are per
* user.
*/
export function createSessionTagRepository(db: PluginDatabase, table: Table) {
const client = () => db.client<Drizzle>();
return {
async listByUserAndHost(
userId: string,
hostId: number,
): Promise<Map<string, string[]>> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)));
const bySession = new Map<string, string[]>();
for (const row of rows as { sessionName: string; tag: string }[]) {
const tags = bySession.get(row.sessionName) ?? [];
tags.push(row.tag);
bySession.set(row.sessionName, tags);
}
return bySession;
},
async renameSessionForHost(
hostId: number,
sessionName: string,
newSessionName: string,
): Promise<void> {
const drizzle = await client();
await drizzle
.update(table)
.set({ sessionName: newSessionName })
.where(
and(eq(table.hostId, hostId), eq(table.sessionName, sessionName)),
);
await db.persist();
},
async deleteSessionForHost(
hostId: number,
sessionName: string,
): Promise<void> {
const drizzle = await client();
await drizzle
.delete(table)
.where(
and(eq(table.hostId, hostId), eq(table.sessionName, sessionName)),
);
await db.persist();
},
async replaceForUserHostSession(
userId: string,
hostId: number,
sessionName: string,
tags: string[],
): Promise<void> {
const drizzle = await client();
await drizzle
.delete(table)
.where(
and(
eq(table.userId, userId),
eq(table.hostId, hostId),
eq(table.sessionName, sessionName),
),
);
if (tags.length > 0) {
await drizzle
.insert(table)
.values(tags.map((tag) => ({ userId, hostId, sessionName, tag })));
}
await db.persist();
},
};
}
+872
View File
@@ -0,0 +1,872 @@
import express, { type Router } from "express";
import type { Client } from "ssh2";
import type {
PluginContext,
PluginHostSummary,
} from "@termix/plugin-sdk/backend";
import { execCommand, tmuxCommand } from "./tmux-commands.js";
import {
SEP,
parseSessions,
parseWindows,
parsePanes,
parsePsOutput,
parseGpuOutput,
buildPaneMetrics,
attachPanesToWindows,
shellEscape,
type RawPane,
type TmuxSessionSummary,
type TmuxWindow,
type PaneMetrics,
} from "./monitor-helpers.js";
import type { SessionTagRepository } from "./repository.js";
const PANE_ID_RE = /^%\d+$/;
// tmux session names cannot contain ":" or "."; keep to a conservative
// printable subset so the name is safe as a tmux target everywhere.
const SESSION_NAME_RE = /^[A-Za-z0-9_@%+=-]{1,64}$/;
const MAX_SEARCH_PANES = 100;
const MAX_MATCHES_PER_PANE = 50;
const SEARCH_HISTORY_LINES = 2000;
const SEARCH_CONCURRENCY = 4;
const POOL = "tmux-monitor";
interface TmuxSessionOverview extends TmuxSessionSummary {
windows: TmuxWindow[];
tags: string[];
}
function toErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
type TmuxErrorCode =
"TMUX_NOT_INSTALLED" | "TMUX_NO_SERVER" | "HOST_UNREACHABLE" | "TMUX_ERROR";
function classifyTmuxError(err: unknown): TmuxErrorCode {
const msg = toErrorMessage(err);
if (/command not found|exited with code 127/i.test(msg))
return "TMUX_NOT_INSTALLED";
if (/no server running|lost server/i.test(msg)) return "TMUX_NO_SERVER";
if (
/timeout|timed out|econnrefused|ehostunreach|enotfound|enetunreach|econnreset|authentication|handshake|keepalive/i.test(
msg,
)
)
return "HOST_UNREACHABLE";
return "TMUX_ERROR";
}
function sendTmuxError(
ctx: PluginContext,
res: express.Response,
err: unknown,
context: string,
hostId: number,
): void {
const code = classifyTmuxError(err);
const status = code === "TMUX_ERROR" ? 500 : 503;
const error =
code === "TMUX_NOT_INSTALLED"
? "tmux is not installed on this host"
: code === "TMUX_NO_SERVER"
? "No tmux server is running on this host"
: code === "HOST_UNREACHABLE"
? "Could not connect to the host"
: toErrorMessage(err);
ctx.log.error(`tmux ${context} failed for host ${hostId}`, err as Error);
res.status(status).json({ error, code });
}
/**
* Resolves the host for the acting user and checks the monitor is enabled for
* it. Sends the error response and returns null when access is denied.
*/
async function requireHost(
ctx: PluginContext,
req: express.Request,
res: express.Response,
): Promise<PluginHostSummary | null> {
const hostId = parseInt(String(req.params.hostId), 10);
if (isNaN(hostId)) {
res.status(400).json({ error: "Invalid host ID" });
return null;
}
const host = await ctx.hosts.get(hostId);
if (!host) {
res.status(403).json({ error: "Host not found or access denied" });
return null;
}
// The monitor is opt-in per host: hiding the UI is not enough, the API
// must refuse too.
const enabled = await ctx.settings.getHost<boolean>(
hostId,
"enableTmuxMonitor",
);
if (!enabled) {
res
.status(403)
.json({ error: "Tmux Monitor is not enabled for this host" });
return null;
}
return host;
}
async function withHostConnection<T>(
ctx: PluginContext,
host: PluginHostSummary,
fn: (client: Client) => Promise<T>,
): Promise<T> {
return ctx.ssh.withConnection<T, Client>(
host.id,
{ pool: POOL, purpose: "tmux" },
fn,
);
}
async function tmuxAvailable(conn: Client): Promise<boolean> {
try {
await execCommand(conn, tmuxCommand("-V"));
return true;
} catch {
return false;
}
}
async function runTmuxList(conn: Client, command: string): Promise<string> {
try {
return await execCommand(conn, command);
} catch {
return ""; // tmux server not running -- no sessions
}
}
function listSessionsCmd(): string {
return tmuxCommand(
`list-sessions -F "#{session_name}${SEP}#{session_created}${SEP}#{session_activity}${SEP}#{session_attached}" 2>/dev/null`,
);
}
function listWindowsCmd(): string {
return tmuxCommand(
`list-windows -a -F "#{session_name}${SEP}#{window_index}${SEP}#{window_active}${SEP}#{window_name}" 2>/dev/null`,
);
}
function listPanesCmd(): string {
return tmuxCommand(
`list-panes -a -F "#{session_name}${SEP}#{window_index}${SEP}#{pane_id}${SEP}#{pane_index}${SEP}#{pane_pid}${SEP}#{pane_active}${SEP}#{pane_width}${SEP}#{pane_height}${SEP}#{pane_current_command}${SEP}#{pane_current_path}${SEP}#{pane_title}" 2>/dev/null`,
);
}
async function listPanesRaw(conn: Client): Promise<RawPane[]> {
return parsePanes(await runTmuxList(conn, listPanesCmd()));
}
async function collectPaneMetrics(
conn: Client,
panes: RawPane[],
): Promise<PaneMetrics[]> {
let psOutput = "";
try {
psOutput = await execCommand(
conn,
"ps -eo pid=,ppid=,pcpu=,pmem=,rss=,comm= 2>/dev/null",
);
} catch {
return [];
}
let gpuOutput = "";
try {
gpuOutput = await execCommand(
conn,
"command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-compute-apps=pid,used_gpu_memory --format=csv,noheader,nounits 2>/dev/null || true",
);
} catch {
// no GPU on host
}
return buildPaneMetrics(
panes,
parsePsOutput(psOutput),
parseGpuOutput(gpuOutput),
);
}
export function createTmuxMonitorRoutes(
ctx: PluginContext,
tags: SessionTagRepository,
): Router {
const router = express.Router();
router.use(ctx.rbac.require("use") as never);
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/overview:
* get:
* summary: Get a host's tmux sessions, windows and panes
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The tmux overview for this host.
* 403:
* description: Host not found, not accessible, or the monitor is disabled for it.
*/
router.get("/:hostId/overview", async (req, res) => {
const userId = ctx.currentActor();
const host = await requireHost(ctx, req, res);
if (!host || !userId) return;
try {
const result = await withHostConnection(ctx, host, async (conn) => {
if (!(await tmuxAvailable(conn))) {
return { available: false, sessions: [] as TmuxSessionOverview[] };
}
const [sessionsOut, windowsOut, panesOut] = await Promise.all([
runTmuxList(conn, listSessionsCmd()),
runTmuxList(conn, listWindowsCmd()),
runTmuxList(conn, listPanesCmd()),
]);
const sessions = parseSessions(sessionsOut);
const windows = parseWindows(windowsOut);
attachPanesToWindows(windows, parsePanes(panesOut));
const sessionTags = await tags.listByUserAndHost(userId, host.id);
const full: TmuxSessionOverview[] = sessions.map((s) => ({
...s,
windows: windows.get(s.name) || [],
tags: sessionTags.get(s.name) || [],
}));
return { available: true, sessions: full };
});
res.json(result);
} catch (err) {
sendTmuxError(ctx, res, err, "overview", host.id);
}
});
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/focus:
* post:
* summary: Focus a pane
* description: Selects the pane's window and pane on the server, so every attached client switches to it.
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Focused.
*/
router.post("/:hostId/focus", async (req, res) => {
const host = await requireHost(ctx, req, res);
if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || "");
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
try {
await withHostConnection(ctx, host, (conn) =>
execCommand(
conn,
tmuxCommand(
`select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`,
),
),
);
res.json({ ok: true });
} catch (err) {
sendTmuxError(ctx, res, err, "focus", host.id);
}
});
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/sessions:
* post:
* summary: Create a detached tmux session
* description: Starts the tmux server if none is running.
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Session created.
* 409:
* description: A session with this name already exists.
*/
router.post("/:hostId/sessions", async (req, res) => {
const host = await requireHost(ctx, req, res);
if (!host) return;
const name = String((req.body as { name?: string })?.name || "").trim();
if (!SESSION_NAME_RE.test(name)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(ctx, host, (conn) =>
execCommand(
conn,
tmuxCommand(`new-session -d -s ${shellEscape(name)}`),
),
);
ctx.log.info(`tmux session created: ${name} on host ${host.id}`);
res.json({ ok: true, name });
} catch (err) {
if (/duplicate session/i.test(toErrorMessage(err))) {
return res
.status(409)
.json({ error: "A session with this name already exists" });
}
sendTmuxError(ctx, res, err, "create session", host.id);
}
});
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/windows:
* post:
* summary: Create a window in an existing session
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Window created.
* 404:
* description: Session not found.
*/
router.post("/:hostId/windows", async (req, res) => {
const host = await requireHost(ctx, req, res);
if (!host) return;
const sessionName = String(
(req.body as { sessionName?: string })?.sessionName || "",
).trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(ctx, host, (conn) =>
execCommand(
conn,
tmuxCommand(`new-window -t ${shellEscape(`=${sessionName}`)}`),
),
);
ctx.log.info(
`tmux window created in session ${sessionName} on host ${host.id}`,
);
res.json({ ok: true });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
sendTmuxError(ctx, res, err, "create window", host.id);
}
});
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/rename:
* post:
* summary: Rename a session
* description: Saved tags follow the session to its new name, for every user monitoring this host.
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Session renamed.
* 404:
* description: Session not found.
* 409:
* description: A session with this name already exists.
*/
router.post("/:hostId/rename", async (req, res) => {
const host = await requireHost(ctx, req, res);
if (!host) return;
const body = req.body as { sessionName?: string; newName?: string };
const sessionName = String(body?.sessionName || "").trim();
const newName = String(body?.newName || "").trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
if (!SESSION_NAME_RE.test(newName)) {
return res.status(400).json({ error: "Invalid new session name" });
}
try {
await withHostConnection(ctx, host, (conn) =>
execCommand(
conn,
tmuxCommand(
`rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`,
),
),
);
await tags.renameSessionForHost(host.id, sessionName, newName);
ctx.log.info(
`tmux session renamed on host ${host.id}: ${sessionName} -> ${newName}`,
);
await ctx.audit.record({
action: "tmux_session_rename",
resourceType: "host",
resourceId: String(host.id),
resourceName: sessionName,
details: JSON.stringify({ newName }),
success: true,
});
res.json({ ok: true, name: newName });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
if (/duplicate session/i.test(toErrorMessage(err))) {
return res
.status(409)
.json({ error: "A session with this name already exists" });
}
sendTmuxError(ctx, res, err, "rename session", host.id);
}
});
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/kill:
* post:
* summary: Kill a session and drop its saved tags
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Session killed.
* 404:
* description: Session not found.
*/
router.post("/:hostId/kill", async (req, res) => {
const host = await requireHost(ctx, req, res);
if (!host) return;
const sessionName = String(
(req.body as { sessionName?: string })?.sessionName || "",
).trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(ctx, host, (conn) =>
execCommand(
conn,
tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`),
),
);
await tags.deleteSessionForHost(host.id, sessionName);
ctx.log.info(`tmux session killed on host ${host.id}: ${sessionName}`);
await ctx.audit.record({
action: "tmux_session_kill",
resourceType: "host",
resourceId: String(host.id),
resourceName: sessionName,
success: true,
});
res.json({ ok: true });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
sendTmuxError(ctx, res, err, "kill session", host.id);
}
});
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/kill-window:
* post:
* summary: Kill a window and every pane in it
* description: Killing the last window of a session ends the session (tmux semantics).
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Window killed.
* 404:
* description: Window not found.
*/
router.post("/:hostId/kill-window", async (req, res) => {
const host = await requireHost(ctx, req, res);
if (!host) return;
const body = req.body as { sessionName?: string; windowIndex?: number };
const sessionName = String(body?.sessionName || "").trim();
const windowIndex = Number(body?.windowIndex);
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
if (!Number.isInteger(windowIndex) || windowIndex < 0) {
return res.status(400).json({ error: "Invalid window index" });
}
try {
await withHostConnection(ctx, host, (conn) =>
execCommand(
conn,
tmuxCommand(
`kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`,
),
),
);
ctx.log.info(
`tmux window killed on host ${host.id}: ${sessionName}:${windowIndex}`,
);
await ctx.audit.record({
action: "tmux_window_kill",
resourceType: "host",
resourceId: String(host.id),
resourceName: sessionName,
details: JSON.stringify({ windowIndex }),
success: true,
});
res.json({ ok: true });
} catch (err) {
if (
/can't find window|no such window|can't find session/i.test(
toErrorMessage(err),
)
) {
return res.status(404).json({ error: "Window not found" });
}
sendTmuxError(ctx, res, err, "kill window", host.id);
}
});
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/kill-pane:
* post:
* summary: Kill a single pane
* description: Killing the last pane of a window closes the window, and the last window of a session ends the session (tmux semantics).
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Pane killed.
* 404:
* description: Pane not found.
*/
router.post("/:hostId/kill-pane", async (req, res) => {
const host = await requireHost(ctx, req, res);
if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || "");
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
try {
await withHostConnection(ctx, host, (conn) =>
execCommand(conn, tmuxCommand(`kill-pane -t ${shellEscape(paneId)}`)),
);
ctx.log.info(`tmux pane killed on host ${host.id}: ${paneId}`);
await ctx.audit.record({
action: "tmux_pane_kill",
resourceType: "host",
resourceId: String(host.id),
resourceName: paneId,
success: true,
});
res.json({ ok: true });
} catch (err) {
if (/can't find pane|no such pane/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Pane not found" });
}
sendTmuxError(ctx, res, err, "kill pane", host.id);
}
});
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/split:
* post:
* summary: Split the window containing a pane
* description: "\"h\" places the new pane to the right, \"v\" below, matching tmux's own -h/-v semantics. The new pane starts in the source pane's working directory."
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Pane split.
*/
router.post("/:hostId/split", async (req, res) => {
const host = await requireHost(ctx, req, res);
if (!host) return;
const body = req.body as { paneId?: string; direction?: string };
const paneId = String(body?.paneId || "");
const direction = body?.direction === "v" ? "-v" : "-h";
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
if (body?.direction !== "h" && body?.direction !== "v") {
return res.status(400).json({ error: "Invalid split direction" });
}
try {
await withHostConnection(ctx, host, (conn) =>
execCommand(
conn,
tmuxCommand(
`split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`,
),
),
);
ctx.log.info(
`tmux pane split on host ${host.id}: ${paneId} (${body.direction})`,
);
res.json({ ok: true });
} catch (err) {
sendTmuxError(ctx, res, err, "split", host.id);
}
});
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/search:
* get:
* summary: Search pane output across a host's tmux sessions
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* - in: query
* name: q
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Matching lines, capped per pane and across panes.
*/
router.get("/:hostId/search", async (req, res) => {
const host = await requireHost(ctx, req, res);
if (!host) return;
const query = String(req.query.q || "").trim();
if (!query) {
return res.status(400).json({ error: "Missing search query" });
}
try {
const results = await withHostConnection(ctx, host, async (conn) => {
const allPanes = await listPanesRaw(conn);
const panes = allPanes.slice(0, MAX_SEARCH_PANES);
// Flips to true whenever a limit was hit, so the UI can tell the user
// the results are partial instead of silently truncating.
let truncated = allPanes.length > MAX_SEARCH_PANES;
const matches: Array<{
paneId: string;
sessionName: string;
windowIndex: number;
line: number;
text: string;
}> = [];
// Bounded concurrency; each search runs capture+grep remotely so only
// matching lines travel back over the wire.
for (let i = 0; i < panes.length; i += SEARCH_CONCURRENCY) {
const batch = panes.slice(i, i + SEARCH_CONCURRENCY);
await Promise.all(
batch.map(async (pane) => {
try {
const output = await execCommand(
conn,
tmuxCommand(
`capture-pane -p -J -t ${shellEscape(pane.id)} -S -${SEARCH_HISTORY_LINES} 2>/dev/null | grep -n -i -F -- ${shellEscape(query)} | head -${MAX_MATCHES_PER_PANE}`,
),
);
const lines = output.split("\n").filter(Boolean);
if (lines.length >= MAX_MATCHES_PER_PANE) truncated = true;
for (const line of lines) {
const sep = line.indexOf(":");
if (sep === -1) continue;
matches.push({
paneId: pane.id,
sessionName: pane.sessionName,
windowIndex: pane.windowIndex,
line: parseInt(line.slice(0, sep), 10) || 0,
text: line.slice(sep + 1).slice(0, 500),
});
}
} catch {
// grep exits non-zero when there are no matches -- not an error
}
}),
);
}
return { matches, truncated };
});
res.json({
query,
matches: results.matches,
truncated: results.truncated,
searchedLines: SEARCH_HISTORY_LINES,
maxPanes: MAX_SEARCH_PANES,
});
} catch (err) {
sendTmuxError(ctx, res, err, "search", host.id);
}
});
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/metrics:
* get:
* summary: Per-pane CPU, memory and GPU usage
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Metrics for every pane tmux reports.
*/
router.get("/:hostId/metrics", async (req, res) => {
const host = await requireHost(ctx, req, res);
if (!host) return;
try {
const metrics = await withHostConnection(ctx, host, async (conn) => {
const panes = await listPanesRaw(conn);
if (panes.length === 0) return [];
return collectPaneMetrics(conn, panes);
});
res.json({ panes: metrics });
} catch (err) {
sendTmuxError(ctx, res, err, "metrics", host.id);
}
});
/**
* @openapi
* /plugin-api/tmux-monitor/{hostId}/tags:
* put:
* summary: Replace the acting user's tags on a session
* tags:
* - Tmux Monitor
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Tags saved.
*/
router.put("/:hostId/tags", async (req, res) => {
const userId = ctx.currentActor();
const host = await requireHost(ctx, req, res);
if (!host || !userId) return;
const { sessionName, tags: rawTags } = req.body as {
sessionName?: string;
tags?: string[];
};
if (!sessionName || typeof sessionName !== "string") {
return res.status(400).json({ error: "Missing session name" });
}
if (!Array.isArray(rawTags) || rawTags.some((t) => typeof t !== "string")) {
return res
.status(400)
.json({ error: "Tags must be an array of strings" });
}
const cleanTags = [
...new Set(rawTags.map((t) => t.trim().slice(0, 64)).filter(Boolean)),
].slice(0, 20);
try {
await tags.replaceForUserHostSession(
userId,
host.id,
sessionName,
cleanTags,
);
res.json({ sessionName, tags: cleanTags });
} catch (err) {
ctx.log.error(
`Failed to save tmux session tags for host ${host.id}`,
err as Error,
);
res.status(500).json({ error: toErrorMessage(err) });
}
});
return router;
}
@@ -0,0 +1,54 @@
import type { Client, ClientChannel } from "ssh2";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
attachOrCreateTmuxSession,
detectTmux,
waitForTmuxSession,
} from "./tmux-commands.js";
export interface TmuxDetection {
available: boolean;
sessions: string[];
}
/**
* What the terminal (ssh-terminal's optional "tmux.sessions" consumer) asks
* of this plugin. Clients and streams cross the service boundary as
* `unknown`, since the SDK types services without an ssh2 dependency.
*/
export interface TmuxSessionsV1 {
detect: (client: unknown) => Promise<TmuxDetection>;
attachOrCreate: (
stream: unknown,
name?: string,
newName?: string,
) => Promise<void>;
waitForSession: (client: unknown, name: string) => Promise<string>;
}
export function createTmuxSessionsService(ctx: PluginContext): TmuxSessionsV1 {
return {
async detect(client) {
const result = await detectTmux(client as Client);
return {
available: result.available,
sessions: result.sessions.map((s) => s.name),
};
},
async attachOrCreate(stream, name, newName) {
attachOrCreateTmuxSession(stream as ClientChannel, name, newName);
},
async waitForSession(client, name) {
const confirmed = await waitForTmuxSession(client as Client, name);
if (!confirmed) {
ctx.log.warn(
`Timed out waiting for a new tmux session to appear: ${name}`,
);
return name;
}
return confirmed;
},
};
}
@@ -0,0 +1,32 @@
import {
adoptLegacyTable,
defineTable,
id,
refHost,
refUser,
text,
timestamp,
} from "@termix/plugin-sdk/db";
/**
* A user's tags on a tmux session, per host. Sessions are shared on the host
* itself, but tags are per user: everyone who monitors the same host can tag
* the same session differently.
*
* Adopted from core's tmux_session_tags, so the column names are the legacy
* ones and the rename carries every existing row across. Core never indexed
* it, so there is no legacy index to keep.
*/
export const sessionTags = adoptLegacyTable(
"tmux_session_tags",
defineTable("session_tags", {
id: id(),
userId: refUser(),
hostId: refHost(),
sessionName: text().notNull(),
tag: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
}),
);
export const tables = [sessionTags];
@@ -1,18 +1,4 @@
import type { Client, ClientChannel } from "ssh2";
import { sshLogger } from "../../utils/logger.js";
export interface TmuxSessionInfo {
name: string;
created: number;
lastActivity: number;
windows: number;
attachedClients: number;
}
export interface TmuxDetectionResult {
available: boolean;
sessions: TmuxSessionInfo[];
}
const TMUX_PATH_DIRS = [
"/opt/homebrew/bin",
@@ -65,6 +51,19 @@ export function execCommand(conn: Client, command: string): Promise<string> {
});
}
export interface TmuxSessionInfo {
name: string;
created: number;
lastActivity: number;
windows: number;
attachedClients: number;
}
export interface TmuxDetectionResult {
available: boolean;
sessions: TmuxSessionInfo[];
}
/**
* Detect whether tmux is installed and list all existing sessions with details.
*/
@@ -171,20 +170,9 @@ export function attachOrCreateTmuxSession(
const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : "";
command = `${tmuxCommand(`${TMUX_OPTS} \\; new-session${nameFlag}`)} && exit\r`;
}
sshLogger.info("Writing tmux command to shell", {
operation: "tmux_attach_or_create",
sessionName: existingSessionName || "(auto)",
isReattach: !!existingSessionName,
});
stream.write(command);
}
/**
* Query the name of the most recently created tmux session via exec channel.
*/
function shellEscape(s: string): string {
export function shellEscape(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
@@ -0,0 +1,65 @@
import { Layers } from "lucide-react";
import {
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import { FakeSwitch, SectionCard, SettingRow } from "@termix/plugin-sdk/ui";
type PluginSettingsForm = Record<string, Record<string, unknown>>;
/**
* The host editor's tmux monitor toggle. Writes into the form's
* pluginSettings for this plugin, which the editor saves through the
* plugin's host settings route after the host itself.
*/
export function HostTmuxMonitorSection({
form,
updateForm,
}: HostEditorSectionProps) {
const { t } = useTranslation();
const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)?.[
"tmux-monitor"
] ?? {}) as Record<string, unknown>;
const enabled = settings.enableTmuxMonitor === true;
const setEnabled = (value: boolean) =>
updateForm((current) => {
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
return {
...current,
pluginSettings: {
...all,
"tmux-monitor": { ...all["tmux-monitor"], enableTmuxMonitor: value },
},
};
});
return (
<SectionCard
title={t("tmuxMonitor.title")}
icon={<Layers className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableTmuxMonitor")}
description={
<>
{t("hosts.enableTmuxMonitorDesc")}{" "}
<a
href="https://docs.termix.site/features/terminal/tmux"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch checked={enabled} onChange={setEnabled} />
</SettingRow>
</div>
</SectionCard>
);
}
@@ -1,11 +1,11 @@
// Interactive view of a tmux pane. Embeds the real SSH terminal (the same
// component as the Terminal feature) and attaches to the selected session via
// the native tmux_attach flow, so the pane is fully usable — typing, mouse
// scrolling and tmux copy-mode all behave exactly like a normal terminal tab.
// Interactive view of a tmux pane. Embeds the real SSH terminal (through the
// terminal.view component ssh-terminal registers) and attaches to the
// selected session via the native tmux_attach flow, so the pane is fully
// usable: typing, mouse scrolling and tmux copy-mode all behave exactly like
// a normal terminal tab.
import { PluginComponent } from "@/plugin-host/component-registry";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Activity,
Cpu,
@@ -16,21 +16,19 @@ import {
Trash2,
X,
} from "lucide-react";
import type { TabHandle as TerminalHandle } from "@termix/plugin-sdk/frontend";
type TerminalHostConfig = Record<string, unknown>;
import type { SSHHost } from "@/types/index";
import type { TmuxPaneMetrics } from "@/api/tmux-monitor-api";
import { PluginComponent } from "@termix/plugin-sdk/ui";
import type { PluginHostRecord, TabHandle } from "@termix/plugin-sdk/frontend";
import type { TmuxPaneMetrics } from "./api";
import { formatMem } from "./format";
import type { SelectedPane } from "./types";
interface PanePreviewProps {
host: SSHHost;
host: PluginHostRecord;
pane: SelectedPane;
metrics?: TmuxPaneMetrics;
/** Imperative handle of the embedded terminal, used by the parent to nudge
* a refit/redraw after layout-changing tmux actions. */
terminalRef?: React.RefObject<TerminalHandle | null>;
terminalRef?: React.RefObject<TabHandle | null>;
/** Split the window containing this pane ("h" = new pane to the right,
* "v" = below — tmux -h/-v semantics). */
onSplit: (direction: "h" | "v") => void;
@@ -70,13 +68,14 @@ export function PanePreview({
// The host's own authType travels in the spread, so a Tailscale host still
// gets the Tailscale-aware PTY path. The SSH port may be non-standard.
const resolvedPort = host.sshPort ?? host.port;
const hostRecord = host as unknown as Record<string, unknown>;
const resolvedPort = hostRecord.sshPort ?? hostRecord.port;
const terminalHostConfig = {
...host,
...hostRecord,
port: resolvedPort,
sshPort: resolvedPort,
instanceId: instanceIdRef.current,
} as TerminalHostConfig;
};
return (
<>
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { X } from "lucide-react";
import type { TmuxSearchMatch, TmuxSearchResult } from "@/api/tmux-monitor-api";
import type { TmuxSearchMatch, TmuxSearchResult } from "./api";
interface SearchResultsProps {
results: TmuxSearchMatch[];
@@ -24,7 +24,7 @@ function highlightMatch(text: string, query: string): ReactNode {
return (
<>
{text.slice(0, index)}
<mark className="rounded-sm bg-accent-brand/30 px-0 text-foreground">
<mark className="bg-accent-brand/30 px-0 text-foreground">
{text.slice(index, index + query.length)}
</mark>
{text.slice(index + query.length)}
@@ -6,7 +6,7 @@
// keyboard focus.
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
AppWindow,
BarChart2,
@@ -23,18 +23,17 @@ import {
Trash2,
X,
} from "lucide-react";
import { Badge } from "@/components/badge";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
import type {
TmuxPaneMetrics,
TmuxSessionOverview,
} from "@/api/tmux-monitor-api";
Popover,
PopoverContent,
PopoverTrigger,
} from "@termix/plugin-sdk/ui";
import type { TmuxPaneMetrics, TmuxSessionOverview } from "./api";
import { formatMem, formatRelativeTime } from "./format";
import type { SelectedPane } from "./types";
@@ -137,7 +136,7 @@ export function SessionTree({
return (
<div key={session.name} className="mb-0.5">
<div
className="group flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 hover:bg-muted/40"
className="group flex cursor-pointer items-center gap-1.5 px-2 py-1 hover:bg-muted/40"
onClick={() => onToggleSession(session.name)}
>
{expanded ? (
@@ -184,7 +183,7 @@ export function SessionTree({
)}
<span className="pointer-events-none z-10 col-start-1 row-start-1 flex items-center gap-1.5 justify-self-end opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground"
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.attachSessionTooltip", {
session: session.name,
})}
@@ -199,7 +198,7 @@ export function SessionTree({
<Play className="size-3.5" />
</button>
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground"
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.newWindow")}
aria-label={t("tmuxMonitor.newWindow")}
onClick={(e) => {
@@ -212,7 +211,7 @@ export function SessionTree({
<Popover>
<PopoverTrigger asChild>
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"
className="-m-1 p-1 text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"
title={t("tmuxMonitor.sessionStats")}
aria-label={t("tmuxMonitor.sessionStats")}
onClick={(e) => e.stopPropagation()}
@@ -286,7 +285,7 @@ export function SessionTree({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"
className="-m-1 p-1 text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"
title={t("tmuxMonitor.moreActions")}
aria-label={t("tmuxMonitor.moreActions")}
onClick={(e) => e.stopPropagation()}
@@ -341,7 +340,7 @@ export function SessionTree({
return (
<div key={win.index} className="ml-4">
<div
className="group flex cursor-pointer items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted/40"
className="group flex cursor-pointer items-center gap-1 px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted/40"
onClick={() => toggleWindow(session.name, win.index)}
>
{winCollapsed ? (
@@ -354,7 +353,7 @@ export function SessionTree({
{win.index}: {win.name}
</span>
<button
className="-m-1 ml-auto shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
className="-m-1 ml-auto shrink-0 p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
title={t("tmuxMonitor.killWindow")}
aria-label={t("tmuxMonitor.killWindow")}
onClick={(e) => {
@@ -372,7 +371,7 @@ export function SessionTree({
return (
<div
key={pane.id}
className={`group ml-5 flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-xs hover:bg-muted/40 ${isSelected ? "bg-accent-brand/10" : ""}`}
className={`group ml-5 flex cursor-pointer items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/40 ${isSelected ? "bg-accent-brand/10" : ""}`}
onClick={() =>
onSelectPane({
paneId: pane.id,
@@ -403,7 +402,7 @@ export function SessionTree({
)}
<span className="pointer-events-none z-10 col-start-1 row-start-1 flex items-center gap-1 justify-self-end opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground"
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.splitRight")}
aria-label={t("tmuxMonitor.splitRight")}
onClick={(e) => {
@@ -414,7 +413,7 @@ export function SessionTree({
<SquareSplitHorizontal className="size-3" />
</button>
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-foreground"
className="-m-1 p-1 text-muted-foreground hover:text-foreground"
title={t("tmuxMonitor.splitDown")}
aria-label={t("tmuxMonitor.splitDown")}
onClick={(e) => {
@@ -425,7 +424,7 @@ export function SessionTree({
<SquareSplitVertical className="size-3" />
</button>
<button
className="-m-1 rounded p-1 text-muted-foreground hover:text-destructive"
className="-m-1 p-1 text-muted-foreground hover:text-destructive"
title={t("tmuxMonitor.killPane")}
aria-label={t("tmuxMonitor.killPane")}
onClick={(e) => {
@@ -1,10 +1,25 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
defaultConnectAction,
listHostActions,
} from "@/sidebar/host-contributions";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
useHosts,
useToast,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import type { PluginHostRecord, TabHandle } from "@termix/plugin-sdk/frontend";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Popover,
PopoverContent,
PopoverTrigger,
ScrollArea,
Skeleton,
isElectron,
} from "@termix/plugin-sdk/ui";
import {
ChevronsDownUp,
ChevronsUpDown,
@@ -17,21 +32,6 @@ import {
Server,
SquareTerminal,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { Skeleton } from "@/components/skeleton";
import { ScrollArea } from "@/components/scroll-area";
import { getSSHHosts } from "@/main-axios";
import { isElectron } from "@/lib/electron";
import type { SSHHost } from "@/types/index";
import {
getTmuxOverview,
getTmuxMetrics,
@@ -49,9 +49,9 @@ import {
type TmuxPaneMetrics,
type TmuxSearchMatch,
type TmuxSearchResult,
} from "@/api/tmux-monitor-api";
import type { TabHandle as TerminalHandle } from "@termix/plugin-sdk/frontend";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
} from "./api";
import { tmuxMonitorEnabled } from "./host-tmux-monitor";
import { useAdaptivePolling } from "./use-adaptive-polling";
import { SessionTree, type SessionMetricsAgg } from "./SessionTree";
import { SearchResults } from "./SearchResults";
import { PanePreview } from "./PanePreview";
@@ -69,11 +69,11 @@ const TREE_WIDTH_DEFAULT = 288; // matches the old fixed w-72
const TREE_WIDTH_MIN = 200;
const TREE_WIDTH_MAX = 520;
function expandedStorageKey(hostId: number): string {
function expandedStorageKey(hostId: string | number): string {
return `${LS_PREFIX}expanded-${hostId}`;
}
function readStoredExpanded(hostId: number): Set<string> | null {
function readStoredExpanded(hostId: string | number): Set<string> | null {
try {
const raw = localStorage.getItem(expandedStorageKey(hostId));
if (!raw) return null;
@@ -93,9 +93,20 @@ export function TmuxMonitor({
isVisible?: boolean;
}) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(true);
const [selectedHostId, setSelectedHostId] = useState<number | null>(null);
const toast = useToast();
const { hosts: allHosts, loaded: hostsLoaded } = useHosts();
const hosts = useMemo(
() =>
allHosts.filter(
(h) =>
((h.connectionType as string | undefined) ?? "ssh") === "ssh" &&
tmuxMonitorEnabled(h),
),
[allHosts],
);
const [selectedHostId, setSelectedHostId] = useState<string | number | null>(
null,
);
const [overview, setOverview] = useState<TmuxOverview | null>(null);
const [overviewLoading, setOverviewLoading] = useState(false);
const [overviewError, setOverviewError] = useState<string | null>(null);
@@ -120,7 +131,7 @@ export function TmuxMonitor({
// Host whose data is currently on screen; responses for any other host are
// stale (the user switched away while the request was in flight) and must
// not overwrite the current tree.
const activeHostRef = useRef<number | null>(null);
const activeHostRef = useRef<string | number | null>(null);
const overviewSignatureRef = useRef("");
const metricsSignatureRef = useRef("");
// True when the expanded-session set for the current host was restored from
@@ -131,7 +142,7 @@ export function TmuxMonitor({
// that change the layout (split / kill-pane / new-window) the attached
// client can render stale borders; a refit forces a PTY resize which makes
// tmux fully redraw the client.
const previewTermRef = useRef<TerminalHandle | null>(null);
const previewTermRef = useRef<TabHandle | null>(null);
const nudgePreviewRedraw = useCallback(() => {
setTimeout(() => {
previewTermRef.current?.fit?.();
@@ -180,65 +191,43 @@ export function TmuxMonitor({
[treeWidth],
);
// -- hosts ----------------------------------------------------------------
const loadHosts = useCallback(
async (initial: boolean) => {
try {
const all: SSHHost[] = await getSSHHosts();
const sshHosts = all.filter(
(h) =>
(h.connectionType ?? "ssh") === "ssh" &&
h.enableTmuxMonitor === true,
);
setHosts(sshHosts);
setSelectedHostId((prev) => {
if (prev !== null && sshHosts.some((h) => h.id === prev)) return prev;
if (sshHosts.length === 0) return null;
if (initial) {
if (
initialHostId != null &&
sshHosts.some((h) => h.id === initialHostId)
)
return initialHostId;
const stored = Number(localStorage.getItem(LS_LAST_HOST_KEY));
if (
Number.isFinite(stored) &&
sshHosts.some((h) => h.id === stored)
)
return stored;
}
return sshHosts[0].id;
});
} catch {
if (initial) toast.error(t("tmuxMonitor.failedToLoadHosts"));
} finally {
if (initial) setHostsLoading(false);
// -- host selection ---------------------------------------------------------
const initialHostPicked = useRef(false);
useEffect(() => {
if (!hostsLoaded || initialHostPicked.current) return;
setSelectedHostId((prev) => {
if (prev !== null && hosts.some((h) => h.id === prev)) return prev;
if (hosts.length === 0) return null;
if (
initialHostId != null &&
hosts.some((h) => String(h.id) === String(initialHostId))
) {
initialHostPicked.current = true;
return initialHostId;
}
},
// initialHostId is only consulted on the initial load; the mount effect
// already runs once and the follow-initialHostId effect handles updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
[t],
);
const stored = localStorage.getItem(LS_LAST_HOST_KEY);
if (stored !== null && hosts.some((h) => String(h.id) === stored)) {
initialHostPicked.current = true;
return stored;
}
initialHostPicked.current = true;
return hosts[0].id;
});
}, [hostsLoaded, hosts, initialHostId]);
// If the selected host disappears (deleted, or the monitor was turned off
// for it), fall back the same way the initial pick does.
useEffect(() => {
loadHosts(true);
}, [loadHosts]);
// The tab stays mounted while hosts are added or removed elsewhere (Host
// Manager, sidebar), so follow the app-wide change event to keep the list
// and selection current.
useEffect(() => {
const handler = () => loadHosts(false);
window.addEventListener("termix:hosts-changed", handler);
return () => window.removeEventListener("termix:hosts-changed", handler);
}, [loadHosts]);
if (!hostsLoaded || selectedHostId === null) return;
if (hosts.some((h) => h.id === selectedHostId)) return;
setSelectedHostId(hosts.length > 0 ? hosts[0].id : null);
}, [hostsLoaded, hosts, selectedHostId]);
// Reopening the singleton tab from a host action updates initialHostId;
// follow it so the requested host gets selected without a remount.
useEffect(() => {
if (initialHostId == null) return;
if (hosts.some((h) => h.id === initialHostId))
if (hosts.some((h) => String(h.id) === String(initialHostId)))
setSelectedHostId(initialHostId);
// Intentionally not depending on `hosts`: the mount effect already picks
// the initial host once hosts load.
@@ -254,13 +243,13 @@ export function TmuxMonitor({
// -- overview polling -----------------------------------------------------
const loadOverview = useCallback(
async (hostId: number, silent = false) => {
async (hostId: string | number, silent = false) => {
if (!silent) {
setOverviewLoading(true);
setOverviewError(null);
}
try {
const data = await getTmuxOverview(hostId);
const data = await getTmuxOverview(Number(hostId));
if (activeHostRef.current !== hostId) return true;
const signature = JSON.stringify(data);
const changed = signature !== overviewSignatureRef.current;
@@ -330,7 +319,7 @@ export function TmuxMonitor({
} finally {
setRefreshing(false);
}
}, [selectedHostId, refreshing, overview, loadOverview, t]);
}, [selectedHostId, refreshing, overview, loadOverview, t, toast]);
useEffect(() => {
activeHostRef.current = selectedHostId;
@@ -348,7 +337,8 @@ export function TmuxMonitor({
expandedRestoredRef.current = storedExpanded !== null;
setExpandedSessions(storedExpanded ?? new Set());
loadOverview(selectedHostId);
}, [selectedHostId, loadOverview]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedHostId]);
// Poll only while this app tab is shown: the component stays mounted when
// the user switches tabs (AppShell keeps tab content alive) and would
@@ -369,7 +359,7 @@ export function TmuxMonitor({
useAdaptivePolling(
async () => {
if (selectedHostId === null) return;
const next = await getTmuxMetrics(selectedHostId);
const next = await getTmuxMetrics(Number(selectedHostId));
const signature = JSON.stringify(next);
const changed = signature !== metricsSignatureRef.current;
metricsSignatureRef.current = signature;
@@ -412,7 +402,7 @@ export function TmuxMonitor({
const query = searchQuery.trim();
setSearching(true);
try {
const result = await searchTmux(selectedHostId, query);
const result = await searchTmux(Number(selectedHostId), query);
setSearchResults(result.matches);
setSearchLimits(result);
setSearchedQuery(query);
@@ -423,16 +413,19 @@ export function TmuxMonitor({
}
}
const persistExpanded = useCallback((hostId: number, set: Set<string>) => {
try {
localStorage.setItem(
expandedStorageKey(hostId),
JSON.stringify([...set]),
);
} catch {
// localStorage may be unavailable
}
}, []);
const persistExpanded = useCallback(
(hostId: string | number, set: Set<string>) => {
try {
localStorage.setItem(
expandedStorageKey(hostId),
JSON.stringify([...set]),
);
} catch {
// localStorage may be unavailable
}
},
[],
);
function toggleSession(name: string) {
const next = new Set(expandedSessions);
@@ -449,7 +442,7 @@ export function TmuxMonitor({
(pane: SelectedPane) => {
setSelectedPane(pane);
if (selectedHostId !== null)
focusTmuxPane(selectedHostId, pane.paneId).catch(() => {});
focusTmuxPane(Number(selectedHostId), pane.paneId).catch(() => {});
},
[selectedHostId],
);
@@ -483,7 +476,7 @@ export function TmuxMonitor({
const name = newSessionName.trim();
setCreatingSession(true);
try {
await createTmuxSession(selectedHostId, name);
await createTmuxSession(Number(selectedHostId), name);
toast.success(t("tmuxMonitor.sessionCreated", { name }));
setNewSessionOpen(false);
setNewSessionName("");
@@ -510,14 +503,14 @@ export function TmuxMonitor({
async (paneId: string, direction: "h" | "v") => {
if (selectedHostId === null) return;
try {
await splitTmuxPane(selectedHostId, paneId, direction);
await splitTmuxPane(Number(selectedHostId), paneId, direction);
loadOverview(selectedHostId, true);
nudgePreviewRedraw();
} catch {
toast.error(t("tmuxMonitor.splitFailed"));
}
},
[selectedHostId, loadOverview, nudgePreviewRedraw, t],
[selectedHostId, loadOverview, nudgePreviewRedraw, t, toast],
);
// -- new window ---------------------------------------------------------------
@@ -525,14 +518,14 @@ export function TmuxMonitor({
async (sessionName: string) => {
if (selectedHostId === null) return;
try {
await createTmuxWindow(selectedHostId, sessionName);
await createTmuxWindow(Number(selectedHostId), sessionName);
loadOverview(selectedHostId, true);
nudgePreviewRedraw();
} catch {
toast.error(t("tmuxMonitor.windowCreateFailed"));
}
},
[selectedHostId, loadOverview, nudgePreviewRedraw, t],
[selectedHostId, loadOverview, nudgePreviewRedraw, t, toast],
);
// -- collapse / expand all ----------------------------------------------------
@@ -566,7 +559,7 @@ export function TmuxMonitor({
const newName = renameDraft.trim();
setRenaming(true);
try {
await renameTmuxSession(selectedHostId, renameTarget, newName);
await renameTmuxSession(Number(selectedHostId), renameTarget, newName);
toast.success(t("tmuxMonitor.sessionRenamed", { name: newName }));
if (expandedSessions.has(renameTarget)) {
const next = new Set(expandedSessions);
@@ -596,7 +589,7 @@ export function TmuxMonitor({
if (selectedHostId === null || killTarget === null || killing) return;
setKilling(true);
try {
await killTmuxSession(selectedHostId, killTarget);
await killTmuxSession(Number(selectedHostId), killTarget);
toast.success(t("tmuxMonitor.sessionKilled", { name: killTarget }));
if (selectedPane?.sessionName === killTarget) setSelectedPane(null);
if (expandedSessions.has(killTarget)) {
@@ -652,7 +645,7 @@ export function TmuxMonitor({
setKillingWindow(true);
try {
await killTmuxWindow(
selectedHostId,
Number(selectedHostId),
killWindowTarget.sessionName,
killWindowTarget.windowIndex,
);
@@ -660,7 +653,7 @@ export function TmuxMonitor({
selectedPane?.sessionName === killWindowTarget.sessionName &&
selectedPane?.windowIndex === killWindowTarget.windowIndex;
setKillWindowTarget(null);
const data = await getTmuxOverview(selectedHostId);
const data = await getTmuxOverview(Number(selectedHostId));
setOverview(data);
if (wasViewing) selectSurvivor(data, killWindowTarget.sessionName);
nudgePreviewRedraw();
@@ -683,13 +676,13 @@ export function TmuxMonitor({
return;
setKillingPane(true);
try {
await killTmuxPane(selectedHostId, killPaneTarget);
await killTmuxPane(Number(selectedHostId), killPaneTarget);
const viewedSession =
selectedPane?.paneId === killPaneTarget
? selectedPane.sessionName
: null;
setKillPaneTarget(null);
const data = await getTmuxOverview(selectedHostId);
const data = await getTmuxOverview(Number(selectedHostId));
setOverview(data);
if (viewedSession) selectSurvivor(data, viewedSession);
nudgePreviewRedraw();
@@ -722,7 +715,7 @@ export function TmuxMonitor({
.filter(Boolean);
setSavingTags(true);
try {
await setTmuxSessionTags(selectedHostId, tagsTarget, tags);
await setTmuxSessionTags(Number(selectedHostId), tagsTarget, tags);
toast.success(t("tmuxMonitor.tagsSaved"));
setTagsTarget(null);
loadOverview(selectedHostId, true);
@@ -769,17 +762,8 @@ export function TmuxMonitor({
if (session) attachInline(session);
return;
}
// The standalone view of whichever plugin connects to a host by default.
const selectedHost = hosts.find((h) => h.id === selectedHostId);
const view = selectedHost
? defaultConnectAction(
listHostActions(),
selectedHost as unknown as Parameters<typeof defaultConnectAction>[1],
)?.copyUrlView
: undefined;
if (!view) return;
const params = new URLSearchParams({
view,
view: "terminal",
hostId: String(selectedHostId),
});
if (session) params.set("tmuxSession", session);
@@ -804,9 +788,12 @@ export function TmuxMonitor({
return map;
}, [metrics]);
const selectedHost = hosts.find((h) => h.id === selectedHostId);
const selectedHost: PluginHostRecord | undefined = hosts.find(
(h) => h.id === selectedHostId,
);
const hostLabel = selectedHost
? selectedHost.name || `${selectedHost.username}@${selectedHost.ip}`
? selectedHost.name ||
`${selectedHost.username as string}@${selectedHost.ip}`
: "";
const selectedPaneMetrics = selectedPane
? metricsByPane.get(selectedPane.paneId)
@@ -923,7 +910,7 @@ export function TmuxMonitor({
truncate instead. */}
<ScrollArea className="flex-1 [&_[data-slot=scroll-area-viewport]>div]:!block">
<div className="p-2">
{!hostsLoading && hosts.length === 0 && (
{hostsLoaded && hosts.length === 0 && (
<div className="px-2 py-4">
<p className="text-sm text-muted-foreground">
{t("tmuxMonitor.noHosts")}
@@ -937,9 +924,9 @@ export function TmuxMonitor({
<div className="space-y-3 px-2 py-2">
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-5 w-full rounded-md" />
<Skeleton className="ml-6 h-3.5 w-3/4 rounded-md" />
<Skeleton className="ml-6 h-3.5 w-2/3 rounded-md" />
<Skeleton className="h-5 w-full" />
<Skeleton className="ml-6 h-3.5 w-3/4" />
<Skeleton className="ml-6 h-3.5 w-2/3" />
</div>
))}
</div>
@@ -1101,7 +1088,7 @@ export function TmuxMonitor({
onKillPane={() => setKillPaneTarget(selectedPane.paneId)}
onClose={() => setSelectedPane(null)}
/>
) : !hostsLoading && hosts.length === 0 ? (
) : hostsLoaded && hosts.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<div className="max-w-sm text-center text-muted-foreground">
<Server className="mx-auto mb-2 size-8 opacity-50" />
@@ -1,11 +1,8 @@
import React from "react";
import { TmuxMonitor } from "@/features/tmux-monitor/TmuxMonitor.tsx";
import type { StandaloneViewProps } from "@termix/plugin-sdk/frontend";
import { TmuxMonitor } from "./TmuxMonitor";
interface TmuxMonitorAppProps {
hostId?: string;
}
const TmuxMonitorApp: React.FC<TmuxMonitorAppProps> = ({ hostId }) => {
/** `?view=tmux_monitor` full-screen links. */
function TmuxMonitorApp({ hostId }: StandaloneViewProps) {
const parsed = hostId ? parseInt(hostId, 10) : NaN;
return (
<div className="h-screen w-screen overflow-hidden">
@@ -14,6 +11,6 @@ const TmuxMonitorApp: React.FC<TmuxMonitorAppProps> = ({ hostId }) => {
/>
</div>
);
};
}
export default TmuxMonitorApp;
@@ -1,4 +1,16 @@
import { tmuxMonitorApi } from "@/main-axios";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
let client: PluginApiClient | null = null;
/** Set from activate with app.api, cleared on deactivate. */
export function setTmuxMonitorApi(api: PluginApiClient | null): void {
client = api;
}
function api(): PluginApiClient {
if (!client) throw new Error("The Tmux Monitor plugin is not active");
return client;
}
export interface TmuxPane {
id: string;
@@ -53,7 +65,7 @@ export interface TmuxPaneMetrics {
}
export async function getTmuxOverview(hostId: number): Promise<TmuxOverview> {
const response = await tmuxMonitorApi.get(`/${hostId}/overview`);
const response = await api().get<TmuxOverview>(`/${hostId}/overview`);
return response.data;
}
@@ -63,7 +75,7 @@ export async function focusTmuxPane(
hostId: number,
paneId: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/focus`, { paneId });
await api().post(`/${hostId}/focus`, { paneId });
}
/** Create a new detached session on the host. Starts the tmux server if
@@ -72,7 +84,7 @@ export async function createTmuxSession(
hostId: number,
name: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/sessions`, { name });
await api().post(`/${hostId}/sessions`, { name });
}
/** Create a new window in an existing session. */
@@ -80,7 +92,7 @@ export async function createTmuxWindow(
hostId: number,
sessionName: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/windows`, { sessionName });
await api().post(`/${hostId}/windows`, { sessionName });
}
/** Rename a session. Saved tags follow the session to its new name. */
@@ -89,7 +101,7 @@ export async function renameTmuxSession(
sessionName: string,
newName: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/rename`, { sessionName, newName });
await api().post(`/${hostId}/rename`, { sessionName, newName });
}
/** Kill a session (terminates all of its windows and processes). */
@@ -97,7 +109,7 @@ export async function killTmuxSession(
hostId: number,
sessionName: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/kill`, { sessionName });
await api().post(`/${hostId}/kill`, { sessionName });
}
/** Kill a window and every pane in it. */
@@ -106,10 +118,7 @@ export async function killTmuxWindow(
sessionName: string,
windowIndex: number,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/kill-window`, {
sessionName,
windowIndex,
});
await api().post(`/${hostId}/kill-window`, { sessionName, windowIndex });
}
/** Kill a single pane (last pane of a window closes the window). */
@@ -117,7 +126,7 @@ export async function killTmuxPane(
hostId: number,
paneId: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/kill-pane`, { paneId });
await api().post(`/${hostId}/kill-pane`, { paneId });
}
/** Split the window containing a pane: "h" adds a pane to the right,
@@ -127,7 +136,7 @@ export async function splitTmuxPane(
paneId: string,
direction: "h" | "v",
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/split`, { paneId, direction });
await api().post(`/${hostId}/split`, { paneId, direction });
}
export interface TmuxSearchResult {
@@ -142,9 +151,10 @@ export async function searchTmux(
hostId: number,
query: string,
): Promise<TmuxSearchResult> {
const response = await tmuxMonitorApi.get(`/${hostId}/search`, {
params: { q: query },
});
const response = await api().get<Partial<TmuxSearchResult>>(
`/${hostId}/search`,
{ params: { q: query } },
);
return {
matches: response.data.matches ?? [],
truncated: response.data.truncated ?? false,
@@ -156,7 +166,9 @@ export async function searchTmux(
export async function getTmuxMetrics(
hostId: number,
): Promise<TmuxPaneMetrics[]> {
const response = await tmuxMonitorApi.get(`/${hostId}/metrics`);
const response = await api().get<{ panes?: TmuxPaneMetrics[] }>(
`/${hostId}/metrics`,
);
return response.data.panes ?? [];
}
@@ -165,7 +177,7 @@ export async function setTmuxSessionTags(
sessionName: string,
tags: string[],
): Promise<string[]> {
const response = await tmuxMonitorApi.put(`/${hostId}/tags`, {
const response = await api().put<{ tags?: string[] }>(`/${hostId}/tags`, {
sessionName,
tags,
});
@@ -1,22 +1,18 @@
import type { TFunction } from "i18next";
import type { TranslateFn } from "@termix/plugin-sdk/frontend";
export function formatRelativeTime(
unixSeconds: number,
nowMs: number,
t: TFunction,
t: TranslateFn,
): string {
if (!unixSeconds) return "";
const diff = Math.max(0, nowMs / 1000 - unixSeconds);
if (diff < 60) return String(t("tmuxMonitor.timeJustNow"));
if (diff < 60) return t("tmuxMonitor.timeJustNow");
if (diff < 3600)
return String(
t("tmuxMonitor.timeMinutes", { count: Math.floor(diff / 60) }),
);
return t("tmuxMonitor.timeMinutes", { count: Math.floor(diff / 60) });
if (diff < 86400)
return String(
t("tmuxMonitor.timeHours", { count: Math.floor(diff / 3600) }),
);
return String(t("tmuxMonitor.timeDays", { count: Math.floor(diff / 86400) }));
return t("tmuxMonitor.timeHours", { count: Math.floor(diff / 3600) });
return t("tmuxMonitor.timeDays", { count: Math.floor(diff / 86400) });
}
export function formatMem(kb: number): string {
@@ -0,0 +1,12 @@
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
/** Whether the monitor is enabled for this host, as the host payload's
* pluginSettings carries it. */
export function tmuxMonitorEnabled(
host: PluginHostRecord | null | undefined,
): boolean {
const settings = (
host?.pluginSettings as Record<string, Record<string, unknown>> | undefined
)?.["tmux-monitor"];
return settings?.enableTmuxMonitor === true;
}
@@ -0,0 +1,60 @@
import { Layers } from "lucide-react";
import type {
StandaloneViewProps,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { setTmuxMonitorApi } from "./api";
import { tmuxMonitorEnabled } from "./host-tmux-monitor";
import { HostTmuxMonitorSection } from "./HostTmuxMonitorSection";
import { TmuxMonitor } from "./TmuxMonitor";
import TmuxMonitorApp from "./TmuxMonitorApp";
const VIEW_ID = "tmux_monitor";
function TmuxMonitorTab({ tab, isVisible }: TabProps) {
const hostId = (tab.data?.hostId as number | undefined) ?? undefined;
return <TmuxMonitor initialHostId={hostId} isVisible={isVisible} />;
}
function TmuxMonitorStandalone(props: StandaloneViewProps) {
return <TmuxMonitorApp {...props} />;
}
export function activate(app: TermixApp): void {
setTmuxMonitorApi(app.api);
app.onDispose(() => setTmuxMonitorApi(null));
// A single cross-host browser, like the old core tab: opening it from a
// host row just points the already-open (or newly opened) tab at that host.
app.registerTab(VIEW_ID, TmuxMonitorTab, {
icon: Layers,
titleKey: "nav.tmuxMonitor",
singleton: true,
hostless: true,
persistent: true,
activityTypes: ["tmux_monitor"],
standalone: TmuxMonitorStandalone,
});
app.registerHostAction({
id: "tmux_monitor",
titleKey: "nav.tmuxMonitor",
icon: Layers,
kind: "open",
order: 70,
copyUrlView: VIEW_ID,
when: (host) => !!host.enableSsh && tmuxMonitorEnabled(host),
run: (host, shell) =>
shell.openSingletonTab(VIEW_ID, { data: { hostId: Number(host.id) } }),
});
app.registerHostEditorSection({
id: "tmux-monitor",
group: "ssh",
titleKey: "hosts.tabTmuxMonitor",
icon: Layers,
order: 45,
component: HostTmuxMonitorSection,
});
}
@@ -0,0 +1,63 @@
import { useEffect, useRef } from "react";
import { runAdaptivePolling } from "@termix/plugin-sdk/ui";
type AdaptivePollResult = boolean | void;
interface AdaptivePollingPolicy {
minIntervalMs: number;
maxIntervalMs: number;
stablePollsPerStep?: number;
jitterRatio?: number;
maxRequestDutyCycle?: number;
}
export function useAdaptivePolling(
poll: () => AdaptivePollResult | Promise<AdaptivePollResult>,
policy: AdaptivePollingPolicy,
enabled = true,
options: {
runImmediately?: boolean;
onError?: (error: unknown) => void;
} = {},
): void {
const pollRef = useRef(poll);
const errorRef = useRef(options.onError);
pollRef.current = poll;
errorRef.current = options.onError;
const {
minIntervalMs,
maxIntervalMs,
stablePollsPerStep,
jitterRatio,
maxRequestDutyCycle,
} = policy;
const runImmediately = options.runImmediately;
useEffect(
() =>
runAdaptivePolling(
() => pollRef.current(),
{
minIntervalMs,
maxIntervalMs,
stablePollsPerStep,
jitterRatio,
maxRequestDutyCycle,
},
{
enabled: () => enabled,
runImmediately,
onError: (error) => errorRef.current?.(error),
},
),
[
enabled,
jitterRatio,
maxIntervalMs,
minIntervalMs,
runImmediately,
stablePollsPerStep,
maxRequestDutyCycle,
],
);
}
@@ -0,0 +1,120 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export function host(
overrides: Partial<PluginHostSummary> = {},
): PluginHostSummary {
return {
id: 7,
userId: "user-1",
name: "web",
ip: "10.0.0.5",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
...overrides,
};
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
export async function startServer(
options: {
permissions?: string[];
users?: string[];
hosts?: PluginHostSummary[];
/** hostId -> enableTmuxMonitor, set through ctx.settings before activate. */
enabledHosts?: number[];
sshClient?: unknown;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of options.users ?? ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
for (const h of options.hosts ?? [host()]) {
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(h.id);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["tmux-monitor.use"],
hosts: options.hosts ?? [host()],
sshClient: options.sshClient,
});
for (const hostId of options.enabledHosts ?? [7]) {
await mock.ctx.settings.setHost(hostId, "enableTmuxMonitor", true);
}
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
for (const dispose of [...mock.disposals].reverse()) await dispose();
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// tmux_session_tags as core's SQLite bootstrap created it before 2.9.0.
// Core never indexed it.
const LEGACY_DDL = `
CREATE TABLE tmux_session_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
session_name TEXT NOT NULL,
tag TEXT NOT NULL,
created_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
);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
describe("adopting tmux_session_tags", () => {
it("keeps every existing row when the legacy table is there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO tmux_session_tags (user_id, host_id, session_name, tag) VALUES (?, ?, ?, ?)",
)
.run("user-1", 7, "work", "lab");
},
});
expect(db.applied).toEqual(["0001_adopt_tmux_session_tags"]);
expect(tableExists("tmux_session_tags")).toBe(false);
expect(tableExists("p_tmux_monitor_session_tags")).toBe(true);
expect(
db.sqlite
.prepare(
"SELECT user_id, host_id, session_name, tag FROM p_tmux_monitor_session_tags",
)
.all(),
).toEqual([
{ user_id: "user-1", host_id: 7, session_name: "work", tag: "lab" },
]);
});
it("creates the table fresh when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("tmux_session_tags")).toBe(false);
expect(tableExists("p_tmux_monitor_session_tags")).toBe(true);
expect(
db.sqlite.prepare("SELECT * FROM p_tmux_monitor_session_tags").all(),
).toEqual([]);
});
it("cascades on host delete", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
},
});
db.sqlite
.prepare(
"INSERT INTO p_tmux_monitor_session_tags (user_id, host_id, session_name, tag) VALUES (?, ?, ?, ?)",
)
.run("user-1", 7, "work", "lab");
db.sqlite.exec("DELETE FROM ssh_data WHERE id = 7");
expect(
db.sqlite.prepare("SELECT * FROM p_tmux_monitor_session_tags").all(),
).toEqual([]);
});
});
@@ -11,7 +11,7 @@ import {
shellEscape,
type ProcessInfo,
type TmuxWindow,
} from "../../../hosts/tmux/monitor-helpers.js";
} from "../../src/backend/monitor-helpers.js";
function join(...fields: (string | number)[]): string {
return fields.join(SEP);
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it } from "vitest";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { sessionTags } from "../../src/backend/tables.js";
import { createSessionTagRepository } from "../../src/backend/repository.js";
import { manifest, pluginDir } from "./helpers";
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
async function createRepository() {
db = await createTestDb(pluginDir);
db.sqlite.exec(
"INSERT INTO users (id, username) VALUES ('user-1', 'user-1'), ('user-2', 'user-2')",
);
db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
});
const table = await mock.ctx.db.define(sessionTags);
const repository = createSessionTagRepository(mock.ctx.db, table);
db.sqlite.exec(`
INSERT INTO p_tmux_monitor_session_tags (user_id, host_id, session_name, tag)
VALUES
('user-1', 1, 'api', 'prod'),
('user-1', 1, 'api', 'critical'),
('user-1', 1, 'worker', 'batch'),
('user-2', 2, 'api', 'other');
`);
return repository;
}
describe("createSessionTagRepository", () => {
it("groups tags by session for a user and host", async () => {
const repository = await createRepository();
const tags = await repository.listByUserAndHost("user-1", 1);
expect(tags.get("api")).toEqual(["prod", "critical"]);
expect(tags.get("worker")).toEqual(["batch"]);
expect(tags.has("missing")).toBe(false);
});
it("renames tags by host/session, leaving other hosts alone", async () => {
const repository = await createRepository();
await repository.renameSessionForHost(1, "api", "api-renamed");
const renamed = await repository.listByUserAndHost("user-1", 1);
expect(renamed.get("api-renamed")).toEqual(["prod", "critical"]);
expect(renamed.has("api")).toBe(false);
const other = await repository.listByUserAndHost("user-2", 2);
expect(other.get("api")).toEqual(["other"]);
});
it("deletes tags by host/session", async () => {
const repository = await createRepository();
await repository.deleteSessionForHost(1, "api");
const tags = await repository.listByUserAndHost("user-1", 1);
expect(tags.has("api")).toBe(false);
expect(tags.get("worker")).toEqual(["batch"]);
});
it("replaces tags for one user/host/session only", async () => {
const repository = await createRepository();
await repository.replaceForUserHostSession("user-1", 1, "api", [
"blue",
"green",
]);
const tags = await repository.listByUserAndHost("user-1", 1);
expect(tags.get("api")).toEqual(["blue", "green"]);
expect(tags.get("worker")).toEqual(["batch"]);
await repository.replaceForUserHostSession("user-1", 1, "worker", []);
const cleared = await repository.listByUserAndHost("user-1", 1);
expect(cleared.has("worker")).toBe(false);
});
});
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it } from "vitest";
import { EventEmitter } from "node:events";
import { startServer, type TestServer } from "./helpers";
function fakeSshClient(outputs: Record<string, string>, exitCode = 0) {
const client = new EventEmitter() as EventEmitter & {
exec: (
command: string,
cb: (err: Error | null, stream: unknown) => void,
) => void;
};
client.exec = (command, cb) => {
const stream = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
};
stream.stderr = new EventEmitter();
const matchKey = Object.keys(outputs).find((k) => command.includes(k));
const output = matchKey ? outputs[matchKey] : "";
cb(null, stream);
setImmediate(() => {
if (output) stream.emit("data", Buffer.from(output));
stream.emit("close", exitCode);
});
};
return client;
}
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
describe("GET /:hostId/overview", () => {
it("403s when the host does not exist or is not reachable", async () => {
server = await startServer({ hosts: [] });
const res = await server.request("GET", "/7/overview");
expect(res.status).toBe(403);
});
it("403s when the monitor is disabled for the host", async () => {
server = await startServer({ enabledHosts: [] });
const res = await server.request("GET", "/7/overview");
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not enabled/i);
});
it("403s without the tmux-monitor.use permission", async () => {
server = await startServer({ permissions: [] });
const res = await server.request("GET", "/7/overview");
expect(res.status).toBe(403);
});
it("reports tmux unavailable when the exec exits non-zero", async () => {
server = await startServer({ sshClient: fakeSshClient({}, 127) });
const res = await server.request("GET", "/7/overview");
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false, sessions: [] });
});
});
describe("PUT /:hostId/tags", () => {
it("saves tags for the acting user and session", async () => {
server = await startServer();
const res = await server.request("PUT", "/7/tags", {
body: { sessionName: "work", tags: ["a", "b", "b", " "] },
});
expect(res.status).toBe(200);
expect(res.body.tags.sort()).toEqual(["a", "b"]);
});
it("rejects a non-array tags field", async () => {
server = await startServer();
const res = await server.request("PUT", "/7/tags", {
body: { sessionName: "work", tags: "nope" },
});
expect(res.status).toBe(400);
});
});
describe("POST /:hostId/sessions", () => {
it("rejects an invalid session name", async () => {
server = await startServer();
const res = await server.request("POST", "/7/sessions", {
body: { name: "bad:name" },
});
expect(res.status).toBe(400);
});
});
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { createFakeContext } from "@termix/plugin-sdk/testing";
import { createTmuxSessionsService } from "../../src/backend/service.js";
function fakeSshClient(outputs: Record<string, string>, exitCode = 0) {
const client = new EventEmitter() as EventEmitter & {
exec: (
command: string,
cb: (err: Error | null, stream: unknown) => void,
) => void;
};
client.exec = (command, cb) => {
const stream = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
};
stream.stderr = new EventEmitter();
const matchKey = Object.keys(outputs).find((k) => command.includes(k));
const output = matchKey ? outputs[matchKey] : "";
cb(null, stream);
setImmediate(() => {
if (output) stream.emit("data", Buffer.from(output));
stream.emit("close", exitCode);
});
};
return client;
}
describe("createTmuxSessionsService", () => {
it("detect() reports unavailable when tmux -V fails", async () => {
const { ctx } = createFakeContext({ pluginId: "tmux-monitor" });
const service = createTmuxSessionsService(ctx);
const client = fakeSshClient({}, 127);
await expect(service.detect(client)).resolves.toEqual({
available: false,
sessions: [],
});
});
it("detect() lists session names when tmux is available", async () => {
const { ctx } = createFakeContext({ pluginId: "tmux-monitor" });
const service = createTmuxSessionsService(ctx);
const client = fakeSshClient({
"-V": "tmux 3.7b\n",
"list-sessions": "main|1|2|1\nlogs|3|4|0\n",
});
await expect(service.detect(client)).resolves.toEqual({
available: true,
sessions: ["main", "logs"],
});
});
it("attachOrCreate() writes the attach command to the stream", async () => {
const { ctx } = createFakeContext({ pluginId: "tmux-monitor" });
const service = createTmuxSessionsService(ctx);
const writes: string[] = [];
const stream = { write: (data: string) => writes.push(data) };
await service.attachOrCreate(stream, "main");
expect(writes).toHaveLength(1);
expect(writes[0]).toContain("attach-session -t");
expect(writes[0]).toContain("main");
});
it("waitForSession() falls back to the requested name on timeout", async () => {
vi.useFakeTimers();
try {
const { ctx } = createFakeContext({ pluginId: "tmux-monitor" });
const warn = vi.fn();
ctx.log.warn = warn;
const service = createTmuxSessionsService(ctx);
const client = fakeSshClient({}, 1);
const result = service.waitForSession(client, "new-session");
await vi.advanceTimersByTimeAsync(5100);
await expect(result).resolves.toBe("new-session");
expect(warn).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
@@ -6,7 +6,7 @@ import {
detectTmux,
tmuxCommand,
withTmuxPath,
} from "../../../hosts/tmux/helper.js";
} from "../../src/backend/tmux-commands.js";
describe("tmux command path handling", () => {
it("prepends all non-login tmux paths while preserving inherited PATH", () => {
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const declared = (views?: Array<{ id: string }>) =>
(views ?? []).map((view) => view.id).sort();
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
return {
get: vi.fn(async () => ({ data: {} })),
post: vi.fn(async () => ({ data: {} })),
put: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: {} })),
...overrides,
} as PluginApiClient;
}
describe(`${manifest.id} activate`, () => {
it("registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
const contributes = manifest.contributes as unknown as Record<
string,
Array<{ id: string }> | undefined
>;
const tabs = declared(contributes.tabs);
expect(rendered.registered.tabs()).toEqual(["tmux_monitor"]);
expect(rendered.registered.hostEditorSections()).toEqual(["tmux-monitor"]);
expect(rendered.registered.hostActions()).toEqual([
expect.objectContaining({ id: "tmux_monitor", tabType: undefined }),
]);
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.hostActions()).toEqual([]);
expect(app.registered.hostEditorSections()).toEqual([]);
});
});
describe("host editor section", () => {
it("writes the enable switch into the form's tmux-monitor plugin settings", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
let form: Record<string, unknown> = {
name: "web",
pluginSettings: { "tmux-monitor": { enableTmuxMonitor: false } },
};
const updateForm = vi.fn(
(
patch: (current: Record<string, unknown>) => Record<string, unknown>,
) => {
form = patch(form);
},
);
rendered.renderHostEditorSection("tmux-monitor", {
form,
setField: vi.fn(),
updateForm,
protocols: { ssh: true },
});
fireEvent.click(screen.getByRole("button"));
const settings = (form.pluginSettings as Record<string, unknown>)[
"tmux-monitor"
] as { enableTmuxMonitor: boolean };
expect(settings.enableTmuxMonitor).toBe(true);
});
});
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { tmuxMonitorEnabled } from "../../src/frontend/host-tmux-monitor";
describe("tmuxMonitorEnabled", () => {
it("is false when there is no pluginSettings", () => {
expect(tmuxMonitorEnabled(undefined)).toBe(false);
expect(
tmuxMonitorEnabled({ id: "1", name: "a", ip: "1.2.3.4", port: 22 }),
).toBe(false);
});
it("is false when another plugin's settings are present but not this one's", () => {
expect(
tmuxMonitorEnabled({
id: "1",
name: "a",
ip: "1.2.3.4",
port: 22,
pluginSettings: { tunnels: { enableTunnel: true } },
}),
).toBe(false);
});
it("is true only when enableTmuxMonitor is exactly true", () => {
expect(
tmuxMonitorEnabled({
id: "1",
name: "a",
ip: "1.2.3.4",
port: 22,
pluginSettings: { "tmux-monitor": { enableTmuxMonitor: true } },
}),
).toBe(true);
expect(
tmuxMonitorEnabled({
id: "1",
name: "a",
ip: "1.2.3.4",
port: 22,
pluginSettings: { "tmux-monitor": { enableTmuxMonitor: "true" } },
}),
).toBe(false);
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
-73
View File
@@ -1950,79 +1950,6 @@ const migrateSchema = () => {
}
}
// --- tmux-monitor begin ---
try {
sqlite.prepare("SELECT id FROM tmux_session_tags LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS tmux_session_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
session_name TEXT NOT NULL,
tag TEXT NOT NULL,
created_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
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create tmux_session_tags table", {
operation: "schema_migration",
error: createError,
});
}
}
// Rebuild pre-release tables created without the host FK so deleting a
// host cascades to its tags (SQLite cannot add a FK via ALTER TABLE).
try {
const tagFks = sqlite
.prepare("PRAGMA foreign_key_list(tmux_session_tags)")
.all() as Array<{ table: string; from: string }>;
const hasHostFk = tagFks.some(
(fk) => fk.from === "host_id" && fk.table === "ssh_data",
);
if (!hasHostFk) {
sqlite.exec(`
BEGIN;
CREATE TABLE tmux_session_tags_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
session_name TEXT NOT NULL,
tag TEXT NOT NULL,
created_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
);
INSERT INTO tmux_session_tags_new (id, user_id, host_id, session_name, tag, created_at)
SELECT id, user_id, host_id, session_name, tag, created_at
FROM tmux_session_tags
WHERE user_id IN (SELECT id FROM users)
AND host_id IN (SELECT id FROM ssh_data);
DROP TABLE tmux_session_tags;
ALTER TABLE tmux_session_tags_new RENAME TO tmux_session_tags;
COMMIT;
`);
databaseLogger.info("Rebuilt tmux_session_tags with host FK", {
operation: "schema_migration",
});
}
} catch (rebuildError) {
try {
sqlite.exec("ROLLBACK;");
} catch {
// no transaction open
}
databaseLogger.warn("Failed to add host FK to tmux_session_tags", {
operation: "schema_migration",
error: rebuildError,
});
}
// --- tmux-monitor end ---
try {
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
-17
View File
@@ -1204,23 +1204,6 @@ export const termixIdentityCa = mysqlTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- tmux-monitor begin ---
export const tmuxSessionTags = mysqlTable("tmux_session_tags", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sessionName: text("session_name").notNull(),
tag: text("tag").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// --- tmux-monitor end ---
// --- metrics-history begin ---
export const hostMetricsHistory = mysqlTable("host_metrics_history", {
id: int("id").autoincrement().primaryKey(),
-17
View File
@@ -1205,23 +1205,6 @@ export const termixIdentityCa = pgTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- tmux-monitor begin ---
export const tmuxSessionTags = pgTable("tmux_session_tags", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sessionName: text("session_name").notNull(),
tag: text("tag").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- tmux-monitor end ---
// --- metrics-history begin ---
export const hostMetricsHistory = pgTable("host_metrics_history", {
id: serial("id").primaryKey(),
-17
View File
@@ -1203,23 +1203,6 @@ export const termixIdentityCa = sqliteTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- tmux-monitor begin ---
export const tmuxSessionTags = sqliteTable("tmux_session_tags", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sessionName: text("session_name").notNull(),
tag: text("tag").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- tmux-monitor end ---
// --- metrics-history begin ---
export const hostMetricsHistory = sqliteTable("host_metrics_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
+1 -8
View File
@@ -53,7 +53,6 @@ import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { SsoProviderRepository } from "./sso-provider-repository.js";
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
import { TermixIdentityRepository } from "./termix-identity-repository.js";
import { TmuxSessionTagRepository } from "./tmux-session-tag-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
import { UserDataExportRepository } from "./user-data-export-repository.js";
import { UserPreferenceRepository } from "./user-preference-repository.js";
@@ -482,14 +481,8 @@ export function createCurrentTermixIdentityRepository(): TermixIdentityRepositor
);
}
export function createCurrentTmuxSessionTagRepository(): TmuxSessionTagRepository {
return new TmuxSessionTagRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("tmux_session_tag_repository_write"),
);
}
// transfer_recent moved to the file-manager plugin.
// tmux_session_tags moved to the tmux-monitor plugin.
export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
return new TrustedDeviceRepository(
@@ -1,136 +0,0 @@
import { and, eq } from "drizzle-orm";
import { tmuxSessionTags } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect;
export interface TmuxSessionTagInput {
userId: string;
hostId: number;
sessionName: string;
tag: string;
}
export class TmuxSessionTagRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserAndHost(
userId: string,
hostId: number,
): Promise<Map<string, string[]>> {
const rows = await this.context.drizzle
.select()
.from(tmuxSessionTags)
.where(
and(
eq(tmuxSessionTags.userId, userId),
eq(tmuxSessionTags.hostId, hostId),
),
);
const bySession = new Map<string, string[]>();
for (const row of rows) {
const tags = bySession.get(row.sessionName) ?? [];
tags.push(row.tag);
bySession.set(row.sessionName, tags);
}
return bySession;
}
async renameSessionForHost(
hostId: number,
sessionName: string,
newSessionName: string,
): Promise<number> {
const result = await this.context.drizzle
.update(tmuxSessionTags)
.set({ sessionName: newSessionName })
.where(
and(
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteSessionForHost(
hostId: number,
sessionName: string,
): Promise<number> {
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(
and(
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async replaceForUserHostSession(
userId: string,
hostId: number,
sessionName: string,
tags: string[],
): Promise<number> {
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(
and(
eq(tmuxSessionTags.userId, userId),
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
);
if (tags.length > 0) {
await this.context.drizzle.insert(tmuxSessionTags).values(
tags.map((tag) => ({
userId,
hostId,
sessionName,
tag,
})),
);
}
const changedRows = rowsAffected(result) + tags.length;
if (changedRows > 0) {
await this.afterWrite();
}
return changedRows;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(eq(tmuxSessionTags.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -28,7 +28,6 @@ import {
createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository,
createCurrentTermixIdentityRepository,
createCurrentTmuxSessionTagRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
@@ -112,7 +111,6 @@ export async function deleteUserAndRelatedData(
await createCurrentSecretSourceRepository().deleteByUserId(userId);
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
await createCurrentTmuxSessionTagRepository().deleteByUserId(userId);
await createCurrentOpenTabRepository().deleteByUserId(userId);
await createCurrentUserPreferenceRepository().deleteByUserId(userId);
-767
View File
@@ -1,767 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import express from "express";
import cookieParser from "cookie-parser";
import type { Client } from "ssh2";
import { connectHost, getConnectionPoolKey } from "../connect/connect-host.js";
import type { SshConnectHost } from "../connect/types.js";
import { createCorsMiddleware } from "../../utils/cors-config.js";
import { createCompressionMiddleware } from "../../utils/compression-config.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import {
createCurrentTmuxSessionTagRepository,
createCurrentUserRepository,
} from "../../database/repositories/factory.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { sshLogger } from "../../utils/logger.js";
import { resolveHostById, checkHostAccess } from "../host-resolver.js";
import type { HostAction } from "../../utils/permission-manager.js";
import { withConnection } from "../ssh-connection-pool.js";
import { execCommand, tmuxCommand } from "./helper.js";
import {
SEP,
parseSessions,
parseWindows,
parsePanes,
parsePsOutput,
parseGpuOutput,
buildPaneMetrics,
attachPanesToWindows,
shellEscape,
type RawPane,
type TmuxSessionSummary,
type TmuxWindow,
type PaneMetrics,
} from "./monitor-helpers.js";
import type { SSHHost, AuthenticatedRequest } from "../../../types/index.js";
import { listenOnServicePort } from "../../utils/service-listen.js";
const PANE_ID_RE = /^%\d+$/;
// tmux session names cannot contain ":" or "."; keep to a conservative
// printable subset so the name is safe as a tmux target everywhere.
const SESSION_NAME_RE = /^[A-Za-z0-9_@%+=-]{1,64}$/;
const MAX_SEARCH_PANES = 100;
const MAX_MATCHES_PER_PANE = 50;
const SEARCH_HISTORY_LINES = 2000;
const SEARCH_CONCURRENCY = 4;
interface TmuxSessionOverview extends TmuxSessionSummary {
windows: TmuxWindow[];
tags: string[];
}
// SSH connection
export function connectToHost(host: SSHHost): () => Promise<Client> {
return async () => {
const connection = await connectHost(host as unknown as SshConnectHost, {
userId: host.userId || "",
purpose: "tmux",
});
return connection.client;
};
}
function getPoolKey(host: SSHHost): string {
return getConnectionPoolKey(
"tmux-monitor",
host as unknown as SshConnectHost,
);
}
async function withHostConnection<T>(
host: SSHHost,
fn: (client: Client) => Promise<T>,
): Promise<T> {
return withConnection(getPoolKey(host), connectToHost(host), fn);
}
// tmux queries
async function tmuxAvailable(conn: Client): Promise<boolean> {
try {
await execCommand(conn, tmuxCommand("-V"));
return true;
} catch {
return false;
}
}
async function runTmuxList(conn: Client, command: string): Promise<string> {
try {
return await execCommand(conn, command);
} catch {
return ""; // tmux server not running -- no sessions
}
}
function listSessionsCmd(): string {
return tmuxCommand(
`list-sessions -F "#{session_name}${SEP}#{session_created}${SEP}#{session_activity}${SEP}#{session_attached}" 2>/dev/null`,
);
}
function listWindowsCmd(): string {
return tmuxCommand(
`list-windows -a -F "#{session_name}${SEP}#{window_index}${SEP}#{window_active}${SEP}#{window_name}" 2>/dev/null`,
);
}
function listPanesCmd(): string {
return tmuxCommand(
`list-panes -a -F "#{session_name}${SEP}#{window_index}${SEP}#{pane_id}${SEP}#{pane_index}${SEP}#{pane_pid}${SEP}#{pane_active}${SEP}#{pane_width}${SEP}#{pane_height}${SEP}#{pane_current_command}${SEP}#{pane_current_path}${SEP}#{pane_title}" 2>/dev/null`,
);
}
async function listPanesRaw(conn: Client): Promise<RawPane[]> {
return parsePanes(await runTmuxList(conn, listPanesCmd()));
}
async function fetchSessionTags(
userId: string,
hostId: number,
): Promise<Map<string, string[]>> {
return createCurrentTmuxSessionTagRepository().listByUserAndHost(
userId,
hostId,
);
}
async function collectPaneMetrics(
conn: Client,
panes: RawPane[],
): Promise<PaneMetrics[]> {
let psOutput = "";
try {
psOutput = await execCommand(
conn,
"ps -eo pid=,ppid=,pcpu=,pmem=,rss=,comm= 2>/dev/null",
);
} catch {
return [];
}
// GPU memory per pid (best effort; nvidia-smi may not exist)
let gpuOutput = "";
try {
gpuOutput = await execCommand(
conn,
"command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-compute-apps=pid,used_gpu_memory --format=csv,noheader,nounits 2>/dev/null || true",
);
} catch {
// no GPU on host
}
return buildPaneMetrics(
panes,
parsePsOutput(psOutput),
parseGpuOutput(gpuOutput),
);
}
// Express app
const app = express();
app.set("trust proxy", "loopback");
const authManager = AuthManager.getInstance();
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
app.use(authManager.createAuthMiddleware());
/**
* Resolve the host for a request and verify the user can access it.
* Sends the error response and returns null when access is denied.
*/
async function requireHost(
req: express.Request,
res: express.Response,
permission: HostAction = "connect",
): Promise<SSHHost | null> {
const userId = (req as unknown as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.hostId), 10);
if (isNaN(hostId)) {
res.status(400).json({ error: "Invalid host ID" });
return null;
}
if (DataCrypto.getUserDataKey(userId) === null) {
res.status(401).json({ error: "User data is locked" });
return null;
}
let host: SSHHost | null = null;
try {
host = await resolveHostById(hostId, userId);
} catch (err) {
sshLogger.error(`Failed to resolve host ${hostId} for tmux monitor`, err);
}
if (!host) {
res.status(404).json({ error: "Host not found" });
return null;
}
const hasAccess = await checkHostAccess(
hostId,
userId,
host.userId || userId,
permission,
);
if (!hasAccess) {
res.status(403).json({ error: "Access denied" });
return null;
}
// The monitor is opt-in per host (same pattern as enableDocker in
// docker.ts): hiding the UI is not enough, the API must refuse too.
if (!host.enableTmuxMonitor) {
res
.status(403)
.json({ error: "Tmux Monitor is not enabled for this host" });
return null;
}
return host;
}
function toErrorMessage(err: unknown): string {
return getErrorMessage(err);
}
// Destructive tmux actions terminate processes on the remote host, so they
// land in the audit log like other host-level mutations (see host.ts).
async function auditTmuxAction(
req: express.Request,
host: SSHHost,
action: string,
resourceName: string,
details?: Record<string, unknown>,
): Promise<void> {
const userId = (req as unknown as AuthenticatedRequest).userId;
const { ipAddress, userAgent } = getRequestMeta(req);
let username = userId;
try {
const actor = await createCurrentUserRepository().findById(userId);
username = actor?.username ?? userId;
} catch {
// fall back to the raw user id
}
await logAudit({
userId,
username,
action,
resourceType: "host",
resourceId: String(host.id),
resourceName,
details: details ? JSON.stringify(details) : undefined,
ipAddress,
userAgent,
success: true,
});
}
// Typed error codes so the frontend can render a helpful state instead of a
// raw 500 (same pattern as SESSION_EXPIRED handling in main-axios).
type TmuxErrorCode =
"TMUX_NOT_INSTALLED" | "TMUX_NO_SERVER" | "HOST_UNREACHABLE" | "TMUX_ERROR";
function classifyTmuxError(err: unknown): TmuxErrorCode {
const msg = getErrorMessage(err, "");
if (/command not found|exited with code 127/i.test(msg))
return "TMUX_NOT_INSTALLED";
if (/no server running|lost server/i.test(msg)) return "TMUX_NO_SERVER";
if (
/timeout|timed out|econnrefused|ehostunreach|enotfound|enetunreach|econnreset|authentication|handshake|keepalive/i.test(
msg,
)
)
return "HOST_UNREACHABLE";
return "TMUX_ERROR";
}
function sendTmuxError(
res: express.Response,
err: unknown,
context: string,
hostId: number,
): void {
const code = classifyTmuxError(err);
const status = code === "TMUX_ERROR" ? 500 : 503;
const error =
code === "TMUX_NOT_INSTALLED"
? "tmux is not installed on this host"
: code === "TMUX_NO_SERVER"
? "No tmux server is running on this host"
: code === "HOST_UNREACHABLE"
? "Could not connect to the host"
: toErrorMessage(err);
sshLogger.error(`tmux ${context} failed for host ${hostId}`, err);
res.status(status).json({ error, code });
}
app.get("/tmux_monitor/:hostId/overview", async (req, res) => {
const userId = (req as unknown as AuthenticatedRequest).userId;
const host = await requireHost(req, res);
if (!host) return;
try {
const result = await withHostConnection(host, async (conn) => {
if (!(await tmuxAvailable(conn))) {
return { available: false, sessions: [] as TmuxSessionOverview[] };
}
const [sessionsOut, windowsOut, panesOut] = await Promise.all([
runTmuxList(conn, listSessionsCmd()),
runTmuxList(conn, listWindowsCmd()),
runTmuxList(conn, listPanesCmd()),
]);
const sessions = parseSessions(sessionsOut);
const windows = parseWindows(windowsOut);
attachPanesToWindows(windows, parsePanes(panesOut));
const tags = await fetchSessionTags(userId, host.id);
const full: TmuxSessionOverview[] = sessions.map((s) => ({
...s,
windows: windows.get(s.name) || [],
tags: tags.get(s.name) || [],
}));
return { available: true, sessions: full };
});
res.json(result);
} catch (err) {
sendTmuxError(res, err, "overview", host.id);
}
});
// Focus a pane: select its window and pane on the server so every attached
// client (including the monitor's embedded terminal) switches to it.
app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || "");
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
try {
await withHostConnection(host, (conn) =>
// A pane id is a valid window target: tmux resolves it to the window
// containing the pane.
execCommand(
conn,
tmuxCommand(
`select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`,
),
),
);
res.json({ ok: true });
} catch (err) {
sendTmuxError(res, err, "focus", host.id);
}
});
// Create a detached session. Starts the tmux server if none is running.
app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const name = String((req.body as { name?: string })?.name || "").trim();
if (!SESSION_NAME_RE.test(name)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(conn, tmuxCommand(`new-session -d -s ${shellEscape(name)}`)),
);
sshLogger.info("tmux session created", {
operation: "tmux_session_create",
hostId: host.id,
sessionName: name,
});
res.json({ ok: true, name });
} catch (err) {
if (/duplicate session/i.test(toErrorMessage(err))) {
return res
.status(409)
.json({ error: "A session with this name already exists" });
}
sendTmuxError(res, err, "create session", host.id);
}
});
// Create a window in an existing session. The session name comes from tmux's
// own listing, so it is only checked for characters that would change the
// target's meaning (":" and "." are window/pane separators in tmux targets);
// "=" prefixes the target for an exact-name match.
app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const sessionName = String(
(req.body as { sessionName?: string })?.sessionName || "",
).trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
tmuxCommand(`new-window -t ${shellEscape(`=${sessionName}`)}`),
),
);
sshLogger.info("tmux window created", {
operation: "tmux_window_create",
hostId: host.id,
sessionName,
});
res.json({ ok: true });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
sendTmuxError(res, err, "create window", host.id);
}
});
// Rename a session. Saved tags follow the session to its new name (for every
// user — the session itself is shared on the host).
app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const body = req.body as { sessionName?: string; newName?: string };
const sessionName = String(body?.sessionName || "").trim();
const newName = String(body?.newName || "").trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
if (!SESSION_NAME_RE.test(newName)) {
return res.status(400).json({ error: "Invalid new session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
tmuxCommand(
`rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`,
),
),
);
await createCurrentTmuxSessionTagRepository().renameSessionForHost(
host.id,
sessionName,
newName,
);
sshLogger.info("tmux session renamed", {
operation: "tmux_session_rename",
hostId: host.id,
sessionName,
newName,
});
await auditTmuxAction(req, host, "tmux_session_rename", sessionName, {
newName,
});
res.json({ ok: true, name: newName });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
if (/duplicate session/i.test(toErrorMessage(err))) {
return res
.status(409)
.json({ error: "A session with this name already exists" });
}
sendTmuxError(res, err, "rename session", host.id);
}
});
// Kill a session and drop its saved tags.
app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const sessionName = String(
(req.body as { sessionName?: string })?.sessionName || "",
).trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`),
),
);
await createCurrentTmuxSessionTagRepository().deleteSessionForHost(
host.id,
sessionName,
);
sshLogger.info("tmux session killed", {
operation: "tmux_session_kill",
hostId: host.id,
sessionName,
});
await auditTmuxAction(req, host, "tmux_session_kill", sessionName);
res.json({ ok: true });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
sendTmuxError(res, err, "kill session", host.id);
}
});
// Kill a window (and every pane in it). Killing the last window of a session
// ends the session — tmux semantics.
app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const body = req.body as { sessionName?: string; windowIndex?: number };
const sessionName = String(body?.sessionName || "").trim();
const windowIndex = Number(body?.windowIndex);
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
if (!Number.isInteger(windowIndex) || windowIndex < 0) {
return res.status(400).json({ error: "Invalid window index" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
tmuxCommand(
`kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`,
),
),
);
sshLogger.info("tmux window killed", {
operation: "tmux_window_kill",
hostId: host.id,
sessionName,
windowIndex,
});
await auditTmuxAction(req, host, "tmux_window_kill", sessionName, {
windowIndex,
});
res.json({ ok: true });
} catch (err) {
if (
/can't find window|no such window|can't find session/i.test(
toErrorMessage(err),
)
) {
return res.status(404).json({ error: "Window not found" });
}
sendTmuxError(res, err, "kill window", host.id);
}
});
// Kill a single pane. Killing the last pane of a window closes the window,
// and the last window of a session ends the session — tmux semantics.
app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || "");
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(conn, tmuxCommand(`kill-pane -t ${shellEscape(paneId)}`)),
);
sshLogger.info("tmux pane killed", {
operation: "tmux_pane_kill",
hostId: host.id,
paneId,
});
await auditTmuxAction(req, host, "tmux_pane_kill", paneId);
res.json({ ok: true });
} catch (err) {
if (/can't find pane|no such pane/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Pane not found" });
}
sendTmuxError(res, err, "kill pane", host.id);
}
});
// Split the window containing a pane. "h" places the new pane to the right,
// "v" below — matching tmux's own -h/-v semantics. The new pane starts in the
// source pane's working directory.
app.post("/tmux_monitor/:hostId/split", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const body = req.body as { paneId?: string; direction?: string };
const paneId = String(body?.paneId || "");
const direction = body?.direction === "v" ? "-v" : "-h";
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
if (body?.direction !== "h" && body?.direction !== "v") {
return res.status(400).json({ error: "Invalid split direction" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
tmuxCommand(
`split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`,
),
),
);
sshLogger.info("tmux pane split", {
operation: "tmux_pane_split",
hostId: host.id,
paneId,
direction: body.direction,
});
res.json({ ok: true });
} catch (err) {
sendTmuxError(res, err, "split", host.id);
}
});
app.get("/tmux_monitor/:hostId/search", async (req, res) => {
const host = await requireHost(req, res);
if (!host) return;
const query = String(req.query.q || "").trim();
if (!query) {
return res.status(400).json({ error: "Missing search query" });
}
try {
const results = await withHostConnection(host, async (conn) => {
const allPanes = await listPanesRaw(conn);
const panes = allPanes.slice(0, MAX_SEARCH_PANES);
// Flips to true whenever a limit was hit, so the UI can tell the user
// the results are partial instead of silently truncating.
let truncated = allPanes.length > MAX_SEARCH_PANES;
const matches: Array<{
paneId: string;
sessionName: string;
windowIndex: number;
line: number;
text: string;
}> = [];
// Bounded concurrency; each search runs capture+grep remotely so only
// matching lines travel back over the wire.
for (let i = 0; i < panes.length; i += SEARCH_CONCURRENCY) {
const batch = panes.slice(i, i + SEARCH_CONCURRENCY);
await Promise.all(
batch.map(async (pane) => {
try {
const output = await execCommand(
conn,
tmuxCommand(
`capture-pane -p -J -t ${shellEscape(pane.id)} -S -${SEARCH_HISTORY_LINES} 2>/dev/null | grep -n -i -F -- ${shellEscape(query)} | head -${MAX_MATCHES_PER_PANE}`,
),
);
const lines = output.split("\n").filter(Boolean);
if (lines.length >= MAX_MATCHES_PER_PANE) truncated = true;
for (const line of lines) {
const sep = line.indexOf(":");
if (sep === -1) continue;
matches.push({
paneId: pane.id,
sessionName: pane.sessionName,
windowIndex: pane.windowIndex,
line: parseInt(line.slice(0, sep), 10) || 0,
text: line.slice(sep + 1).slice(0, 500),
});
}
} catch {
// grep exits non-zero when there are no matches -- not an error
}
}),
);
}
return { matches, truncated };
});
res.json({
query,
matches: results.matches,
truncated: results.truncated,
searchedLines: SEARCH_HISTORY_LINES,
maxPanes: MAX_SEARCH_PANES,
});
} catch (err) {
sendTmuxError(res, err, "search", host.id);
}
});
app.get("/tmux_monitor/:hostId/metrics", async (req, res) => {
const host = await requireHost(req, res);
if (!host) return;
try {
const metrics = await withHostConnection(host, async (conn) => {
const panes = await listPanesRaw(conn);
if (panes.length === 0) return [];
return collectPaneMetrics(conn, panes);
});
res.json({ panes: metrics });
} catch (err) {
sendTmuxError(res, err, "metrics", host.id);
}
});
app.put("/tmux_monitor/:hostId/tags", async (req, res) => {
const userId = (req as unknown as AuthenticatedRequest).userId;
const host = await requireHost(req, res);
if (!host) return;
const { sessionName, tags } = req.body as {
sessionName?: string;
tags?: string[];
};
if (!sessionName || typeof sessionName !== "string") {
return res.status(400).json({ error: "Missing session name" });
}
if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string")) {
return res.status(400).json({ error: "Tags must be an array of strings" });
}
const cleanTags = [
...new Set(tags.map((t) => t.trim().slice(0, 64)).filter(Boolean)),
].slice(0, 20);
try {
await createCurrentTmuxSessionTagRepository().replaceForUserHostSession(
userId,
host.id,
sessionName,
cleanTags,
);
res.json({ sessionName, tags: cleanTags });
} catch (err) {
sshLogger.error(
`Failed to save tmux session tags for host ${host.id}`,
err,
);
res.status(500).json({ error: toErrorMessage(err) });
}
});
const PORT = 30010;
listenOnServicePort({
app,
port: PORT,
logger: sshLogger,
serviceName: "tmux",
});
+1
View File
@@ -60,6 +60,7 @@ const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
"file-manager",
"file-transfer",
"terminal",
"tmux",
]);
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
+10 -6
View File
@@ -302,18 +302,18 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { primeKnownPermissions } =
await import("./utils/known-permissions.js");
await primeKnownPermissions();
// Terminal, docker, host-metrics and file-manager are deliberately
// absent: the ssh-terminal, docker, host-metrics and file-manager plugins
// start their own servers, so disabling any of them stops its WS/HTTP
// server. See plugins/ssh-terminal, plugins/docker, plugins/host-metrics
// and plugins/file-manager.
// Terminal, docker, host-metrics, file-manager and tmux monitoring are
// deliberately absent: the ssh-terminal, docker, host-metrics,
// file-manager and tmux-monitor plugins start their own servers, so
// disabling any of them stops its WS/HTTP server. See
// plugins/ssh-terminal, plugins/docker, plugins/host-metrics,
// plugins/file-manager and plugins/tmux-monitor.
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
// Network Topology, Workspaces, Web Endpoint, Tunnels) is absent for the
// same reason: each one serves its routes under /plugin-api/<id>/ through
// ctx.http on activate, so disabling it answers 503 instead of leaving a
// dead import here. Automations' scheduler and tunnel autostart also
// start from their own activate() rather than here.
await import("./hosts/tmux/index.js");
await import("./hosts/serial.js");
await import("./services/dashboard.js");
await import("./services/homepage.js");
@@ -365,6 +365,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runSshTerminalSettingsMigration } =
await import("./utils/crypto-migration/ssh-terminal-settings-migration.js");
await runSshTerminalSettingsMigration();
const { runTmuxMonitorSettingsMigration } =
await import("./utils/crypto-migration/tmux-monitor-settings-migration.js");
await runTmuxMonitorSettingsMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -1,105 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { TmuxSessionTagRepository } from "../../../database/repositories/tmux-session-tag-repository.js";
describe("TmuxSessionTagRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<TmuxSessionTagRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password');
INSERT INTO tmux_session_tags (user_id, host_id, session_name, tag)
VALUES
('user-1', 1, 'api', 'prod'),
('user-1', 1, 'api', 'critical'),
('user-1', 1, 'worker', 'batch'),
('user-2', 2, 'api', 'other');
`);
return new TmuxSessionTagRepository(context, onWrite);
}
it("groups tags by session for a user and host", async () => {
const repo = await createRepository();
const tags = await repo.listByUserAndHost("user-1", 1);
expect(tags.get("api")).toEqual(["prod", "critical"]);
expect(tags.get("worker")).toEqual(["batch"]);
expect(tags.has("missing")).toBe(false);
});
it("renames and deletes session tags by host/session", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(await repo.renameSessionForHost(1, "api", "api-renamed")).toBe(2);
expect(await repo.renameSessionForHost(1, "missing", "noop")).toBe(0);
const renamed = await repo.listByUserAndHost("user-1", 1);
expect(renamed.get("api-renamed")).toEqual(["prod", "critical"]);
expect(renamed.has("api")).toBe(false);
expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(2);
expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(0);
expect(writeCount).toBe(2);
});
it("replaces tags for one user/host/session", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.replaceForUserHostSession("user-1", 1, "api", [
"blue",
"green",
]),
).toBe(4);
const tags = await repo.listByUserAndHost("user-1", 1);
expect(tags.get("api")).toEqual(["blue", "green"]);
expect(tags.get("worker")).toEqual(["batch"]);
expect(
await repo.replaceForUserHostSession("user-1", 1, "worker", []),
).toBe(1);
expect(
await repo.replaceForUserHostSession("user-1", 1, "missing", []),
).toBe(0);
expect(writeCount).toBe(2);
});
it("deletes all tags for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(repo.deleteByUserId("user-1")).resolves.toBe(3);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.listByUserAndHost("user-1", 1)).toEqual(new Map());
expect(await repo.listByUserAndHost("user-2", 2)).toEqual(
new Map([["api", ["other"]]]),
);
expect(writeCount).toBe(1);
});
});
@@ -0,0 +1,149 @@
/**
* The tmux monitor host-column migration.
*
* An upgrade must be lossless: a host with the monitor on must keep it on
* once the ssh_data column is dropped. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_tmux_monitor: boolean;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => hostRows.filter((row) => row.enable_tmux_monitor),
}),
}));
const { runTmuxMonitorSettingsMigration } =
await import("../../utils/crypto-migration/tmux-monitor-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runTmuxMonitorSettingsMigration", () => {
it("moves a host's enabled switch into plugin settings", async () => {
hostRows.push({ id: 1, enable_tmux_monitor: true });
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableTmuxMonitor")).toBe(true);
expect(settingsRows.every((row) => row.pluginId === "tmux-monitor")).toBe(
true,
);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("leaves a disabled host untouched", async () => {
hostRows.push({ id: 3, enable_tmux_monitor: false });
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(0);
expect(storedValue(3, "enableTmuxMonitor")).toBeUndefined();
});
it("does nothing on a fresh install", async () => {
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_tmux_monitor: true });
await runTmuxMonitorSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runTmuxMonitorSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({ id: 5, enable_tmux_monitor: true });
await pluginSettingsRepository.set(
"tmux-monitor",
"host",
"5",
"enableTmuxMonitor",
"false",
);
const result = await runTmuxMonitorSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableTmuxMonitor")).toBe(false);
});
});
@@ -0,0 +1,84 @@
/**
* Moves the tmux monitor host column into the tmux-monitor plugin's host-scope
* settings, before the column is dropped from ssh_data.
*
* Idempotent: skips a host that already has a tmux-monitor settings row, so it
* is safe to run on every boot. Lossless: reads straight off the still-present
* ssh_data column, so it must run and finish before that column is ever
* dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { getDb } from "../../database/db/index.js";
import { createCurrentPluginSettingsRepository } from "../../database/repositories/factory.js";
export interface TmuxMonitorSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyTmuxMonitorRow {
id: number;
enable_tmux_monitor: number | boolean | null;
}
export async function runTmuxMonitorSettingsMigration(): Promise<TmuxMonitorSettingsMigrationResult> {
const result: TmuxMonitorSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
const drizzleDb = getDb();
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring this column. The plugin's enable switch defaults to
// off, matching the old column's default, so only hosts that turned it
// on need a row.
const rows = await drizzleDb.all<LegacyTmuxMonitorRow>(sql`
SELECT id, enable_tmux_monitor
FROM ssh_data
WHERE enable_tmux_monitor = true
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"tmux-monitor",
"host",
scopeId,
"enableTmuxMonitor",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"tmux-monitor",
"host",
scopeId,
"enableTmuxMonitor",
JSON.stringify(!!row.enable_tmux_monitor),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved tmux monitor settings for ${result.moved} host(s) into plugin settings`,
{ operation: "tmux_monitor_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep the
// monitor off, the plugin's default.
databaseLogger.warn("Tmux monitor settings migration failed", {
operation: "tmux_monitor_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more