Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 a34c852ae1 fix(system-manager): left-align the runtime selector row
The Kubernetes context, namespace, and runtime selects sat on the right of
the header, so they did not line up with the search field. Keep the same
padding, let the context select fill the remaining width, and leave the
runtime and namespace selects at their natural width.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:47:21 +00:00
Cursor Agentand陈大猫 1667b88ce8 fix(system-manager): keep an avoided shell popup inside the work area
When the source window sits partly off-screen, clamping a slot back onto
the display could cover the system panel, and the fallback then used the
raw slot origin. Place the popup in the on-screen part of the slot instead.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:21:02 +00:00
Cursor Agentand陈大猫 a40fcf40b1 fix(system-manager): show a pod's Evicted reason
Pod status.reason is the eviction signal, while the phase stays Failed and
the container often reports Error. Use that reason after a container waiting
state and before the generic phase so the badge stays Evicted.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:15:15 +00:00
Cursor Agentand陈大猫 04883a8ce5 fix(system-manager): keep a runtime choice across probe gaps and show podman ports
A failed nerdctl or kubectl probe was saved as the fallback engine, so the
previous choice never returned. Reconciliation now restores the stored source
when it is detected again, and automatic fallbacks are not written back.
Podman ps port mappings and exposed ports are formatted into the same display
string the container detail strip already shows.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:07:34 +00:00
Cursor Agentand陈大猫 21706be69e fix(system-manager): avoid the visible system panel when others are parked
A hidden parking-host panel can be the first system-manager match and
has a zero-size rect. Scan every match and pass the first panel that
actually has size so the shell popup still sits beside the open panel.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:54:41 +00:00
Cursor Agentand陈大猫 991e400118 fix(system-manager): restore the saved engine when Kubernetes disappears
A host that was on Podman before Kubernetes keeps Podman if kubectl is gone, instead of falling back to Docker and overwriting that choice.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:46:19 +00:00
Cursor Agentand陈大猫 71d03b9b85 fix(system-manager): keep the runtime select on Kubernetes and avoid covering it
Kubernetes uses the same compact source menu as Docker, with context and namespace beside it. Shell popups open in the free part of the window so they no longer sit on top of the pod badges.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:39:06 +00:00
Cursor Agentand陈大猫 655eeeade7 fix(system-manager): show terminating pods and ephemeral containers
A deletion timestamp is Terminating even while the phase is still Running. Ephemeral containers are included in the logs list, and a running one is a shell target.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:02:59 +00:00
Cursor Agentand陈大猫 8e008681e7 fix(system-manager): hide raw nerdctl and unreachable cluster errors
Only offer nerdctl after a short info probe succeeds, or when the failure is a socket permission error that sudo can still recover. Map dead containerd and unreachable kube API errors to short localized messages and keep the CLI text in the log.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 10:55:11 +00:00
Cursor Agentand陈大猫 5cca5beab4 fix(system-manager): reject cmd arguments that contain delayed-expansion marks
cmd.exe with /V:ON expands !VAR! inside double quotes. Contexts such as
prod!USER! are refused for cmd popups, same as percent signs.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 09:29:43 +00:00
Cursor Agentand陈大猫 6ff86aad61 fix(system-manager): mark Windows popups instead of matching kubectl syntax
Filtering kubectl --context logs and describe also dropped commands the
user typed. Windows popups now carry __NC_POPUP__, and ordinary kubectl
lines stay in history.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 09:22:59 +00:00
Cursor Agentand陈大猫 930488d36e fix(system-manager): keep Windows popup commands out of shell history
PowerShell and cmd container popups inspect then run, and kubectl popups
are typed directly. History filtering only matched the POSIX sh -c wrapper,
so those launches were stored as user commands.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 09:08:56 +00:00
Cursor Agentand陈大猫 bfffaa0f81 fix(system-manager): offer kubectl shell only for running containers
Succeeded and failed pods have no running container, and a completed init
container cannot accept exec. Shell targets now come from container
statuses that are running; logs still include init containers.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 09:01:54 +00:00
Cursor Agentand陈大猫 22d91a6881 refactor(system-manager): move runtime selection into an application hook
Persisted source, context, and namespace state plus the kubectl discovery
effects now live in useContainerRuntimeSelection. DockerManagerTab only
renders the switcher and panels.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 08:54:11 +00:00
Cursor Agentand陈大猫 584324ff13 fix(system-manager): reject cmd kubectl arguments that contain percent signs
Interactive cmd.exe expands %VAR% inside double quotes, so a context such
as prod-%USERNAME% would be rewritten before kubectl sees it. Cmd popups
now refuse that argument. PowerShell quoting still preserves the percent.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 08:54:11 +00:00
Cursor Agentand陈大猫 50f9295393 fix(system-manager): remove a selected image tag instead of the shared id
Podman rows that share one image id were all deleted with rmi <id>, which
either fails while other tags remain or drops every name. Tagged rows now
run rmi repository:tag; dangling images still use the id.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 08:44:16 +00:00
Cursor Agentand陈大猫 db1a73071d fix(system-manager): sum Podman interface stats and quote kubectl for the executor
Podman {{json .}} stats keep per-interface counters on Network, so the
details panel now sums RxBytes and TxBytes. Background kubectl runs in
powershell.exe on local Windows even when the terminal is cmd, so those
commands are quoted for that executor instead of the interactive shell.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 08:36:27 +00:00
Cursor Agentand陈大猫 c9fa5eaa0b fix(system-manager): drop renamed runtime launch commands from shell history
Container popups now probe with _nc_rt_err and also cover podman, nerdctl,
and kubectl. History sanitizers still required _nc_docker_err, so those
launches stayed in persisted history. Confirm-dialog resets also follow the
selected runtime.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 08:25:57 +00:00
Cursor Agentand陈大猫 b82f4907ed fix(system-manager): quote kubectl list and delete for the session shell
Namespace, pod, and delete commands follow PowerShell or cmd quoting when
the session shell is not POSIX, so apostrophes in a context name survive
local PowerShell.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 08:11:39 +00:00
Cursor Agentand陈大猫 9481ed3579 fix(system-manager): list every Podman image tag
Expand each name in a Podman image's Names array into its own row so
extra tags stay visible and searchable.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 08:04:13 +00:00
Cursor Agentand陈大猫 085c6427aa fix(system-manager): parse Podman stats arrays
Accept Podman's stats JSON array and its id/cpu_percent fields, and keep
Docker's one-stat-per-line output.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:57:06 +00:00
Cursor Agentand陈大猫 69af63da10 fix(system-manager): parse Podman image lists emitted as JSON arrays
Podman applies {{json .}} to the whole image slice. Accept that array,
including Names and byte sizes, and keep Docker's JSON lines.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:49:42 +00:00
Cursor Agentand陈大猫 c6f7b7468f fix(system-manager): parse kubectl JSON after banners and scope inspect cache
Skip bracketed banners before kubectl JSON, and include the container
runtime in image and container inspect cache keys.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:42:11 +00:00
Cursor Agentand陈大猫 d711499458 fix(system-manager): cover Windows container popups and stale namespaces
Run container shell and logs through the host shell's inspect wrapper on
PowerShell and cmd, and reset the namespace when a remembered Kubernetes
context is no longer present.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:31:35 +00:00
Cursor Agentand陈大猫 f811171ea5 fix(system-manager): address container runtime review findings
Move the runtime adapter into the domain layer, keep a context namespace
when namespace listing is forbidden, and emit PowerShell or cmd startup
commands for Kubernetes shell, logs, and describe.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:23:24 +00:00
Cursor Agentand陈大猫 e6ff7c281f feat(system-manager): add container runtime switcher and kubectl pods
Keep the existing Docker tab and select docker, podman, nerdctl, or
Kubernetes from runtimes detected on the host. Kubernetes replaces the
switcher with context and namespace selectors in the same panel.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:13:38 +00:00
48 changed files with 3703 additions and 257 deletions

No files matched your search

+2 -2
View File
@@ -860,8 +860,8 @@ Highlight the focused split pane:
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Container list refresh',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh container and pod lists.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
'settings.terminal.serverStats.show': 'Show Server Stats',
+36 -1
View File
@@ -26,6 +26,11 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Failed to load containers',
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.loadKubernetes': 'Failed to load Kubernetes resources',
'systemManager.errors.loadPods': 'Failed to load pods',
'systemManager.errors.nerdctlUnreachable': "nerdctl can't reach containerd",
'systemManager.errors.clusterUnreachable': "Can't reach the cluster for context {{context}}",
'systemManager.errors.clusterUnreachableGeneric': "Can't reach the cluster",
'systemManager.errors.loadOverview': 'Failed to load system overview',
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
'systemManager.errors.loadPorts': 'Failed to load listening ports',
@@ -132,7 +137,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
'systemManager.tmux.noWindows': 'No windows',
'systemManager.tmux.unavailable': 'tmux is not available on this host',
'systemManager.docker.unavailable': 'Docker is not available on this host',
'systemManager.docker.unavailable': 'No container runtime (Docker, Podman, nerdctl, or kubectl) was detected on this host',
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
'systemManager.tmux.lastCommand': 'last command: {{command}}',
'systemManager.tmux.noPanes': 'No panes',
@@ -194,6 +199,36 @@ export const enSystemManagerMessages: Messages = {
'systemManager.docker.imagesMeta': '{{count}} image(s)',
'systemManager.docker.start': 'Start',
'systemManager.docker.stop': 'Stop',
'systemManager.runtime.switch': 'Container runtime',
'systemManager.runtime.docker': 'Docker',
'systemManager.runtime.podman': 'Podman',
'systemManager.runtime.nerdctl': 'nerdctl',
'systemManager.runtime.kubernetes': 'Kubernetes',
'systemManager.k8s.context': 'Context',
'systemManager.k8s.namespace': 'Namespace',
'systemManager.k8s.empty': 'No pods found',
'systemManager.k8s.search': 'Search pods…',
'systemManager.k8s.meta': '{{count}} pod(s)',
'systemManager.k8s.describe': 'Describe',
'systemManager.k8s.delete': 'Delete pod',
'systemManager.k8s.confirmDelete': 'Delete pod "{{name}}"?',
'systemManager.k8s.restarts': '{{count}} restarts',
'systemManager.k8s.chooseContainer': 'Choose a container',
'systemManager.k8s.noContext': 'No Kubernetes context on this host',
'systemManager.k8s.status.Running': 'Running',
'systemManager.k8s.status.Pending': 'Pending',
'systemManager.k8s.status.Completed': 'Completed',
'systemManager.k8s.status.Succeeded': 'Succeeded',
'systemManager.k8s.status.Failed': 'Failed',
'systemManager.k8s.status.Unknown': 'Unknown',
'systemManager.k8s.status.CrashLoopBackOff': 'CrashLoopBackOff',
'systemManager.k8s.status.ContainerCreating': 'ContainerCreating',
'systemManager.k8s.status.Terminating': 'Terminating',
'systemManager.k8s.status.ImagePullBackOff': 'ImagePullBackOff',
'systemManager.k8s.status.ErrImagePull': 'ErrImagePull',
'systemManager.k8s.status.Error': 'Error',
'systemManager.k8s.status.Evicted': 'Evicted',
'systemManager.k8s.status.OOMKilled': 'OOMKilled',
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
'systemManager.ports.loading': 'Loading listening ports…',
+2 -2
View File
@@ -775,8 +775,8 @@ Resalta el panel dividido enfocado:
'settings.terminal.systemManager.processRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de procesos en el panel lateral del administrador del sistema.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'Actualización de sesiones tmux',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de sesiones tmux.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores Docker',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de contenedores Docker.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar las listas de contenedores y pods.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Actualización de estadísticas Docker',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Con qué frecuencia actualizar las estadísticas de CPU/memoria/red de los contenedores Docker.',
'settings.terminal.serverStats.show': 'Mostrar estadísticas del servidor',
+19 -1
View File
@@ -26,6 +26,8 @@ export const esSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
'systemManager.errors.loadKubernetes': 'No se pudieron cargar los recursos de Kubernetes',
'systemManager.errors.loadPods': 'No se pudieron cargar los pods',
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
@@ -132,7 +134,7 @@ export const esSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
'systemManager.tmux.noWindows': 'No hay ventanas',
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
'systemManager.docker.unavailable': 'No se detectó ningún runtime de contenedores (Docker, Podman, nerdctl o kubectl) en este host',
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
'systemManager.tmux.noPanes': 'No hay paneles',
@@ -194,6 +196,22 @@ export const esSystemManagerMessages: Messages = {
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
'systemManager.docker.start': 'Iniciar',
'systemManager.docker.stop': 'Detener',
'systemManager.runtime.switch': 'Runtime de contenedores',
'systemManager.runtime.docker': 'Docker',
'systemManager.runtime.podman': 'Podman',
'systemManager.runtime.nerdctl': 'nerdctl',
'systemManager.runtime.kubernetes': 'Kubernetes',
'systemManager.k8s.context': 'Contexto',
'systemManager.k8s.namespace': 'Espacio de nombres',
'systemManager.k8s.empty': 'No se encontraron pods',
'systemManager.k8s.search': 'Buscar pods…',
'systemManager.k8s.meta': '{{count}} pod(s)',
'systemManager.k8s.describe': 'Describir',
'systemManager.k8s.delete': 'Eliminar pod',
'systemManager.k8s.confirmDelete': '¿Eliminar el pod "{{name}}"?',
'systemManager.k8s.restarts': '{{count}} reinicios',
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
'systemManager.ports.loading': 'Cargando puertos en escucha…',
+2 -2
View File
@@ -813,8 +813,8 @@ export const ruCoreMessages: Messages = {
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять списки контейнеров и подов.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
+19 -1
View File
@@ -26,6 +26,8 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.loadKubernetes': 'Не удалось загрузить ресурсы Kubernetes',
'systemManager.errors.loadPods': 'Не удалось загрузить поды',
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
@@ -132,7 +134,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
'systemManager.tmux.noWindows': 'Нет окон',
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
'systemManager.docker.unavailable': 'На этом хосте не обнаружена среда контейнеров (Docker, Podman, nerdctl или kubectl)',
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
'systemManager.tmux.noPanes': 'Нет панелей',
@@ -194,6 +196,22 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.docker.imagesMeta': '{{count}} образов',
'systemManager.docker.start': 'Запустить',
'systemManager.docker.stop': 'Остановить',
'systemManager.runtime.switch': 'Среда выполнения контейнеров',
'systemManager.runtime.docker': 'Docker',
'systemManager.runtime.podman': 'Podman',
'systemManager.runtime.nerdctl': 'nerdctl',
'systemManager.runtime.kubernetes': 'Kubernetes',
'systemManager.k8s.context': 'Контекст',
'systemManager.k8s.namespace': 'Пространство имён',
'systemManager.k8s.empty': 'Поды не найдены',
'systemManager.k8s.search': 'Поиск подов…',
'systemManager.k8s.meta': '{{count}} под(ов)',
'systemManager.k8s.describe': 'Описание',
'systemManager.k8s.delete': 'Удалить под',
'systemManager.k8s.confirmDelete': 'Удалить под «{{name}}»?',
'systemManager.k8s.restarts': '{{count}} перезапусков',
'systemManager.k8s.chooseContainer': 'Выберите контейнер',
'systemManager.k8s.noContext': 'На этом хосте нет контекста Kubernetes',
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
'systemManager.ports.loading': 'Загрузка слушающих портов…',
@@ -26,6 +26,11 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '加载容器列表失败',
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.loadKubernetes': '无法加载 Kubernetes 资源',
'systemManager.errors.loadPods': '无法加载 Pod',
'systemManager.errors.nerdctlUnreachable': 'nerdctl 无法连接 containerd',
'systemManager.errors.clusterUnreachable': '无法连接到上下文 {{context}} 的集群',
'systemManager.errors.clusterUnreachableGeneric': '无法连接到集群',
'systemManager.errors.loadOverview': '加载系统概览失败',
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
'systemManager.errors.loadPorts': '加载监听端口失败',
@@ -132,7 +137,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
'systemManager.tmux.noWindows': '没有窗口',
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
'systemManager.docker.unavailable': '此主机未检测到 Docker',
'systemManager.docker.unavailable': '此主机未检测到容器运行时(Docker、Podman、nerdctl 或 kubectl)',
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
'systemManager.tmux.noPanes': '没有面板',
@@ -194,6 +199,36 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
'systemManager.docker.start': '启动',
'systemManager.docker.stop': '停止',
'systemManager.runtime.switch': '容器运行时',
'systemManager.runtime.docker': 'Docker',
'systemManager.runtime.podman': 'Podman',
'systemManager.runtime.nerdctl': 'nerdctl',
'systemManager.runtime.kubernetes': 'Kubernetes',
'systemManager.k8s.context': '上下文',
'systemManager.k8s.namespace': '命名空间',
'systemManager.k8s.empty': '未找到 Pod',
'systemManager.k8s.search': '搜索 Pod…',
'systemManager.k8s.meta': '{{count}} 个 Pod',
'systemManager.k8s.describe': '描述',
'systemManager.k8s.delete': '删除 Pod',
'systemManager.k8s.confirmDelete': '确定删除 Pod「{{name}}」?',
'systemManager.k8s.restarts': '重启 {{count}} 次',
'systemManager.k8s.chooseContainer': '选择容器',
'systemManager.k8s.noContext': '此主机没有 Kubernetes 上下文',
'systemManager.k8s.status.Running': '运行中',
'systemManager.k8s.status.Pending': '等待中',
'systemManager.k8s.status.Completed': '已完成',
'systemManager.k8s.status.Succeeded': '已成功',
'systemManager.k8s.status.Failed': '失败',
'systemManager.k8s.status.Unknown': '未知',
'systemManager.k8s.status.CrashLoopBackOff': '崩溃循环',
'systemManager.k8s.status.ContainerCreating': '创建中',
'systemManager.k8s.status.Terminating': '终止中',
'systemManager.k8s.status.ImagePullBackOff': '镜像拉取退避',
'systemManager.k8s.status.ErrImagePull': '拉取镜像失败',
'systemManager.k8s.status.Error': '错误',
'systemManager.k8s.status.Evicted': '已驱逐',
'systemManager.k8s.status.OOMKilled': '内存不足',
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
'systemManager.ports.loading': '正在加载监听端口…',
+2 -2
View File
@@ -483,8 +483,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表刷新间隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器与 Pod 列表的刷新频率。',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
@@ -26,6 +26,11 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '載入容器列表失敗',
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
'systemManager.errors.loadKubernetes': '無法載入 Kubernetes 資源',
'systemManager.errors.loadPods': '無法載入 Pod',
'systemManager.errors.nerdctlUnreachable': 'nerdctl 無法連線到 containerd',
'systemManager.errors.clusterUnreachable': '無法連到內容 {{context}} 的叢集',
'systemManager.errors.clusterUnreachableGeneric': '無法連到叢集',
'systemManager.errors.loadOverview': '載入系統概覽失敗',
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
@@ -132,7 +137,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
'systemManager.tmux.noWindows': '沒有視窗',
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
'systemManager.docker.unavailable': '此主機未偵測到容器執行環境(Docker、Podman、nerdctl 或 kubectl)',
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
'systemManager.tmux.noPanes': '沒有窗格',
@@ -194,6 +199,36 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.docker.imagesMeta': '{{count}} 個映像',
'systemManager.docker.start': '啟動',
'systemManager.docker.stop': '停止',
'systemManager.runtime.switch': '容器執行環境',
'systemManager.runtime.docker': 'Docker',
'systemManager.runtime.podman': 'Podman',
'systemManager.runtime.nerdctl': 'nerdctl',
'systemManager.runtime.kubernetes': 'Kubernetes',
'systemManager.k8s.context': '內容',
'systemManager.k8s.namespace': '命名空間',
'systemManager.k8s.empty': '沒有 Pod',
'systemManager.k8s.search': '搜尋 Pod…',
'systemManager.k8s.meta': '{{count}} 個 Pod',
'systemManager.k8s.describe': '描述',
'systemManager.k8s.delete': '刪除 Pod',
'systemManager.k8s.confirmDelete': '確定刪除 Pod「{{name}}」?',
'systemManager.k8s.restarts': '重新啟動 {{count}} 次',
'systemManager.k8s.chooseContainer': '選擇容器',
'systemManager.k8s.noContext': '此主機沒有 Kubernetes 內容',
'systemManager.k8s.status.Running': '執行中',
'systemManager.k8s.status.Pending': '等待中',
'systemManager.k8s.status.Completed': '已完成',
'systemManager.k8s.status.Succeeded': '已成功',
'systemManager.k8s.status.Failed': '失敗',
'systemManager.k8s.status.Unknown': '未知',
'systemManager.k8s.status.CrashLoopBackOff': '當機重試',
'systemManager.k8s.status.ContainerCreating': '建立中',
'systemManager.k8s.status.Terminating': '終止中',
'systemManager.k8s.status.ImagePullBackOff': '映像拉取退避',
'systemManager.k8s.status.ErrImagePull': '拉取映像失敗',
'systemManager.k8s.status.Error': '錯誤',
'systemManager.k8s.status.Evicted': '已驅逐',
'systemManager.k8s.status.OOMKilled': '記憶體不足',
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
'systemManager.ports.loading': '正在載入監聽連接埠…',
+2 -2
View File
@@ -483,8 +483,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表重新整理間隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器與 Pod 列表的重新整理頻率。',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
@@ -0,0 +1,39 @@
import type { ContainerRuntimeId, ContainerSourceId } from '../../../domain/systemManager/containerRuntime';
import { isContainerSource } from '../../../domain/systemManager/containerRuntime';
import { STORAGE_KEY_CONTAINER_RUNTIME } from '../../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../../infrastructure/persistence/localStorageAdapter';
export interface ContainerRuntimePreference {
source: ContainerSourceId;
engine?: ContainerRuntimeId;
context?: string;
namespace?: string;
}
type PreferenceStore = Record<string, ContainerRuntimePreference>;
const CONTAINER_ENGINES = new Set<ContainerRuntimeId>(['docker', 'podman', 'nerdctl']);
function readStore(): PreferenceStore {
const value = localStorageAdapter.read<PreferenceStore>(STORAGE_KEY_CONTAINER_RUNTIME);
return value && typeof value === 'object' ? value : {};
}
export function readContainerRuntimePreference(hostId: string): ContainerRuntimePreference | null {
if (!hostId) return null;
const entry = readStore()[hostId];
if (!entry || !isContainerSource(entry.source)) return null;
return {
source: entry.source,
engine: entry.engine && CONTAINER_ENGINES.has(entry.engine) ? entry.engine : undefined,
context: typeof entry.context === 'string' ? entry.context : undefined,
namespace: typeof entry.namespace === 'string' ? entry.namespace : undefined,
};
}
export function writeContainerRuntimePreference(hostId: string, entry: ContainerRuntimePreference): void {
if (!hostId || !isContainerSource(entry.source)) return;
const store = readStore();
store[hostId] = entry;
localStorageAdapter.write(STORAGE_KEY_CONTAINER_RUNTIME, store);
}
@@ -0,0 +1,225 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { useSystemManagerBackend } from '../useSystemManagerBackend';
import { useStableTranslate } from '../useSystemManager';
import type {
ContainerRuntimeId,
ContainerSourceId,
KubeContextInfo,
} from '../../../domain/systemManager/containerRuntime';
import {
describeRuntimeFailure,
firstContainerRuntime,
isAutomaticRuntimeFallback,
isKubectlForbiddenError,
reconcileContainerSource,
sourcesFromCapabilities,
} from '../../../domain/systemManager/containerRuntime';
import type { SessionCapabilities } from '../../../domain/systemManager/types';
import {
readContainerRuntimePreference,
writeContainerRuntimePreference,
} from './containerRuntimePreference';
type Backend = ReturnType<typeof useSystemManagerBackend>;
interface UseContainerRuntimeSelectionOptions {
sessionId: string;
hostKey: string;
capabilities?: SessionCapabilities;
backend: Backend;
}
export function useContainerRuntimeSelection({
sessionId,
hostKey,
capabilities,
backend,
}: UseContainerRuntimeSelectionOptions) {
const stableT = useStableTranslate();
const detected = sourcesFromCapabilities(capabilities);
const detectedKey = detected.join('|');
const [source, setSource] = useState<ContainerSourceId | null>(() => {
const remembered = readContainerRuntimePreference(hostKey);
return reconcileContainerSource(detected, null, remembered);
});
const [contextName, setContextName] = useState<string | null>(() => (
readContainerRuntimePreference(hostKey)?.context ?? null
));
const [namespaceName, setNamespaceName] = useState<string | null>(() => (
readContainerRuntimePreference(hostKey)?.namespace ?? null
));
const [contexts, setContexts] = useState<KubeContextInfo[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [scopeError, setScopeError] = useState<string | null>(null);
const [scopeNonce, setScopeNonce] = useState(0);
const skipPersist = useRef(true);
const namespaceNameRef = useRef(namespaceName);
namespaceNameRef.current = namespaceName;
useEffect(() => {
skipPersist.current = true;
const remembered = readContainerRuntimePreference(hostKey);
setSource(reconcileContainerSource(detected, null, remembered));
setContextName(remembered?.context ?? null);
setNamespaceName(remembered?.namespace ?? null);
setContexts([]);
setNamespaces([]);
setScopeError(null);
// Detection is reconciled by the next effect so a host switch does not
// keep the previous host's in-memory selection.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostKey]);
useEffect(() => {
const available = sourcesFromCapabilities(capabilities);
setSource((current) => reconcileContainerSource(
available,
current,
readContainerRuntimePreference(hostKey),
));
}, [capabilities, detectedKey, hostKey]);
useEffect(() => {
if (skipPersist.current) {
skipPersist.current = false;
return;
}
if (!source) return;
const remembered = readContainerRuntimePreference(hostKey);
const available = sourcesFromCapabilities(capabilities);
if (isAutomaticRuntimeFallback(remembered, source, available)) return;
const engine = source === 'kubernetes'
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
: source;
writeContainerRuntimePreference(hostKey, {
source,
engine,
context: contextName || undefined,
namespace: namespaceName || undefined,
});
}, [capabilities, contextName, hostKey, namespaceName, source]);
useEffect(() => {
if (source !== 'kubernetes') return undefined;
let cancelled = false;
void backend.kubectl({ sessionId, op: 'contexts' }).then((result) => {
if (cancelled) return;
if (!result.success || !result.contexts) {
setContexts([]);
setScopeError(describeRuntimeFailure(
result,
stableT,
stableT('systemManager.errors.loadKubernetes'),
contextName,
));
return;
}
setScopeError(null);
const available = result.contexts;
const kept = contextName && available.some((item) => item.name === contextName)
? contextName
: null;
const next = kept
?? available.find((item) => item.current)?.name
?? available[0]?.name
?? null;
setContexts(available);
setContextName(next);
if (!kept) {
const configured = available.find((item) => item.name === next)?.namespace || 'default';
setNamespaceName(next ? configured : null);
setNamespaces([]);
}
});
return () => {
cancelled = true;
};
}, [backend, contextName, sessionId, source, scopeNonce, stableT]);
useEffect(() => {
if (source !== 'kubernetes' || !contextName) return undefined;
let cancelled = false;
void backend.kubectl({
sessionId,
op: 'namespaces',
context: contextName,
}).then((result) => {
if (cancelled) return;
if (!result.success || !result.namespaces) {
const message = result.error || '';
if (isKubectlForbiddenError(message)) {
const configured = contexts.find((item) => item.name === contextName)?.namespace || 'default';
const fallback = namespaceNameRef.current || configured;
setScopeError(null);
setNamespaceName((current) => current || fallback);
setNamespaces([fallback]);
return;
}
setScopeError(describeRuntimeFailure(
result,
stableT,
stableT('systemManager.errors.loadKubernetes'),
contextName,
));
return;
}
setScopeError(null);
setNamespaces(result.namespaces);
setNamespaceName((current) => {
if (current && result.namespaces?.includes(current)) return current;
const configured = contexts.find((item) => item.name === contextName)?.namespace;
if (configured && result.namespaces?.includes(configured)) return configured;
if (result.namespaces?.includes('default')) return 'default';
return result.namespaces?.[0] ?? null;
});
});
return () => {
cancelled = true;
};
}, [backend, contextName, contexts, sessionId, source, scopeNonce, stableT]);
const selectSource = useCallback((value: string) => {
if (value !== 'docker' && value !== 'podman' && value !== 'nerdctl' && value !== 'kubernetes') return;
setSource(value);
const remembered = readContainerRuntimePreference(hostKey);
const available = sourcesFromCapabilities(capabilities);
const engine = value === 'kubernetes'
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
: value;
writeContainerRuntimePreference(hostKey, {
source: value,
engine,
context: contextName || undefined,
namespace: namespaceName || undefined,
});
}, [capabilities, contextName, hostKey, namespaceName]);
const selectContext = useCallback((value: string) => {
setContextName(value);
const configured = contexts.find((item) => item.name === value)?.namespace ?? null;
setNamespaceName(configured);
}, [contexts]);
const reloadScopes = useCallback(() => {
setScopeNonce((value) => value + 1);
}, []);
const runtime: ContainerRuntimeId = source && source !== 'kubernetes' ? source : 'docker';
const showRuntimeSwitcher = detected.length > 1;
return {
source,
selectSource,
contextName,
selectContext,
namespaceName,
setNamespaceName,
contexts,
namespaces,
scopeError,
reloadScopes,
runtime,
detected,
showRuntimeSwitcher,
};
}
+42 -8
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from 'react';
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
import type {
DockerContainerAction,
DockerImageManageAction,
@@ -94,23 +95,27 @@ export function useSystemManagerBackend() {
return bridge.tmuxAction(options);
}, []);
const listDockerContainers = useCallback(async (sessionId: string) => {
const listDockerContainers = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
const bridge = netcattyBridge.get();
if (!bridge?.listDockerContainers) {
return { success: false as const, error: 'listDockerContainers unavailable' };
}
return bridge.listDockerContainers(sessionId);
return bridge.listDockerContainers(sessionId, runtime);
}, []);
const listDockerImages = useCallback(async (sessionId: string) => {
const listDockerImages = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
const bridge = netcattyBridge.get();
if (!bridge?.listDockerImages) {
return { success: false as const, error: 'listDockerImages unavailable' };
}
return bridge.listDockerImages(sessionId);
return bridge.listDockerImages(sessionId, runtime);
}, []);
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
const getDockerStats = useCallback(async (options: {
sessionId: string;
ids?: string[];
runtime?: ContainerRuntimeId;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.getDockerStats) {
return { success: false as const, error: 'getDockerStats unavailable' };
@@ -155,7 +160,11 @@ export function useSystemManagerBackend() {
return bridge.systemServiceAction(options);
}, []);
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
const dockerInspect = useCallback(async (options: {
sessionId: string;
containerId: string;
runtime?: ContainerRuntimeId;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerInspect) {
return { success: false as const, error: 'dockerInspect unavailable' };
@@ -163,7 +172,11 @@ export function useSystemManagerBackend() {
return bridge.dockerInspect(options);
}, []);
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
const dockerImageInspect = useCallback(async (options: {
sessionId: string;
imageId: string;
runtime?: ContainerRuntimeId;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerImageInspect) {
return { success: false as const, error: 'dockerImageInspect unavailable' };
@@ -176,6 +189,7 @@ export function useSystemManagerBackend() {
containerId: string;
action: DockerContainerAction;
newName?: string;
runtime?: ContainerRuntimeId;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerAction) {
@@ -184,7 +198,10 @@ export function useSystemManagerBackend() {
return bridge.dockerAction(options);
}, []);
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
const dockerImageAction = useCallback(async (options: {
sessionId: string;
runtime?: ContainerRuntimeId;
} & DockerImageManageAction) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerImageAction) {
return { success: false as const, error: 'dockerImageAction unavailable' };
@@ -192,6 +209,21 @@ export function useSystemManagerBackend() {
return bridge.dockerImageAction(options);
}, []);
const kubectl = useCallback(async (options: {
sessionId: string;
op: 'contexts' | 'namespaces' | 'pods' | 'delete';
context?: string;
namespace?: string;
pod?: string;
shell?: string;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.kubectl) {
return { success: false as const, error: 'kubectl unavailable' };
}
return bridge.kubectl(options);
}, []);
const openTerminalPopup = useCallback(async (
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
) => {
@@ -223,6 +255,7 @@ export function useSystemManagerBackend() {
dockerImageInspect,
dockerAction,
dockerImageAction,
kubectl,
openTerminalPopup,
}), [
probeSystemCapabilities,
@@ -245,6 +278,7 @@ export function useSystemManagerBackend() {
dockerImageInspect,
dockerAction,
dockerImageAction,
kubectl,
openTerminalPopup,
]);
}
@@ -7,7 +7,8 @@ import type { TerminalSession } from '../../types';
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
import { buildContainerExecShellCommand, buildContainerLogsCommand, describeRuntimeFailure } from '../../domain/systemManager/containerRuntime';
import { DockerContainerDetail } from './DockerContainerDetail';
import { DockerImageIcon } from './DockerImageIcon';
import { useStableListOrder, mergePollListByKey } from './listStable';
@@ -59,6 +60,7 @@ async function buildContainerPopupIcon(image: string): Promise<TerminalPopupIcon
interface DockerContainersPanelProps {
sessionId: string;
parentSession: TerminalSession;
runtime?: ContainerRuntimeId;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
@@ -157,6 +159,7 @@ const DockerContainerRow = memo(function DockerContainerRow({
export const DockerContainersPanel = memo(function DockerContainersPanel({
sessionId,
parentSession,
runtime = 'docker',
isVisible,
warmupEnabled = false,
backend,
@@ -179,15 +182,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
setConfirmAction(null);
setPendingAction(null);
setSelectedId(null);
}, [sessionId]);
}, [runtime, sessionId]);
const containersFetcher = useCallback(async () => {
const result = await backend.listDockerContainers(sessionId);
const result = await backend.listDockerContainers(sessionId, runtime);
if (!result.success || !result.containers) {
throw new Error(result.error || stableT('systemManager.errors.loadDocker'));
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDocker')));
}
return result.containers;
}, [backend, sessionId, stableT]);
}, [backend, runtime, sessionId, stableT]);
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
@@ -195,7 +198,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
listIntervalMs,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
{ poll: isVisible, resetKey: sessionId },
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
);
const matched = useMemo<DockerContainerInfo[]>(() => {
@@ -240,12 +243,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
);
const statsFetcher = useCallback(async () => {
if (statContainerIds.length === 0) return [];
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds, runtime });
if (!result.success || !result.stats) {
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerStats')));
}
return result.stats;
}, [backend, sessionId, stableT, statContainerIds]);
}, [backend, runtime, sessionId, stableT, statContainerIds]);
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
@@ -266,18 +269,19 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
}, [stats]);
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
`${sessionId}:${container.id}`
), [sessionId]);
`${sessionId}:${runtime}:${container.id}`
), [runtime, sessionId]);
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
const result = await backend.dockerInspect({
sessionId,
containerId: container.id.slice(0, 12),
runtime,
});
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
}
return result.inspect ?? null;
}, [backend, sessionId, stableT]);
}, [backend, runtime, sessionId, stableT]);
const {
records: inspectByContainerId,
loadRecord: loadContainerInspect,
@@ -305,9 +309,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
}
setPendingAction({ id: containerId, action });
try {
const result = await backend.dockerAction({ sessionId, containerId, action, newName });
const result = await backend.dockerAction({ sessionId, containerId, action, newName, runtime });
if (!result.success) {
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
showSystemManagerError(
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
t('common.error'),
);
return;
}
const affectedContainer = (containers ?? []).find((container) => (
@@ -332,6 +339,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
invalidateContainerInspectMatching,
refresh,
refreshContainerInspect,
runtime,
sessionId,
t,
]);
@@ -360,7 +368,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
backend,
parentSession,
`docker: ${container.name || id}`,
buildDockerExecShellCommand(id),
buildContainerExecShellCommand(runtime, id, parentSession.shellType),
{ icon: await buildContainerPopupIcon(container.image) },
);
if (!result.success) {
@@ -372,7 +380,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
});
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
}
}, [backend, parentSession, sessionId, t]);
}, [backend, parentSession, runtime, sessionId, t]);
const openLogs = useCallback(async (container: DockerContainerInfo) => {
const id = container.id.slice(0, 12);
@@ -387,7 +395,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
backend,
parentSession,
`logs: ${container.name || id}`,
buildDockerLogsCommand(id),
buildContainerLogsCommand(runtime, id, parentSession.shellType),
{ icon: await buildContainerPopupIcon(container.image) },
);
if (!result.success) {
@@ -399,7 +407,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
});
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
}
}, [backend, parentSession, sessionId, t]);
}, [backend, parentSession, runtime, sessionId, t]);
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers">
+38 -16
View File
@@ -2,6 +2,7 @@ import { Layers, Loader2, Tag, Trash2 } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { describeRuntimeFailure, type ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
import { dockerImageRowKey, type DockerImageInfo } from '../../domain/systemManager/types';
import { dockerImageInfoEqual } from '../../domain/systemManager/pollEquals';
import { DockerImageIcon } from './DockerImageIcon';
@@ -35,6 +36,7 @@ type PendingImageConfirm =
interface DockerImagesPanelProps {
sessionId: string;
runtime?: ContainerRuntimeId;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
@@ -89,6 +91,7 @@ const DockerImageRow = memo(function DockerImageRow({
export const DockerImagesPanel = memo(function DockerImagesPanel({
sessionId,
runtime = 'docker',
isVisible,
warmupEnabled = false,
backend,
@@ -111,15 +114,15 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
// Clear busy so a hung/in-flight action from the previous session cannot
// leave the new session's confirm dialog permanently disabled.
setActionBusy(false);
}, [sessionId]);
}, [runtime, sessionId]);
const imagesFetcher = useCallback(async () => {
const result = await backend.listDockerImages(sessionId);
const result = await backend.listDockerImages(sessionId, runtime);
if (!result.success || !result.images) {
throw new Error(result.error || stableT('systemManager.errors.loadDockerImages'));
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerImages')));
}
return result.images;
}, [backend, sessionId, stableT]);
}, [backend, runtime, sessionId, stableT]);
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
@@ -127,7 +130,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
listIntervalMs,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
{ poll: isVisible, resetKey: sessionId },
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
);
const filtered = useMemo(() => {
@@ -154,18 +157,19 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
`${sessionId}:${dockerImageRowKey(image)}`
), [sessionId]);
`${sessionId}:${runtime}:${dockerImageRowKey(image)}`
), [runtime, sessionId]);
const fetchImageInspect = useCallback(async (image: DockerImageInfo) => {
const result = await backend.dockerImageInspect({
sessionId,
imageId: image.id.slice(0, 12),
runtime,
});
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
}
return result.inspect ?? null;
}, [backend, sessionId, stableT]);
}, [backend, runtime, sessionId, stableT]);
const {
records: inspectByImageKey,
loadRecord: loadImageInspect,
@@ -184,15 +188,26 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
const actionGeneration = actionGenerationRef.current;
setActionBusy(true);
try {
const tagged = Boolean(
image.repository
&& image.tag
&& image.repository !== '<none>'
&& image.tag !== '<none>',
);
const result = await backend.dockerImageAction({
sessionId,
runtime,
action: 'rm',
imageId: image.id.slice(0, 12),
force: image.tag === '<none>',
force: !tagged,
...(tagged ? { repository: image.repository, tag: image.tag } : {}),
});
if (actionGenerationRef.current !== actionGeneration) return;
if (!result.success) {
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
showSystemManagerError(
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
t('common.error'),
);
return;
}
if (selectedId === dockerImageRowKey(image)) {
@@ -205,7 +220,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
setActionBusy(false);
}
}
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, runtime, selectedId, sessionId, t]);
const handleRemove = useCallback((image: DockerImageInfo) => {
const label = image.name || image.id.slice(0, 12);
@@ -216,10 +231,13 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
const actionGeneration = actionGenerationRef.current;
setActionBusy(true);
try {
const result = await backend.dockerImageAction({ sessionId, action: 'prune', all });
const result = await backend.dockerImageAction({ sessionId, runtime, action: 'prune', all });
if (actionGenerationRef.current !== actionGeneration) return;
if (!result.success) {
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
showSystemManagerError(
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
t('common.error'),
);
return;
}
await refresh();
@@ -228,7 +246,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
setActionBusy(false);
}
}
}, [backend, refresh, sessionId, t]);
}, [backend, refresh, runtime, sessionId, t]);
const handlePrune = useCallback((all: boolean) => {
setConfirmTarget({ kind: 'prune', all });
@@ -237,13 +255,17 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
const handleTagSubmit = async (image: DockerImageInfo, repository: string, tag: string) => {
const result = await backend.dockerImageAction({
sessionId,
runtime,
action: 'tag',
imageId: image.id.slice(0, 12),
repository,
tag: tag || 'latest',
});
if (!result.success) {
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
showSystemManagerError(
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
t('common.error'),
);
return;
}
await refresh();
+139 -17
View File
@@ -1,19 +1,32 @@
import { Box, Layers } from 'lucide-react';
import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useContainerRuntimeSelection } from '../../application/state/systemManager/useContainerRuntimeSelection';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import type { ContainerSourceId } from '../../domain/systemManager/containerRuntime';
import type { SessionCapabilities } from '../../domain/systemManager/types';
import type { TerminalSession } from '../../types';
import { cn } from '../../lib/utils';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { DockerContainersPanel } from './DockerContainersPanel';
import { DockerImagesPanel } from './DockerImagesPanel';
import { KubernetesPodsPanel } from './KubernetesPodsPanel';
import { SystemPanelShell } from './SystemPanelUi';
type Backend = ReturnType<typeof useSystemManagerBackend>;
type DockerSubTab = 'containers' | 'images';
const RUNTIME_LABEL: Record<ContainerSourceId, string> = {
docker: 'systemManager.runtime.docker',
podman: 'systemManager.runtime.podman',
nerdctl: 'systemManager.runtime.nerdctl',
kubernetes: 'systemManager.runtime.kubernetes',
};
interface DockerManagerTabProps {
sessionId: string;
parentSession: TerminalSession;
capabilities?: SessionCapabilities;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
@@ -21,9 +34,48 @@ interface DockerManagerTabProps {
statsRefreshIntervalSec: number;
}
function CompactSelect({
value,
options,
ariaLabel,
section,
onChange,
className,
}: {
value: string;
options: { id: string; label: string }[];
ariaLabel: string;
section: string;
onChange: (value: string) => void;
className?: string;
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
aria-label={ariaLabel}
data-section={section}
className={cn(
"h-6 w-auto max-w-[9.5rem] gap-1 rounded border-border/40 bg-transparent px-2 py-0 text-[11px] shadow-none",
className,
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export const DockerManagerTab = memo(function DockerManagerTab({
sessionId,
parentSession,
capabilities,
isVisible,
warmupEnabled = false,
backend,
@@ -32,6 +84,26 @@ export const DockerManagerTab = memo(function DockerManagerTab({
}: DockerManagerTabProps) {
const { t } = useI18n();
const [subTab, setSubTab] = useState<DockerSubTab>('containers');
const {
source,
selectSource,
contextName,
selectContext,
namespaceName,
setNamespaceName,
contexts,
namespaces,
scopeError,
reloadScopes,
runtime,
detected,
showRuntimeSwitcher,
} = useContainerRuntimeSelection({
sessionId,
hostKey: parentSession.hostId || sessionId,
capabilities,
backend,
});
const tabs: { id: DockerSubTab; icon: typeof Box; label: string }[] = [
{ id: 'containers', icon: Box, label: t('systemManager.docker.subTabs.containers') },
@@ -40,8 +112,8 @@ export const DockerManagerTab = memo(function DockerManagerTab({
return (
<SystemPanelShell section="system-manager-docker">
<div className="shrink-0 flex items-center gap-0.5 px-2 py-1 border-b border-border/30">
{tabs.map(({ id, icon: Icon, label }) => (
<div className="shrink-0 flex items-center gap-1 px-2 py-1 border-b border-border/30">
{source !== 'kubernetes' && tabs.map(({ id, icon: Icon, label }) => (
<button
key={id}
type="button"
@@ -57,29 +129,79 @@ export const DockerManagerTab = memo(function DockerManagerTab({
{label}
</button>
))}
{showRuntimeSwitcher && source && detected.includes(source) && (
<CompactSelect
value={source}
ariaLabel={t('systemManager.runtime.switch')}
section="container-runtime-switcher"
className="shrink-0"
options={detected.map((id) => ({ id, label: t(RUNTIME_LABEL[id]) }))}
onChange={selectSource}
/>
)}
{source === 'kubernetes' && contextName && contexts.some((item) => item.name === contextName) && (
<CompactSelect
value={contextName}
ariaLabel={t('systemManager.k8s.context')}
section="k8s-context-select"
className="w-full min-w-0 max-w-none flex-1"
options={contexts.map((item) => ({ id: item.name, label: item.name }))}
onChange={selectContext}
/>
)}
{source === 'kubernetes' && namespaceName && namespaces.includes(namespaceName) && (
<CompactSelect
value={namespaceName}
ariaLabel={t('systemManager.k8s.namespace')}
section="k8s-namespace-select"
className="shrink-0"
options={namespaces.map((name) => ({ id: name, label: name }))}
onChange={setNamespaceName}
/>
)}
</div>
<div className="flex-1 min-h-0 flex flex-col">
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
<DockerContainersPanel
{source && source !== 'kubernetes' && (
<>
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
<DockerContainersPanel
sessionId={sessionId}
parentSession={parentSession}
runtime={runtime}
isVisible={isVisible && subTab === 'containers'}
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
backend={backend}
listRefreshIntervalSec={listRefreshIntervalSec}
statsRefreshIntervalSec={statsRefreshIntervalSec}
/>
</div>
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
<DockerImagesPanel
sessionId={sessionId}
runtime={runtime}
isVisible={isVisible && subTab === 'images'}
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
backend={backend}
listRefreshIntervalSec={listRefreshIntervalSec}
/>
</div>
</>
)}
{source === 'kubernetes' && (
<KubernetesPodsPanel
sessionId={sessionId}
parentSession={parentSession}
isVisible={isVisible && subTab === 'containers'}
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
contextName={contextName}
namespaceName={namespaceName}
scopeError={scopeError}
isVisible={isVisible}
warmupEnabled={warmupEnabled}
backend={backend}
listRefreshIntervalSec={listRefreshIntervalSec}
statsRefreshIntervalSec={statsRefreshIntervalSec}
onReloadScopes={reloadScopes}
/>
</div>
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
<DockerImagesPanel
sessionId={sessionId}
isVisible={isVisible && subTab === 'images'}
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
backend={backend}
listRefreshIntervalSec={listRefreshIntervalSec}
/>
</div>
)}
</div>
</SystemPanelShell>
);
@@ -0,0 +1,361 @@
import { Box, FileText, Info, Terminal, Trash2 } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import type { KubectlPodInfo } from '../../domain/systemManager/containerRuntime';
import {
buildKubectlDescribeCommand,
buildKubectlExecShellCommand,
buildKubectlLogsCommand,
describeRuntimeFailure,
podStatusMessageKey,
} from '../../domain/systemManager/containerRuntime';
import type { TerminalSession } from '../../types';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
import { useStableListOrder } from './listStable';
import { openInteractiveTerminal } from './openInteractiveTerminal';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelRoundButton,
SystemPanelRow,
SystemPanelSearch,
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
import { showSystemManagerError } from './systemManagerToast';
type Backend = ReturnType<typeof useSystemManagerBackend>;
interface KubernetesPodsPanelProps {
sessionId: string;
parentSession: TerminalSession;
contextName: string | null;
namespaceName: string | null;
scopeError?: string | null;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
listRefreshIntervalSec: number;
onReloadScopes: () => void;
}
function podTone(status: string): 'success' | 'warning' | 'muted' {
if (status === 'Running') return 'success';
if (status === 'Pending' || status === 'Completed' || status === 'Succeeded') return 'muted';
return 'warning';
}
const KubernetesPodRow = memo(function KubernetesPodRow({
pod,
selected,
pendingDelete,
onSelect,
onShell,
onLogs,
onDescribe,
onDelete,
}: {
pod: KubectlPodInfo;
selected: boolean;
pendingDelete: boolean;
onSelect: (pod: KubectlPodInfo) => void;
onShell: (pod: KubectlPodInfo) => void;
onLogs: (pod: KubectlPodInfo) => void;
onDescribe: (pod: KubectlPodInfo) => void;
onDelete: (pod: KubectlPodInfo) => void;
}) {
const { t } = useI18n();
const statusKey = podStatusMessageKey(pod.status);
const statusLabel = statusKey ? t(statusKey) : pod.status;
return (
<SystemPanelRow
selected={selected}
onClick={() => onSelect(pod)}
leading={<Box size={14} className="text-muted-foreground" />}
title={pod.name}
subtitle={`${pod.containers.join(', ') || '—'} · ${t('systemManager.k8s.restarts', { count: String(pod.restarts) })}${pod.age ? ` · ${pod.age}` : ''}`}
trailing={(
<div className="flex shrink-0 items-center gap-1">
<SystemPanelStatusBadge tone={podTone(pod.status)}>{statusLabel}</SystemPanelStatusBadge>
{pod.shellContainers.length > 0 && (
<SystemPanelRoundButton title={t('systemManager.docker.shell')} onClick={() => onShell(pod)}>
<Terminal size={12} />
</SystemPanelRoundButton>
)}
<SystemPanelRoundButton title={t('systemManager.docker.logs')} onClick={() => onLogs(pod)}>
<FileText size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton title={t('systemManager.k8s.describe')} onClick={() => onDescribe(pod)}>
<Info size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.k8s.delete')}
destructive
loading={pendingDelete}
onClick={() => onDelete(pod)}
>
<Trash2 size={12} />
</SystemPanelRoundButton>
</div>
)}
/>
);
});
export const KubernetesPodsPanel = memo(function KubernetesPodsPanel({
sessionId,
parentSession,
contextName,
namespaceName,
scopeError = null,
isVisible,
warmupEnabled = false,
backend,
listRefreshIntervalSec,
onReloadScopes,
}: KubernetesPodsPanelProps) {
const { t } = useI18n();
const stableT = useStableTranslate();
const [query, setQuery] = useState('');
const [selectedName, setSelectedName] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const [confirmPod, setConfirmPod] = useState<string | null>(null);
const [containerPrompt, setContainerPrompt] = useState<{
pod: KubectlPodInfo;
action: 'shell' | 'logs';
} | null>(null);
const scopeKey = `${sessionId}:${contextName ?? ''}:${namespaceName ?? ''}`;
useEffect(() => {
setSelectedName(null);
setPendingDelete(null);
setConfirmPod(null);
setContainerPrompt(null);
}, [scopeKey]);
const podsFetcher = useCallback(async () => {
if (!contextName || !namespaceName) return [];
const result = await backend.kubectl({
sessionId,
op: 'pods',
context: contextName,
namespace: namespaceName,
});
if (!result.success || !result.pods) {
throw new Error(describeRuntimeFailure(
result,
stableT,
stableT('systemManager.errors.loadPods'),
contextName,
));
}
return result.pods;
}, [backend, contextName, namespaceName, sessionId, stableT]);
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
const { data: pods, error, loading, refresh } = usePolling<KubectlPodInfo[]>(
podsFetcher,
listIntervalMs,
(isVisible || warmupEnabled) && !!contextName && !!namespaceName,
undefined,
{ poll: isVisible, resetKey: scopeKey },
);
const matched = useMemo(() => {
const q = query.trim().toLowerCase();
return (pods ?? []).filter((pod) => {
if (!q) return true;
const statusKey = podStatusMessageKey(pod.status);
const statusLabel = (statusKey ? t(statusKey) : pod.status).toLowerCase();
return pod.name.toLowerCase().includes(q)
|| pod.status.toLowerCase().includes(q)
|| statusLabel.includes(q)
|| pod.containers.some((name) => name.toLowerCase().includes(q));
});
}, [pods, query, t]);
const displayList = useStableListOrder<KubectlPodInfo, string>(
matched,
(pod) => pod.name,
`${scopeKey}|${query}`,
(a, b) => a.name.localeCompare(b.name),
);
const openCommand = useCallback(async (title: string, command: string) => {
const result = await openInteractiveTerminal(backend, parentSession, title, command);
if (!result.success) {
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
}
}, [backend, parentSession, t]);
const runPodCommand = useCallback((pod: KubectlPodInfo, action: 'shell' | 'logs' | 'describe', container?: string) => {
if (!contextName || !namespaceName) return;
if (action === 'describe') {
void openCommand(
`describe: ${pod.name}`,
buildKubectlDescribeCommand(contextName, namespaceName, pod.name, parentSession.shellType),
);
return;
}
const containers = action === 'shell' ? pod.shellContainers : pod.containers;
if (action === 'shell' && containers.length === 0) return;
if (!container && containers.length > 1) {
setContainerPrompt({ pod, action });
return;
}
const chosen = container || containers[0];
if (action === 'shell') {
void openCommand(
`pod: ${pod.name}`,
buildKubectlExecShellCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
);
return;
}
void openCommand(
`logs: ${pod.name}`,
buildKubectlLogsCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
);
}, [contextName, namespaceName, openCommand, parentSession.shellType]);
const deletePod = useCallback(async (name: string) => {
if (!contextName || !namespaceName) return;
setPendingDelete(name);
try {
const result = await backend.kubectl({
sessionId,
op: 'delete',
context: contextName,
namespace: namespaceName,
pod: name,
});
if (!result.success) {
showSystemManagerError(
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed'), contextName),
t('common.error'),
);
return;
}
if (selectedName === name) setSelectedName(null);
await refresh();
} finally {
setPendingDelete(null);
}
}, [backend, contextName, namespaceName, refresh, selectedName, sessionId, t]);
const visibleError = scopeError || error;
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="kubernetes-pods">
<SystemPanelToolbar
trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={loading}
onClick={() => {
onReloadScopes();
void refresh();
}}
/>
)}
>
<SystemPanelSearch
value={query}
onChange={setQuery}
placeholder={t('systemManager.k8s.search')}
/>
</SystemPanelToolbar>
<SystemPanelMetaBar>
{t('systemManager.k8s.meta', { count: String(displayList.length) })}
</SystemPanelMetaBar>
<SystemPanelList>
{!contextName && !visibleError && (
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.noContext')} />
)}
{visibleError && (
<SystemPanelError
message={visibleError}
onRetry={() => {
onReloadScopes();
void refresh();
}}
retryLabel={t('history.action.retry')}
loading={loading}
/>
)}
{!visibleError && contextName && displayList.length === 0 && loading && (
<SystemPanelLoading message={t('systemManager.common.loading')} />
)}
{!visibleError && contextName && namespaceName && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.empty')} />
)}
{displayList.map((pod) => (
<KubernetesPodRow
key={pod.name}
pod={pod}
selected={selectedName === pod.name}
pendingDelete={pendingDelete === pod.name}
onSelect={(item) => setSelectedName((current) => current === item.name ? null : item.name)}
onShell={(item) => runPodCommand(item, 'shell')}
onLogs={(item) => runPodCommand(item, 'logs')}
onDescribe={(item) => runPodCommand(item, 'describe')}
onDelete={(item) => setConfirmPod(item.name)}
/>
))}
</SystemPanelList>
<SystemPanelConfirmDialog
open={confirmPod !== null}
title={t('systemManager.k8s.delete')}
message={t('systemManager.k8s.confirmDelete', { name: confirmPod ?? '' })}
confirmLabel={t('systemManager.k8s.delete')}
destructive
busy={pendingDelete !== null}
onOpenChange={(open) => { if (!open) setConfirmPod(null); }}
onConfirm={() => {
const name = confirmPod;
setConfirmPod(null);
if (!name) return;
void deletePod(name);
}}
/>
<Dialog open={containerPrompt !== null} onOpenChange={(open) => { if (!open) setContainerPrompt(null); }}>
<DialogContent className="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle>{t('systemManager.k8s.chooseContainer')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-1">
{(containerPrompt?.action === 'shell'
? containerPrompt.pod.shellContainers
: containerPrompt?.pod.containers
)?.map((name) => (
<button
key={name}
type="button"
className="rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
onClick={() => {
const prompt = containerPrompt;
setContainerPrompt(null);
if (!prompt) return;
runPodCommand(prompt.pod, prompt.action, name);
}}
>
{name}
</button>
))}
</div>
</DialogContent>
</Dialog>
</div>
);
});
@@ -14,6 +14,7 @@ import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelStat
import {
allowSystemManagerMutations,
buildSystemManagerTabs,
hasContainerSource,
shouldCollectServerStats,
} from '../../domain/systemManager/systemTarget';
@@ -284,13 +285,14 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
}, [isConnectedSession, isVisible, availableTabsKey, tabBarEl]);
// Must be defined before early returns to comply with React rules of hooks.
const containerSourceReady = hasContainerSource(capabilities);
const prevTabRef = React.useRef(resolvedTab);
const probingRef = React.useRef(false);
React.useEffect(() => {
const prev = prevTabRef.current;
prevTabRef.current = resolvedTab;
if (prev === resolvedTab) return;
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
if (resolvedTab === 'docker' && !containerSourceReady) {
if (!probingRef.current) {
probingRef.current = true;
refreshCapabilities().finally(() => { probingRef.current = false; });
@@ -298,7 +300,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
void refreshCapabilities();
}
}, [resolvedTab, capabilities, refreshCapabilities]);
}, [resolvedTab, capabilities, containerSourceReady, refreshCapabilities]);
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
// Use setTimeout recursion so the next probe only starts after the previous one finishes,
@@ -315,7 +317,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
// preventing stale probes from surviving cleanup (unlike cancelledRef which is shared).
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe.
React.useEffect(() => {
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
if (!isVisible || resolvedTab !== 'docker' || containerSourceReady) return;
let cancelled = false;
let timerId: ReturnType<typeof setTimeout>;
@@ -344,7 +346,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
cancelled = true;
if (timerId) clearTimeout(timerId);
};
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
}, [isVisible, resolvedTab, containerSourceReady, capabilitiesTtlMs]);
const selectTab = useCallback((id: SystemManagerSubTab) => {
setActiveTab(id);
@@ -398,7 +400,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
}
const tmuxReady = capabilities?.hasTmux === true;
const dockerReady = capabilities?.hasDocker === true;
const dockerReady = containerSourceReady;
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
const portsReady = (
capabilities?.hasSs === true
@@ -621,8 +623,10 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
) : dockerPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
<DockerManagerTab
key={session.hostId || sessionId}
sessionId={sessionId}
parentSession={session}
capabilities={capabilities}
isVisible={isVisible && resolvedTab === 'docker'}
warmupEnabled={isVisible && resolvedTab !== 'docker'}
backend={backend}
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
test('kubernetes keeps the shared runtime select and drops the Containers back button', () => {
const tab = readFileSync(new URL('./DockerManagerTab.tsx', import.meta.url), 'utf8');
const hook = readFileSync(
new URL('../../application/state/systemManager/useContainerRuntimeSelection.ts', import.meta.url),
'utf8',
);
assert.equal(tab.includes('container-runtime-back'), false);
assert.match(tab, /data-section=\{section\}/);
assert.match(tab, /section="container-runtime-switcher"/);
assert.match(tab, /section="k8s-context-select"/);
assert.match(tab, /section="k8s-namespace-select"/);
assert.doesNotMatch(tab, /<div className="flex-1" \/>/);
assert.match(tab, /section="k8s-context-select"[\s\S]*className="w-full min-w-0 max-w-none flex-1"/);
assert.match(tab, /section="container-runtime-switcher"[\s\S]*className="shrink-0"/);
assert.match(tab, /section="k8s-namespace-select"[\s\S]*className="shrink-0"/);
assert.match(hook, /const showRuntimeSwitcher = detected\.length > 1;/);
assert.doesNotMatch(hook, /source !== 'kubernetes' && detected\.length/);
assert.match(hook, /reconcileContainerSource\(/);
assert.match(hook, /isAutomaticRuntimeFallback\(remembered, source, available\)/);
});
@@ -93,3 +93,71 @@ test('openInteractiveTerminal keeps SSH connection reuse for ordinary connected
assert.equal(payload.sourceSession.protocol, 'ssh');
assert.equal(payload.sourceSession.reuseConnectionFromSessionId, 'session-1');
});
async function withSystemPanels(
panels: Array<{ getBoundingClientRect: () => { x: number; y: number; width: number; height: number } }>,
run: () => Promise<void>,
): Promise<void> {
const previous = globalThis.document;
globalThis.document = {
querySelectorAll(selector: string) {
return selector === '[data-section="system-manager-panel"]' ? panels : [];
},
} as unknown as Document;
try {
await run();
} finally {
if (previous === undefined) {
delete (globalThis as { document?: Document }).document;
} else {
globalThis.document = previous;
}
}
}
test('openInteractiveTerminal passes the system panel rectangle so the shell can avoid it', async () => {
const panel = {
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
};
const payloads: Array<{ avoidClientRect?: { x: number; width: number } }> = [];
await withSystemPanels([panel], async () => {
await openInteractiveTerminal(
{
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; width: number } }) => {
payloads.push(payload);
return { success: true };
},
} as never,
parentSession(),
'pod: multi-box',
'kubectl exec -it multi-box -- sh',
);
});
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
});
test('openInteractiveTerminal skips a parked zero-size system panel and avoids the visible one', async () => {
const parked = {
getBoundingClientRect: () => ({ x: 0, y: 0, width: 0, height: 0 }),
};
const visible = {
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
};
const payloads: Array<{ avoidClientRect?: { x: number; y: number; width: number; height: number } }> = [];
await withSystemPanels([parked, visible], async () => {
await openInteractiveTerminal(
{
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; y: number; width: number; height: number } }) => {
payloads.push(payload);
return { success: true };
},
} as never,
parentSession(),
'pod: multi-box',
'kubectl exec -it multi-box -- sh',
);
});
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
});
@@ -6,6 +6,18 @@ import type { useSystemManagerBackend } from '../../application/state/useSystemM
type Backend = ReturnType<typeof useSystemManagerBackend>;
function readSystemPanelClientRect(): { x: number; y: number; width: number; height: number } | undefined {
if (typeof document === "undefined" || typeof document.querySelectorAll !== "function") return undefined;
const panels = document.querySelectorAll('[data-section="system-manager-panel"]');
for (const panel of panels) {
if (typeof panel.getBoundingClientRect !== "function") continue;
const rect = panel.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) continue;
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
return undefined;
}
function buildPopupTitle(parentSession: TerminalSession, title: string): string {
const hostLabel = parentSession.hostLabel.trim();
const cleanTitle = title.trim();
@@ -62,11 +74,13 @@ export async function openInteractiveTerminal(
canReuseConnection,
hasIcon: !!options?.icon,
});
const avoidClientRect = readSystemPanelClientRect();
const result = await backend.openTerminalPopup({
title: popupTitle,
icon: options?.icon,
parentSessionId: parentSession.id,
startupCommand,
...(avoidClientRect ? { avoidClientRect } : {}),
sourceSession: buildCommandPopupSourceSession(parentSession, startupCommand, canReuseConnection),
});
await writeSystemManagerDiagnostic('openInteractiveTerminal result', {
@@ -49,11 +49,11 @@ test("process and docker confirm dialogs reset when sessionId changes", () => {
assert.match(processSource, /}, \[sessionId\]\);/);
assert.match(containersSource, /setConfirmAction\(null\)/);
assert.match(containersSource, /}, \[sessionId\]\);/);
assert.match(containersSource, /}, \[runtime, sessionId\]\);/);
assert.match(imagesSource, /setConfirmTarget\(null\)/);
assert.match(imagesSource, /setActionBusy\(false\)/);
assert.match(imagesSource, /}, \[sessionId\]\);/);
assert.match(imagesSource, /}, \[runtime, sessionId\]\);/);
});
test("ports and services confirm dialogs reset when sessionId changes", () => {
+43
View File
@@ -11,6 +11,13 @@ import {
isNetcattyManagedStartupHistoryCommand,
} from './remoteHistory.ts';
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts';
import {
buildContainerExecShellCommand,
buildContainerLogsCommand,
buildKubectlDescribeCommand,
buildKubectlExecShellCommand,
buildKubectlLogsCommand,
} from './systemManager/containerRuntime.ts';
import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts';
test('parseBashHistory: plain lines', () => {
@@ -199,8 +206,44 @@ test('isNetcattyAiHistoryCommand: detects AI PTY marker lines', () => {
test('isNetcattyManagedStartupHistoryCommand: detects Docker and tmux terminal launch commands', () => {
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerExecShellCommand('587abcdef123')), true);
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerLogsCommand('587abcdef123')), true);
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', '587abcdef123')), true);
assert.equal(
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-dev', 'default', 'api-0')),
true,
);
assert.equal(
isNetcattyManagedStartupHistoryCommand(
"printf '\\033[H\\033[2J\\033[3J'; _nc_docker_err=$(docker inspect 587abcdef123 2>&1 >/dev/null);",
),
true,
);
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session')), true);
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session', 2)), true);
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', 'abc123', 'powershell')), true);
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerExecShellCommand('nerdctl', 'abc123', 'cmd')), true);
assert.equal(
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-kind', 'default', 'api', undefined, 'powershell')),
true,
);
assert.equal(
isNetcattyManagedStartupHistoryCommand(buildKubectlDescribeCommand('kind-kind', 'default', 'api', 'cmd')),
true,
);
assert.equal(
isNetcattyManagedStartupHistoryCommand(buildKubectlExecShellCommand('kind-kind', 'default', 'api', 'app', 'powershell')),
true,
);
assert.equal(isNetcattyManagedStartupHistoryCommand('podman logs -f abc123'), false);
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl logs api'), false);
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl describe pod api'), false);
assert.equal(
isNetcattyManagedStartupHistoryCommand('kubectl --context prod -n default logs -f --tail 200 api'),
false,
);
assert.equal(
isNetcattyManagedStartupHistoryCommand('kubectl --context prod describe pod api'),
false,
);
assert.equal(isNetcattyManagedStartupHistoryCommand('docker ps -a'), false);
assert.equal(isNetcattyManagedStartupHistoryCommand('docker logs -f 587abcdef123'), false);
assert.equal(isNetcattyManagedStartupHistoryCommand('docker exec -it 587abcdef123 bash'), false);
+9 -2
View File
@@ -1,4 +1,5 @@
import { RemoteHistoryEntry, RemoteHistorySource } from './models';
import { NETCATTY_POPUP_HISTORY_MARKER } from './systemManager/containerRuntime.ts';
/** Marker prefix Netcatty AI uses when executing commands via the PTY bridge. */
export const NETCATTY_AI_HISTORY_MARKER = '__NCMCP_';
@@ -9,11 +10,17 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
}
const NETCATTY_MANAGED_STARTUP_COMMAND =
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+kubectl\b)|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+(?:(?:docker|podman|nerdctl)\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
const NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND =
/^(?:\$__nc_popup='__NC_POPUP__'; |set "__NC_POPUP__=1"& )?(?:docker|podman|nerdctl) inspect [a-zA-Z0-9]+ (?:>\$null; if \(\$LASTEXITCODE -eq 0\) \{|>nul && )/;
/** True when a shell history line came from a Netcatty-managed terminal launch. */
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {
return NETCATTY_MANAGED_STARTUP_COMMAND.test(command.trim());
const text = command.trim();
return text.includes(NETCATTY_POPUP_HISTORY_MARKER)
|| NETCATTY_MANAGED_STARTUP_COMMAND.test(text)
|| NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND.test(text);
}
const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/;
@@ -0,0 +1,550 @@
"use strict";
const CONTAINER_RUNTIMES = ["docker", "podman", "nerdctl"];
const SOURCE_ORDER = ["docker", "podman", "nerdctl", "kubernetes"];
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
const NETCATTY_POPUP_HISTORY_MARKER = "__NC_POPUP__";
function popupHistoryPrefix(shell) {
if (shell === "powershell") return `$__nc_popup='${NETCATTY_POPUP_HISTORY_MARKER}'; `;
if (shell === "cmd") return `set "${NETCATTY_POPUP_HISTORY_MARKER}=1"& `;
return "";
}
function shQuote(str) {
return `'${String(str).replace(/'/g, "'\"'\"'")}'`;
}
function normalizeContainerRuntime(runtime) {
return CONTAINER_RUNTIMES.includes(runtime) ? runtime : "docker";
}
function isContainerSource(value) {
return SOURCE_ORDER.includes(value);
}
function runtimeBinary(runtime) {
return normalizeContainerRuntime(runtime);
}
function buildRuntimeCommand(runtime, args) {
return `${runtimeBinary(runtime)} ${String(args || "")}`.trim();
}
function parseRuntimeProbe(stdout) {
const text = stdout || "";
const hasFlag = (name) => new RegExp(`(?:^|\\r?\\n)${name}=1(?:\\r?\\n|$)`).test(text);
return {
docker: hasFlag("__NC_DOCKER__"),
podman: hasFlag("__NC_PODMAN__"),
nerdctl: hasFlag("__NC_NERDCTL__"),
kubernetes: hasFlag("__NC_KUBECTL__"),
};
}
function sourcesFromCapabilities(capabilities) {
if (!capabilities) return [];
const present = {
docker: capabilities.hasDocker === true || capabilities.docker === true,
podman: capabilities.hasPodman === true || capabilities.podman === true,
nerdctl: capabilities.hasNerdctl === true || capabilities.nerdctl === true,
kubernetes: capabilities.hasKubectl === true || capabilities.kubernetes === true,
};
return SOURCE_ORDER.filter((id) => present[id]);
}
function pickContainerSource(detected, current, remembered) {
const available = SOURCE_ORDER.filter((id) => Array.isArray(detected) && detected.includes(id));
if (current && available.includes(current)) return current;
if (remembered && available.includes(remembered)) return remembered;
return available[0] || null;
}
function rememberedContainerSource(preference, detected) {
const source = preference?.source;
if (!isContainerSource(source)) return null;
const available = Array.isArray(detected) ? detected : [];
if (source === "kubernetes" && !available.includes("kubernetes") && CONTAINER_RUNTIMES.includes(preference?.engine)) {
return preference.engine;
}
return source;
}
function reconcileContainerSource(detected, current, preference) {
const available = SOURCE_ORDER.filter((id) => Array.isArray(detected) && detected.includes(id));
const preferred = rememberedContainerSource(preference, available);
if (preferred && available.includes(preferred)) return preferred;
return pickContainerSource(available, current, preferred);
}
function isAutomaticRuntimeFallback(preference, source, detected) {
if (!preference?.source || !isContainerSource(source) || preference.source === source) return false;
const available = Array.isArray(detected) ? detected : [];
if (available.includes(preference.source)) return false;
const automatic = pickContainerSource(
available,
null,
rememberedContainerSource(preference, available),
);
return source === automatic;
}
function firstContainerRuntime(detected) {
return CONTAINER_RUNTIMES.find((id) => Array.isArray(detected) && detected.includes(id)) || null;
}
function socketNeedles(runtime) {
if (runtime === "podman") {
return ["podman.sock", "/run/podman", "podman daemon"];
}
if (runtime === "nerdctl") {
return ["containerd.sock", "/run/containerd"];
}
return ["docker daemon", "docker.sock", "/var/run/docker.sock", "connect to the docker daemon"];
}
function isRuntimeSocketPermissionError(runtime, result) {
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
if (!text.includes("permission denied")) return false;
return socketNeedles(normalizeContainerRuntime(runtime)).some((needle) => text.includes(needle));
}
function socketCasePatterns(runtime) {
if (runtime === "podman") {
return [
"*permission\\ denied*podman.sock*",
"*podman.sock*permission\\ denied*",
"*permission\\ denied*/run/podman*",
"*/run/podman*permission\\ denied*",
"*permission\\ denied*podman\\ daemon*",
"*podman\\ daemon*permission\\ denied*",
];
}
if (runtime === "nerdctl") {
return [
"*permission\\ denied*containerd.sock*",
"*containerd.sock*permission\\ denied*",
"*permission\\ denied*/run/containerd*",
"*/run/containerd*permission\\ denied*",
];
}
return [
"*permission\\ denied*docker\\ daemon*",
"*docker\\ daemon*permission\\ denied*",
"*permission\\ denied*docker.sock*",
"*docker.sock*permission\\ denied*",
"*permission\\ denied*/var/run/docker.sock*",
"*/var/run/docker.sock*permission\\ denied*",
"*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*",
"*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*",
];
}
function sanitizeContainerId(id) {
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
}
function containerInteractiveArgs(containerId, shell) {
if (shell === "cmd") {
return `exec -it ${containerId} sh -c "command -v bash >/dev/null 2>&1 && exec bash || exec sh"`;
}
return `exec -it ${containerId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`;
}
function buildContainerCommandWithSudoFallback(runtime, containerId, cliArgs, shell) {
const popupShell = normalizePopupShell(shell);
const bin = runtimeBinary(runtime);
const plainCommand = `${bin} ${cliArgs}`;
if (popupShell === "powershell") {
return `${popupHistoryPrefix(popupShell)}${bin} inspect ${containerId} >$null; if ($LASTEXITCODE -eq 0) { ${plainCommand} } else { exit $LASTEXITCODE }`;
}
if (popupShell === "cmd") {
return `${popupHistoryPrefix(popupShell)}${bin} inspect ${containerId} >nul && ${plainCommand}`;
}
const sudoCommand = `sudo ${plainCommand}`;
const script = [
CLEAR_STARTUP_OUTPUT,
`_nc_rt_err=$(${bin} inspect ${containerId} 2>&1 >/dev/null);`,
"_nc_rt_status=$?;",
`if [ "$_nc_rt_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
"_nc_rt_lc=$(printf '%s' \"$_nc_rt_err\" | tr '[:upper:]' '[:lower:]');",
"case \"$_nc_rt_lc\" in",
`${socketCasePatterns(normalizeContainerRuntime(runtime)).join("|")}) exec ${sudoCommand} ;;`,
"*) printf '%s\\n' \"$_nc_rt_err\" >&2; exit \"$_nc_rt_status\" ;;",
"esac",
].join(" ");
return `sh -c ${shQuote(script)}`;
}
function buildContainerExecShellCommand(runtime, containerId, shell) {
const safeId = sanitizeContainerId(containerId);
if (!safeId) return 'echo "Invalid container id"';
return buildContainerCommandWithSudoFallback(
runtime,
safeId,
containerInteractiveArgs(safeId, normalizePopupShell(shell)),
shell,
);
}
function buildContainerLogsCommand(runtime, containerId, shell) {
const safeId = sanitizeContainerId(containerId);
if (!safeId) return 'echo "Invalid container id"';
return buildContainerCommandWithSudoFallback(runtime, safeId, `logs -f --tail 200 ${safeId}`, shell);
}
function hasControlChar(text) {
for (let i = 0; i < text.length; i += 1) {
const code = text.charCodeAt(i);
if (code <= 31 || code === 127) return true;
}
return false;
}
function isSafeShellArg(value, shell) {
const text = String(value ?? "");
if (!text || text.length > 512) return false;
if (hasControlChar(text)) return false;
// Interactive cmd.exe expands %VAR% inside double quotes, and %% is not a
// reliable escape there. With /V:ON it also expands !VAR! inside quotes.
// Reject those arguments instead of targeting a substituted context.
if (shell === "cmd" && (text.includes("%") || text.includes("!"))) return false;
return true;
}
function normalizePopupShell(shell) {
return shell === "powershell" || shell === "cmd" ? shell : "posix";
}
function quoteShellArg(shell, value) {
const text = String(value);
if (shell === "cmd") return `"${text.replace(/"/g, '""')}"`;
if (shell === "powershell") return `'${text.replace(/'/g, "''")}'`;
return shQuote(text);
}
function kubectlPrefix(context, namespace, shell = "posix") {
const popupShell = normalizePopupShell(shell);
if (!isSafeShellArg(context, popupShell)) return null;
const parts = ["kubectl", "--context", quoteShellArg(popupShell, context)];
if (namespace != null && namespace !== "") {
if (!isSafeShellArg(namespace, popupShell)) return null;
parts.push("-n", quoteShellArg(popupShell, namespace));
}
return parts.join(" ");
}
function optionalContainerArg(container, shell = "posix") {
if (container == null || container === "") return "";
if (!isSafeShellArg(container, normalizePopupShell(shell))) return null;
return ` -c ${quoteShellArg(normalizePopupShell(shell), container)}`;
}
function isKubectlForbiddenError(message) {
return /\bforbidden\b/i.test(String(message || ""));
}
function isKubectlUnreachableError(message) {
const text = String(message || "");
if (!text.trim() || isKubectlForbiddenError(text)) return false;
return /couldn'?t get current server api group list|could not get current server api group list|unable to connect to the server|connection refused|dial tcp|i\/o timeout|no such host|network is unreachable|context deadline exceeded|tls handshake timeout/i.test(text);
}
function isNerdctlUnreachableError(message) {
const text = String(message || "").toLowerCase();
if (!text.trim()) return false;
if (text.includes("rootless containerd") || text.includes("xdg_runtime_dir")) return true;
if (text.includes("cannot connect to the docker daemon")) return true;
if (!text.includes("containerd")) return false;
return /not running|connection refused|no such file|cannot connect|failed to connect|unavailable/.test(text);
}
const POD_STATUS_MESSAGE_KEYS = {
Running: "systemManager.k8s.status.Running",
Pending: "systemManager.k8s.status.Pending",
Completed: "systemManager.k8s.status.Completed",
Succeeded: "systemManager.k8s.status.Succeeded",
Failed: "systemManager.k8s.status.Failed",
Unknown: "systemManager.k8s.status.Unknown",
CrashLoopBackOff: "systemManager.k8s.status.CrashLoopBackOff",
ContainerCreating: "systemManager.k8s.status.ContainerCreating",
Evicted: "systemManager.k8s.status.Evicted",
Terminating: "systemManager.k8s.status.Terminating",
ImagePullBackOff: "systemManager.k8s.status.ImagePullBackOff",
ErrImagePull: "systemManager.k8s.status.ErrImagePull",
Error: "systemManager.k8s.status.Error",
OOMKilled: "systemManager.k8s.status.OOMKilled",
};
function podStatusMessageKey(status) {
return POD_STATUS_MESSAGE_KEYS[String(status || "")] || null;
}
function runtimeFailureMessage(errorCode, context) {
if (errorCode === "nerdctlUnreachable") {
return { key: "systemManager.errors.nerdctlUnreachable" };
}
if (errorCode === "clusterUnreachable") {
const name = typeof context === "string" ? context.trim() : "";
if (name) {
return {
key: "systemManager.errors.clusterUnreachable",
values: { context: name },
};
}
return { key: "systemManager.errors.clusterUnreachableGeneric" };
}
return null;
}
function wrapClearAndExec(command) {
return `sh -c ${shQuote(`${CLEAR_STARTUP_OUTPUT} exec ${command}`)}`;
}
function buildKubectlContextsCommand() {
return "kubectl config view -o json";
}
function buildKubectlNamespacesCommand(context, shell) {
const popupShell = normalizePopupShell(shell);
if (!isSafeShellArg(context, popupShell)) return null;
return `kubectl --context ${quoteShellArg(popupShell, context)} get namespaces -o json`;
}
function buildKubectlPodsCommand(context, namespace, shell) {
const prefix = kubectlPrefix(context, namespace, shell);
if (!prefix) return null;
return `${prefix} get pods -o json`;
}
function buildKubectlDeletePodCommand(context, namespace, pod, shell) {
const popupShell = normalizePopupShell(shell);
const prefix = kubectlPrefix(context, namespace, popupShell);
if (!prefix || !isSafeShellArg(pod, popupShell)) return null;
return `${prefix} delete pod ${quoteShellArg(popupShell, pod)} --wait=false`;
}
function containerShellArg(shell) {
if (shell === "cmd") {
return 'sh -c "command -v bash >/dev/null 2>&1 && exec bash || exec sh"';
}
return "sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'";
}
function finishKubectlPopup(shell, command) {
return shell === "posix" ? wrapClearAndExec(command) : `${popupHistoryPrefix(shell)}${command}`;
}
function buildKubectlExecShellCommand(context, namespace, pod, container, shell) {
const popupShell = normalizePopupShell(shell);
const prefix = kubectlPrefix(context, namespace, popupShell);
if (!prefix) return 'echo "Invalid context"';
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
const containerArg = optionalContainerArg(container, popupShell);
if (containerArg == null) return 'echo "Invalid container name"';
const command = `${prefix} exec -it ${quoteShellArg(popupShell, pod)}${containerArg} -- ${containerShellArg(popupShell)}`;
return finishKubectlPopup(popupShell, command);
}
function buildKubectlLogsCommand(context, namespace, pod, container, shell) {
const popupShell = normalizePopupShell(shell);
const prefix = kubectlPrefix(context, namespace, popupShell);
if (!prefix) return 'echo "Invalid context"';
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
const containerArg = optionalContainerArg(container, popupShell);
if (containerArg == null) return 'echo "Invalid container name"';
const command = `${prefix} logs -f --tail 200 ${quoteShellArg(popupShell, pod)}${containerArg}`;
return finishKubectlPopup(popupShell, command);
}
function buildKubectlDescribeCommand(context, namespace, pod, shell) {
const popupShell = normalizePopupShell(shell);
const prefix = kubectlPrefix(context, namespace, popupShell);
if (!prefix) return 'echo "Invalid context"';
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
const command = `${prefix} describe pod ${quoteShellArg(popupShell, pod)}`;
return finishKubectlPopup(popupShell, command);
}
function parseJsonValue(text) {
const raw = String(text || "");
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (ch !== "{" && ch !== "[") continue;
try {
return JSON.parse(raw.slice(i));
} catch {
// A bracketed banner such as [WARN] is not the JSON document.
}
}
throw new Error("Invalid JSON");
}
function parseKubeconfigContexts(jsonText) {
const parsed = parseJsonValue(jsonText);
const current = typeof parsed["current-context"] === "string" ? parsed["current-context"] : "";
const contexts = [];
const list = Array.isArray(parsed.contexts) ? parsed.contexts : [];
for (const entry of list) {
const name = typeof entry?.name === "string" ? entry.name : "";
if (!name) continue;
const rawNamespace = entry?.context?.namespace;
const namespace = typeof rawNamespace === "string" && rawNamespace ? rawNamespace : "default";
contexts.push({
name,
namespace,
current: name === current,
});
}
return { contexts, current };
}
function parseNamespaceList(jsonText) {
const parsed = parseJsonValue(jsonText);
const items = Array.isArray(parsed.items) ? parsed.items : [];
const names = [];
for (const item of items) {
const name = item?.metadata?.name;
if (typeof name === "string" && name) names.push(name);
}
return names;
}
function formatPodAge(iso, now = Date.now()) {
const timestamp = Date.parse(String(iso || ""));
if (!Number.isFinite(timestamp)) return "";
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
function podDisplayStatus(pod) {
if (typeof pod?.metadata?.deletionTimestamp === "string" && pod.metadata.deletionTimestamp) {
return "Terminating";
}
const statuses = [
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
];
for (const status of statuses) {
const reason = status?.state?.waiting?.reason;
if (reason) return String(reason);
}
const podReason = typeof pod?.status?.reason === "string" ? pod.status.reason.trim() : "";
if (podReason) return podReason;
for (const status of statuses) {
const reason = status?.state?.terminated?.reason;
if (reason && reason !== "Completed") return String(reason);
}
const phase = String(pod?.status?.phase || "Unknown");
if (phase === "Succeeded") return "Completed";
return phase;
}
function podContainerNames(pod) {
const names = [];
const specs = [
...(Array.isArray(pod?.spec?.containers) ? pod.spec.containers : []),
...(Array.isArray(pod?.spec?.initContainers) ? pod.spec.initContainers : []),
...(Array.isArray(pod?.spec?.ephemeralContainers) ? pod.spec.ephemeralContainers : []),
];
for (const container of specs) {
const name = container?.name;
if (typeof name === "string" && name && !names.includes(name)) names.push(name);
}
return names;
}
function podShellContainerNames(pod) {
const running = new Set();
const statuses = [
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
...(Array.isArray(pod?.status?.ephemeralContainerStatuses) ? pod.status.ephemeralContainerStatuses : []),
];
for (const status of statuses) {
if (status?.state?.running && typeof status.name === "string" && status.name) {
running.add(status.name);
}
}
const ordered = podContainerNames(pod).filter((name) => running.has(name));
for (const name of running) {
if (!ordered.includes(name)) ordered.push(name);
}
return ordered;
}
function podRestartCount(pod) {
const statuses = [
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
];
let restarts = 0;
for (const status of statuses) {
const count = Number(status?.restartCount || 0);
if (Number.isFinite(count)) restarts += count;
}
return restarts;
}
function parseKubectlPodList(jsonText, now = Date.now()) {
const parsed = parseJsonValue(jsonText);
const items = Array.isArray(parsed.items) ? parsed.items : [];
const pods = [];
for (const pod of items) {
const name = typeof pod?.metadata?.name === "string" ? pod.metadata.name : "";
if (!name) continue;
pods.push({
name,
namespace: typeof pod?.metadata?.namespace === "string" ? pod.metadata.namespace : "",
status: podDisplayStatus(pod),
restarts: podRestartCount(pod),
age: formatPodAge(pod?.metadata?.creationTimestamp, now),
containers: podContainerNames(pod),
shellContainers: podShellContainerNames(pod),
});
}
return pods;
}
module.exports = {
NETCATTY_POPUP_HISTORY_MARKER,
CONTAINER_RUNTIMES,
SOURCE_ORDER,
normalizeContainerRuntime,
isContainerSource,
runtimeBinary,
buildRuntimeCommand,
parseRuntimeProbe,
sourcesFromCapabilities,
pickContainerSource,
rememberedContainerSource,
reconcileContainerSource,
isAutomaticRuntimeFallback,
firstContainerRuntime,
isRuntimeSocketPermissionError,
sanitizeContainerId,
buildContainerExecShellCommand,
buildContainerLogsCommand,
buildKubectlContextsCommand,
buildKubectlNamespacesCommand,
buildKubectlPodsCommand,
buildKubectlDeletePodCommand,
buildKubectlExecShellCommand,
buildKubectlLogsCommand,
buildKubectlDescribeCommand,
isKubectlForbiddenError,
isKubectlUnreachableError,
isNerdctlUnreachableError,
podStatusMessageKey,
runtimeFailureMessage,
parseKubeconfigContexts,
parseNamespaceList,
parseKubectlPodList,
formatPodAge,
};
+109
View File
@@ -0,0 +1,109 @@
export type ContainerRuntimeId = "docker" | "podman" | "nerdctl";
export type ContainerSourceId = ContainerRuntimeId | "kubernetes";
export interface RuntimeProbeFlags {
docker: boolean;
podman: boolean;
nerdctl: boolean;
kubernetes: boolean;
}
export interface KubeContextInfo {
name: string;
namespace: string;
current: boolean;
}
export interface KubectlPodInfo {
name: string;
namespace: string;
status: string;
restarts: number;
age: string;
containers: string[];
shellContainers: string[];
}
export const NETCATTY_POPUP_HISTORY_MARKER: string;
export function normalizeContainerRuntime(runtime: unknown): ContainerRuntimeId;
export function isContainerSource(value: unknown): value is ContainerSourceId;
export function runtimeBinary(runtime: unknown): string;
export function buildRuntimeCommand(runtime: unknown, args: string): string;
export function parseRuntimeProbe(stdout: string): RuntimeProbeFlags;
export function sourcesFromCapabilities(capabilities: {
hasDocker?: boolean;
hasPodman?: boolean;
hasNerdctl?: boolean;
hasKubectl?: boolean;
docker?: boolean;
podman?: boolean;
nerdctl?: boolean;
kubernetes?: boolean;
} | null | undefined): ContainerSourceId[];
export function pickContainerSource(
detected: readonly ContainerSourceId[] | null | undefined,
current: ContainerSourceId | null | undefined,
remembered: ContainerSourceId | null | undefined,
): ContainerSourceId | null;
export function rememberedContainerSource(
preference: { source?: string; engine?: string } | null | undefined,
detected: readonly ContainerSourceId[] | null | undefined,
): ContainerSourceId | null;
export function reconcileContainerSource(
detected: readonly ContainerSourceId[] | null | undefined,
current: ContainerSourceId | null | undefined,
preference: { source?: string; engine?: string } | null | undefined,
): ContainerSourceId | null;
export function isAutomaticRuntimeFallback(
preference: { source?: string; engine?: string } | null | undefined,
source: ContainerSourceId | null | undefined,
detected: readonly ContainerSourceId[] | null | undefined,
): boolean;
export function firstContainerRuntime(
detected: readonly ContainerSourceId[] | null | undefined,
): ContainerRuntimeId | null;
export function isRuntimeSocketPermissionError(
runtime: unknown,
result: { stderr?: string; stdout?: string; error?: string } | null | undefined,
): boolean;
export function sanitizeContainerId(id: string): string;
export function buildContainerExecShellCommand(runtime: unknown, containerId: string, shell?: string): string;
export function buildContainerLogsCommand(runtime: unknown, containerId: string, shell?: string): string;
export function buildKubectlContextsCommand(): string;
export function buildKubectlNamespacesCommand(context: string, shell?: string): string | null;
export function buildKubectlPodsCommand(context: string, namespace: string, shell?: string): string | null;
export function buildKubectlDeletePodCommand(context: string, namespace: string, pod: string, shell?: string): string | null;
export type PopupShell = "posix" | "powershell" | "cmd";
export function isKubectlForbiddenError(message: string | null | undefined): boolean;
export function isKubectlUnreachableError(message: string | null | undefined): boolean;
export function isNerdctlUnreachableError(message: string | null | undefined): boolean;
export function podStatusMessageKey(status: string | null | undefined): string | null;
export function runtimeFailureMessage(
errorCode: string | null | undefined,
context?: string | null,
): { key: string; values?: Record<string, string> } | null;
export function buildKubectlExecShellCommand(
context: string,
namespace: string,
pod: string,
container?: string,
shell?: string,
): string;
export function buildKubectlLogsCommand(
context: string,
namespace: string,
pod: string,
container?: string,
shell?: string,
): string;
export function buildKubectlDescribeCommand(
context: string,
namespace: string,
pod: string,
shell?: string,
): string;
export function parseKubeconfigContexts(jsonText: string): { contexts: KubeContextInfo[]; current: string };
export function parseNamespaceList(jsonText: string): string[];
export function parseKubectlPodList(jsonText: string, now?: number): KubectlPodInfo[];
export function formatPodAge(iso: string, now?: number): string;
@@ -0,0 +1,486 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildContainerExecShellCommand,
buildContainerLogsCommand,
buildKubectlDeletePodCommand,
buildKubectlDescribeCommand,
buildKubectlExecShellCommand,
buildKubectlLogsCommand,
buildKubectlNamespacesCommand,
buildKubectlPodsCommand,
buildRuntimeCommand,
firstContainerRuntime,
formatPodAge,
isRuntimeSocketPermissionError,
parseKubeconfigContexts,
parseKubectlPodList,
parseNamespaceList,
parseRuntimeProbe,
isAutomaticRuntimeFallback,
pickContainerSource,
reconcileContainerSource,
rememberedContainerSource,
sourcesFromCapabilities,
isKubectlForbiddenError,
isKubectlUnreachableError,
isNerdctlUnreachableError,
podStatusMessageKey,
runtimeFailureMessage,
} = require("./containerRuntime.shared.cjs");
test("parseRuntimeProbe reads line-anchored command -v markers", () => {
const probe = parseRuntimeProbe([
"command -v docker >/dev/null && printf __NC_DOCKER__=1",
"__NC_OS__=Linux",
"__NC_DOCKER__=1",
"__NC_PODMAN__=1",
"echo __NC_NERDCTL__=1 trailing",
"__NC_KUBECTL__=1",
].join("\n"));
assert.deepEqual(probe, {
docker: true,
podman: true,
nerdctl: false,
kubernetes: true,
});
assert.deepEqual(sourcesFromCapabilities(probe), ["docker", "podman", "kubernetes"]);
});
test("sourcesFromCapabilities keeps a stable runtime order and ignores a docker-only host's missing peers", () => {
assert.deepEqual(sourcesFromCapabilities({ hasDocker: true }), ["docker"]);
assert.deepEqual(
sourcesFromCapabilities({ hasNerdctl: true, hasKubectl: true, hasPodman: true }),
["podman", "nerdctl", "kubernetes"],
);
assert.deepEqual(sourcesFromCapabilities(undefined), []);
});
test("pickContainerSource remembers the last choice per host and falls back when it disappears", () => {
assert.equal(
pickContainerSource(["docker", "podman", "kubernetes"], null, "podman"),
"podman",
);
assert.equal(
pickContainerSource(["docker", "podman"], "kubernetes", "podman"),
"podman",
);
assert.equal(
pickContainerSource(["docker", "podman", "kubernetes"], "kubernetes", "docker"),
"kubernetes",
);
assert.equal(pickContainerSource(["nerdctl"], null, "docker"), "nerdctl");
assert.equal(
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman"]),
"podman",
);
assert.equal(
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman", "kubernetes"]),
"kubernetes",
);
assert.equal(
pickContainerSource(
["docker", "podman"],
"kubernetes",
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman"]),
),
"podman",
);
assert.equal(firstContainerRuntime(["kubernetes", "podman"]), "podman");
assert.equal(firstContainerRuntime(["kubernetes"]), null);
});
test("reconcileContainerSource restores a runtime after a probe gap and does not treat the fallback as the choice", () => {
const nerdctl = { source: "nerdctl", engine: "nerdctl" };
assert.equal(reconcileContainerSource(["docker", "podman"], "nerdctl", nerdctl), "docker");
assert.equal(isAutomaticRuntimeFallback(nerdctl, "docker", ["docker", "podman"]), true);
assert.equal(
reconcileContainerSource(["docker", "podman", "nerdctl"], "docker", nerdctl),
"nerdctl",
);
assert.equal(isAutomaticRuntimeFallback(nerdctl, "podman", ["docker", "podman"]), false);
const kubernetes = { source: "kubernetes", engine: "podman" };
assert.equal(reconcileContainerSource(["docker", "podman"], "kubernetes", kubernetes), "podman");
assert.equal(isAutomaticRuntimeFallback(kubernetes, "podman", ["docker", "podman"]), true);
assert.equal(
reconcileContainerSource(["docker", "podman", "kubernetes"], "podman", kubernetes),
"kubernetes",
);
});
test("buildRuntimeCommand uses the detected CLI and defaults unknown runtimes to docker", () => {
assert.equal(buildRuntimeCommand("docker", "ps -a --format '{{json .}}'"), "docker ps -a --format '{{json .}}'");
assert.equal(buildRuntimeCommand("podman", "start abc123"), "podman start abc123");
assert.equal(buildRuntimeCommand("nerdctl", "logs -f --tail 200 abc123"), "nerdctl logs -f --tail 200 abc123");
assert.equal(buildRuntimeCommand("kubernetes", "ps"), "docker ps");
assert.equal(buildRuntimeCommand(undefined, "inspect abc"), "docker inspect abc");
});
test("interactive container commands keep the docker sudo probe and swap the binary", () => {
const dockerShell = buildContainerExecShellCommand("docker", "587abcdef123");
assert.match(dockerShell, /^sh -c /);
assert.match(dockerShell, /docker inspect 587abcdef123/);
assert.match(dockerShell, /exec docker exec -it 587abcdef123/);
assert.match(dockerShell, /exec sudo docker exec -it 587abcdef123/);
assert.match(dockerShell, /permission\\ denied.*docker\.sock.*docker\.sock.*permission\\ denied/);
assert.equal(dockerShell.includes("\n"), false);
assert.doesNotMatch(dockerShell, /sudo -S/);
const podmanLogs = buildContainerLogsCommand("podman", "587abcdef123");
assert.match(podmanLogs, /podman inspect 587abcdef123/);
assert.match(podmanLogs, /exec podman logs -f --tail 200 587abcdef123/);
assert.match(podmanLogs, /exec sudo podman logs -f --tail 200 587abcdef123/);
assert.match(podmanLogs, /podman\.sock/);
assert.doesNotMatch(podmanLogs, /docker/);
const nerdctlShell = buildContainerExecShellCommand("nerdctl", "abc");
assert.match(nerdctlShell, /exec nerdctl exec -it abc/);
assert.match(nerdctlShell, /exec sudo nerdctl exec -it abc/);
assert.match(nerdctlShell, /containerd\.sock/);
});
test("socket permission fallback is specific to each runtime", () => {
assert.equal(
isRuntimeSocketPermissionError("docker", { stderr: "dial unix /var/run/docker.sock: connect: permission denied" }),
true,
);
assert.equal(
isRuntimeSocketPermissionError("docker", { stderr: "permission denied" }),
false,
);
assert.equal(
isRuntimeSocketPermissionError("podman", { stderr: "dial unix /run/podman/podman.sock: connect: permission denied" }),
true,
);
assert.equal(
isRuntimeSocketPermissionError("nerdctl", { stderr: "dial unix /run/containerd/containerd.sock: connect: permission denied" }),
true,
);
assert.equal(
isRuntimeSocketPermissionError("podman", { stderr: "dial unix /var/run/docker.sock: connect: permission denied" }),
false,
);
});
test("parseKubeconfigContexts returns names and namespaces without user material", () => {
const parsed = parseKubeconfigContexts(JSON.stringify({
"current-context": "kind-kind",
contexts: [
{
name: "kind-kind",
context: { cluster: "kind-kind", user: "kind-kind", namespace: "app" },
},
{
name: "arn:aws:eks:us-west-2:123:cluster/prod",
context: { cluster: "prod", user: "admin" },
},
],
users: [{ name: "admin", user: { token: "secret-token" } }],
}));
assert.equal(parsed.current, "kind-kind");
assert.deepEqual(parsed.contexts, [
{ name: "kind-kind", namespace: "app", current: true },
{ name: "arn:aws:eks:us-west-2:123:cluster/prod", namespace: "default", current: false },
]);
assert.equal(JSON.stringify(parsed).includes("secret-token"), false);
});
test("parseNamespaceList skips a bracketed banner before the JSON object", () => {
const names = parseNamespaceList([
"[WARN] connection is using an insecure certificate",
'{"items":[{"metadata":{"name":"default"}}]}',
].join("\n"));
assert.deepEqual(names, ["default"]);
});
test("parseNamespaceList reads metadata names and ignores kubectl warnings before the JSON", () => {
const stdout = "Warning: old client\n" + JSON.stringify({
items: [
{ metadata: { name: "default" } },
{ metadata: { name: "kube-system" } },
{ metadata: {} },
],
});
assert.deepEqual(parseNamespaceList(stdout), ["default", "kube-system"]);
});
test("parseKubectlPodList reports status, restarts, age, and containers", () => {
const now = Date.parse("2026-10-20T00:10:00Z");
const pods = parseKubectlPodList(JSON.stringify({
items: [
{
metadata: {
name: "api",
namespace: "default",
creationTimestamp: "2026-10-20T00:08:30Z",
},
spec: {
containers: [{ name: "app" }, { name: "sidecar" }],
initContainers: [{ name: "init" }],
},
status: {
phase: "Running",
initContainerStatuses: [
{ name: "init", restartCount: 0, state: { terminated: { reason: "Completed" } } },
],
containerStatuses: [
{ name: "app", restartCount: 4, state: { waiting: { reason: "CrashLoopBackOff" } } },
{ name: "sidecar", restartCount: 1, state: { running: {} } },
],
},
},
{
metadata: {
name: "batch",
namespace: "jobs",
creationTimestamp: "2026-10-18T00:10:00Z",
},
spec: { containers: [{ name: "worker" }] },
status: {
phase: "Succeeded",
containerStatuses: [
{ name: "worker", restartCount: 0, state: { terminated: { reason: "Completed" } } },
],
},
},
],
}), now);
assert.deepEqual(pods, [
{
name: "api",
namespace: "default",
status: "CrashLoopBackOff",
restarts: 5,
age: "1m",
containers: ["app", "sidecar", "init"],
shellContainers: ["sidecar"],
},
{
name: "batch",
namespace: "jobs",
status: "Completed",
restarts: 0,
age: "2d",
containers: ["worker"],
shellContainers: [],
},
]);
const terminating = parseKubectlPodList(JSON.stringify({
items: [{
metadata: {
name: "api",
namespace: "default",
creationTimestamp: "2026-10-20T00:08:30Z",
deletionTimestamp: "2026-10-20T00:10:00Z",
},
spec: {
containers: [{ name: "app" }],
ephemeralContainers: [{ name: "debugger" }, { name: "old-debug" }],
},
status: {
phase: "Running",
containerStatuses: [
{ name: "app", restartCount: 0, state: { running: {} } },
],
ephemeralContainerStatuses: [
{ name: "debugger", restartCount: 0, state: { running: {} } },
{ name: "old-debug", restartCount: 0, state: { terminated: { reason: "Completed" } } },
],
},
}],
}), now);
const evicted = parseKubectlPodList(JSON.stringify({
items: [{
metadata: { name: "api", namespace: "default", creationTimestamp: "2026-10-20T00:08:30Z" },
spec: { containers: [{ name: "app" }] },
status: {
phase: "Failed",
reason: "Evicted",
message: "The node was low on resource: memory.",
containerStatuses: [
{ name: "app", restartCount: 0, state: { terminated: { reason: "Error", exitCode: 137 } } },
],
},
}],
}), now);
assert.equal(evicted[0].status, "Evicted");
assert.equal(terminating[0].status, "Terminating");
assert.deepEqual(terminating[0].containers, ["app", "debugger", "old-debug"]);
assert.deepEqual(terminating[0].shellContainers, ["app", "debugger"]);
assert.equal(formatPodAge("2026-10-20T00:09:10Z", now), "50s");
assert.equal(formatPodAge("2026-10-20T00:07:00Z", now), "3m");
assert.equal(formatPodAge("2026-10-19T22:10:00Z", now), "2h");
});
test("kubectl commands quote remote arguments and reject control characters", () => {
assert.equal(
buildKubectlPodsCommand("kind-kind", "default"),
"kubectl --context 'kind-kind' -n 'default' get pods -o json",
);
assert.equal(
buildKubectlNamespacesCommand("arn:aws:eks:us-west-2:123:cluster/prod"),
"kubectl --context 'arn:aws:eks:us-west-2:123:cluster/prod' get namespaces -o json",
);
assert.equal(
buildKubectlDeletePodCommand("ctx", "kube-system", "coredns-a"),
"kubectl --context 'ctx' -n 'kube-system' delete pod 'coredns-a' --wait=false",
);
assert.equal(buildKubectlDeletePodCommand("ctx", "default", "bad\nname"), null);
assert.equal(buildKubectlPodsCommand("ok", "bad\nns"), null);
assert.equal(
buildKubectlNamespacesCommand("o'brien", "powershell"),
"kubectl --context 'o''brien' get namespaces -o json",
);
assert.equal(
buildKubectlPodsCommand("o'brien", "default", "powershell"),
"kubectl --context 'o''brien' -n 'default' get pods -o json",
);
assert.match(
buildKubectlDeletePodCommand("o'brien", "default", "web", "cmd"),
/--context "o'brien"/,
);
const quoted = buildKubectlDeletePodCommand("o'brien", "default", "web");
assert.match(quoted, /--context 'o'"'"'brien'/);
assert.doesNotMatch(quoted, /--context 'o'brien'/);
const shell = buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar");
assert.match(shell, /^sh -c /);
assert.match(shell, /kubectl/);
assert.match(shell, /--context/);
assert.match(shell, /kind-kind/);
assert.match(shell, /-n/);
assert.match(shell, /default/);
assert.match(shell, /exec -it/);
assert.match(shell, /api/);
assert.match(shell, /-c/);
assert.match(shell, /sidecar/);
assert.match(shell, /command -v bash/);
assert.equal(shell.includes("\n"), false);
const logs = buildKubectlLogsCommand("kind-kind", "default", "api", "app");
assert.match(logs, /kubectl/);
assert.match(logs, /logs -f --tail 200/);
assert.match(logs, /kind-kind/);
assert.match(logs, /app/);
const described = buildKubectlDescribeCommand("kind-kind", "default", "api");
assert.match(described, /kubectl/);
assert.match(described, /describe pod/);
assert.match(described, /kind-kind/);
assert.match(described, /api/);
assert.equal(buildKubectlExecShellCommand("kind-kind", "default", "bad\npod"), 'echo "Invalid pod name"');
});
test("kubectl popup commands skip the POSIX wrapper on Windows shells", () => {
for (const shell of ["powershell", "cmd"]) {
const exec = buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar", shell);
const logs = buildKubectlLogsCommand("kind-kind", "default", "api", "app", shell);
const described = buildKubectlDescribeCommand("kind-kind", "default", "api", shell);
assert.match(exec, /kubectl --context/, shell);
assert.match(logs, /kubectl --context/, shell);
assert.match(described, /kubectl --context/, shell);
assert.match(exec, /__NC_POPUP__/, shell);
assert.equal(exec.startsWith("sh -c "), false, shell);
assert.match(exec, /exec -it/);
assert.match(exec, /sidecar/);
assert.match(logs, /logs -f --tail 200/);
assert.match(described, /describe pod/);
}
assert.match(
buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar", "powershell"),
/--context 'kind-kind'/,
);
assert.match(
buildKubectlDescribeCommand("kind-kind", "default", "api", "cmd"),
/--context "kind-kind"/,
);
assert.equal(
buildKubectlDescribeCommand("prod-%USERNAME%", "default", "api", "cmd"),
'echo "Invalid context"',
);
assert.equal(
buildKubectlDescribeCommand("prod!USER!", "default", "api", "cmd"),
'echo "Invalid context"',
);
assert.match(
buildKubectlDescribeCommand("prod-%USERNAME%", "default", "api", "powershell"),
/--context 'prod-%USERNAME%'/,
);
assert.equal(
buildKubectlNamespacesCommand("prod-%USERNAME%", "powershell"),
"kubectl --context 'prod-%USERNAME%' get namespaces -o json",
);
});
test("container popup commands inspect in the host shell on Windows", () => {
const powershellLogs = buildContainerLogsCommand("podman", "abc123", "powershell");
assert.equal(powershellLogs.startsWith("sh -c "), false);
assert.match(powershellLogs, /\$__nc_popup='__NC_POPUP__'; podman inspect abc123 >\$null;/);
assert.match(powershellLogs, /podman logs -f --tail 200 abc123/);
const cmdShell = buildContainerExecShellCommand("nerdctl", "abc123", "cmd");
assert.equal(cmdShell.startsWith("sh -c "), false);
assert.match(cmdShell, /^set "__NC_POPUP__=1"& nerdctl inspect abc123 >nul && nerdctl exec -it abc123/);
assert.match(cmdShell, /sh -c "command -v bash/);
});
test("isKubectlForbiddenError matches namespace list denial", () => {
assert.equal(
isKubectlForbiddenError('Error from server (Forbidden): namespaces is forbidden: User "view" cannot list resource "namespaces"'),
true,
);
assert.equal(isKubectlForbiddenError("Unable to connect to the server"), false);
});
test("unreachable kubectl API text is classified without treating Forbidden as unreachable", () => {
const spam = [
'memcache.go:381] "Couldn\'t get current server API group list"',
'dial tcp 127.0.0.1:6443: connect: connection refused',
].join("\n");
assert.equal(isKubectlUnreachableError(spam), true);
assert.equal(isKubectlUnreachableError("Unable to connect to the server: dial tcp 10.0.0.1:6443: i/o timeout"), true);
assert.equal(
isKubectlUnreachableError('Error from server (Forbidden): pods is forbidden'),
false,
);
assert.equal(isKubectlUnreachableError(""), false);
});
test("nerdctl containerd failures are classified separately from socket permission errors", () => {
assert.equal(
isNerdctlUnreachableError('time="2026-10-20T18:31:27+08:00" level=fatal msg="rootless containerd not running? (hint: use containerd-rootless-setuptool.sh install to start rootless containerd): environment variable XDG_RUNTIME_DIR is not set"'),
true,
);
assert.equal(
isNerdctlUnreachableError("permission denied while trying to connect to the containerd socket"),
false,
);
assert.equal(isNerdctlUnreachableError("Error: no such container: abc"), false);
});
test("runtime failures map to localized message keys and pod badges use known status keys", () => {
assert.deepEqual(runtimeFailureMessage("nerdctlUnreachable"), {
key: "systemManager.errors.nerdctlUnreachable",
});
assert.deepEqual(runtimeFailureMessage("clusterUnreachable", "broken-cluster"), {
key: "systemManager.errors.clusterUnreachable",
values: { context: "broken-cluster" },
});
assert.deepEqual(runtimeFailureMessage("clusterUnreachable", " "), {
key: "systemManager.errors.clusterUnreachableGeneric",
});
assert.equal(runtimeFailureMessage("other"), null);
assert.equal(podStatusMessageKey("Running"), "systemManager.k8s.status.Running");
assert.equal(podStatusMessageKey("CrashLoopBackOff"), "systemManager.k8s.status.CrashLoopBackOff");
assert.equal(podStatusMessageKey("Evicted"), "systemManager.k8s.status.Evicted");
assert.equal(podStatusMessageKey("Init:0/1"), null);
});
+38
View File
@@ -0,0 +1,38 @@
import { runtimeFailureMessage } from "./containerRuntime.shared.cjs";
export type {
ContainerRuntimeId,
ContainerSourceId,
KubeContextInfo,
KubectlPodInfo,
RuntimeProbeFlags,
} from "./containerRuntime.shared.cjs";
export {
NETCATTY_POPUP_HISTORY_MARKER,
buildContainerExecShellCommand,
buildContainerLogsCommand,
buildKubectlDescribeCommand,
buildKubectlExecShellCommand,
buildKubectlLogsCommand,
firstContainerRuntime,
isContainerSource,
isKubectlForbiddenError,
isAutomaticRuntimeFallback,
pickContainerSource,
podStatusMessageKey,
reconcileContainerSource,
rememberedContainerSource,
sourcesFromCapabilities,
} from "./containerRuntime.shared.cjs";
export function describeRuntimeFailure(
result: { error?: string; errorCode?: string } | null | undefined,
translate: (key: string, values?: Record<string, string>) => string,
fallback: string,
context?: string | null,
): string {
const mapped = runtimeFailureMessage(result?.errorCode, context);
if (mapped) return translate(mapped.key, mapped.values);
return result?.error || fallback;
}
+8 -42
View File
@@ -1,52 +1,18 @@
import {
buildContainerExecShellCommand,
buildContainerLogsCommand,
} from "./containerRuntime.ts";
/** Sanitize Docker container/image IDs — must match electron/bridges/systemManager/dockerOps.cjs */
export function sanitizeDockerContainerId(id: string): string {
return String(id || '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 64);
}
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
function shQuote(value: string): string {
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
}
function buildDockerCommandWithSudoFallback(containerId: string, dockerArgs: string): string {
const plainCommand = `docker ${dockerArgs}`;
const sudoCommand = `sudo ${plainCommand}`;
const script = [
CLEAR_STARTUP_OUTPUT,
`_nc_docker_err=$(docker inspect ${containerId} 2>&1 >/dev/null);`,
'_nc_docker_status=$?;',
`if [ "$_nc_docker_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
'_nc_docker_lc=$(printf \'%s\' "$_nc_docker_err" | tr \'[:upper:]\' \'[:lower:]\');',
'case "$_nc_docker_lc" in',
[
'*permission\\ denied*docker\\ daemon*',
'*docker\\ daemon*permission\\ denied*',
'*permission\\ denied*docker.sock*',
'*docker.sock*permission\\ denied*',
'*permission\\ denied*/var/run/docker.sock*',
'*/var/run/docker.sock*permission\\ denied*',
'*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*',
'*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*',
].join('|') + `) exec ${sudoCommand} ;;`,
'*) printf \'%s\\n\' "$_nc_docker_err" >&2; exit "$_nc_docker_status" ;;',
'esac',
].join(' ');
return `sh -c ${shQuote(script)}`;
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
}
/** Interactive shell into a container — prefer bash, fall back to sh. */
export function buildDockerExecShellCommand(containerId: string): string {
const safeId = sanitizeDockerContainerId(containerId);
if (!safeId) return 'echo "Invalid container id"';
return buildDockerCommandWithSudoFallback(
safeId,
`exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`,
);
return buildContainerExecShellCommand("docker", containerId);
}
export function buildDockerLogsCommand(containerId: string): string {
const safeId = sanitizeDockerContainerId(containerId);
if (!safeId) return 'echo "Invalid container id"';
return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`);
return buildContainerLogsCommand("docker", containerId);
}
+18
View File
@@ -4,6 +4,7 @@ import test from "node:test";
import {
allowSystemManagerMutations,
buildSystemManagerTabs,
hasContainerSource,
shouldCollectServerStats,
shouldShowGpuTab,
shouldShowPortsTab,
@@ -57,6 +58,23 @@ test("gpu tab appears only after nvidia-smi or npu-smi is detected", () => {
);
});
test("the single container tab appears for podman, nerdctl, or kubectl without a docker binary", () => {
const networkHost = {
id: "host-podman",
label: "Podman",
hostname: "podman.local",
username: "root",
tags: [],
os: "linux" as const,
deviceType: "network" as const,
};
assert.equal(hasContainerSource(caps()), false);
assert.equal(hasContainerSource(caps({ hasPodman: true })), true);
assert.equal(buildSystemManagerTabs(networkHost, caps({ hasPodman: true }), null).includes("docker"), true);
assert.equal(buildSystemManagerTabs(networkHost, caps({ hasKubectl: true }), null).includes("docker"), true);
assert.equal(buildSystemManagerTabs(networkHost, caps(), null).includes("docker"), false);
});
test("ports and services tabs are detect-first like GPU", () => {
assert.equal(shouldShowPortsTab(undefined), false);
assert.equal(shouldShowPortsTab(caps({ hasSs: true })), true);
+8 -1
View File
@@ -44,12 +44,19 @@ export function shouldShowTmuxTab(
return false;
}
export function hasContainerSource(capabilities: SessionCapabilities | undefined): boolean {
return capabilities?.hasDocker === true
|| capabilities?.hasPodman === true
|| capabilities?.hasNerdctl === true
|| capabilities?.hasKubectl === true;
}
export function shouldShowDockerTab(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
session: TerminalSession | null | undefined,
): boolean {
if (capabilities?.hasDocker === true) return true;
if (hasContainerSource(capabilities)) return true;
// Network appliances: never show Docker from an OS guess alone.
if (isNetworkDeviceTarget(host)) return false;
return isDefiniteLinuxTarget(host, capabilities, session);
+12 -1
View File
@@ -4,6 +4,12 @@ export interface SessionCapabilities {
targetOs: TargetOs;
hasTmux: boolean;
hasDocker: boolean;
/** `podman` binary present. A podman-docker shim may also set hasDocker. */
hasPodman?: boolean;
/** `nerdctl` binary present. */
hasNerdctl?: boolean;
/** `kubectl` binary present on the remote host. */
hasKubectl?: boolean;
hasNvidiaSmi: boolean;
hasNpuSmi: boolean;
/** `ss` binary present (preferred listening-port collector). */
@@ -194,7 +200,7 @@ export type DockerContainerAction =
export type DockerImageManageAction =
| { action: 'pull'; imageRef: string }
| { action: 'rm'; imageId: string; force?: boolean }
| { action: 'rm'; imageId: string; force?: boolean; repository?: string; tag?: string }
| { action: 'prune'; all?: boolean }
| { action: 'tag'; imageId: string; repository: string; tag?: string };
@@ -221,6 +227,11 @@ export interface TerminalPopupPayload {
parentSessionId: string;
sourceSession: import('../../types').TerminalSession;
startupCommand: string;
/**
* System-manager panel rectangle in renderer viewport pixels. The popup is
* placed in the remaining source-window area so it does not cover this panel.
*/
avoidClientRect?: { x: number; y: number; width: number; height: number };
localShellType?: import('../../types').TerminalSession['shellType'];
/**
* When set, the popup attaches to this already-running backend session
+1
View File
@@ -70,6 +70,7 @@ module.exports = {
'domain/terminalPromptSecurity.shared.cjs',
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
'domain/systemManager/containerRuntime.shared.cjs',
'domain/singleChannelSshBanner.shared.cjs',
'domain/singleChannelSshBanner.shared.mjs',
'domain/sshServerVendor.mjs',
+303 -108
View File
@@ -27,100 +27,279 @@ function dockerCommandError(result, fallback) {
return (result?.stderr || result?.error || "").trim() || fallback;
}
function isDockerSocketPermissionError(result) {
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
if (!text.includes("permission denied")) return false;
return text.includes("docker daemon")
|| text.includes("docker.sock")
|| text.includes("/var/run/docker.sock")
|| text.includes("connect to the docker daemon");
}
function getSessionSudoPassword(session) {
return typeof session?.systemManagerSudoPassword === "string" && session.systemManagerSudoPassword.length > 0
? session.systemManagerSudoPassword
: null;
}
function buildDockerCommand(args) {
return `docker ${args}`.trim();
const {
buildRuntimeCommand,
isNerdctlUnreachableError,
isRuntimeSocketPermissionError,
normalizeContainerRuntime,
runtimeBinary,
} = require("../../../domain/systemManager/containerRuntime.shared.cjs");
function userFacingRuntimeFailure(runtime, result, fallback) {
const raw = dockerCommandError(result, fallback);
if (runtime === "nerdctl" && isNerdctlUnreachableError(raw)) {
console.error(`[netcatty] nerdctl: ${raw}`);
return { success: false, errorCode: "nerdctlUnreachable" };
}
return {
success: false,
error: raw,
stderr: result?.stderr,
};
}
function buildPasswordlessSudoDockerCommand(args) {
return `sudo ${buildDockerCommand(args)}`;
function failurePayload(result) {
const payload = { success: false };
if (typeof result?.error === "string" && result.error) payload.error = result.error;
if (typeof result?.errorCode === "string" && result.errorCode) payload.errorCode = result.errorCode;
if (!payload.error && !payload.errorCode) payload.error = "command failed";
return payload;
}
function buildSudoDockerCommand(args) {
return `sudo -S -p '' ${buildDockerCommand(args)}`;
}
function parseDockerContainers(stdout) {
const containers = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
function parseJsonRecords(stdout) {
const raw = String(stdout || "").trim();
if (!raw) return [];
if (raw.startsWith("[") || raw.startsWith("{")) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed.filter((row) => row && typeof row === "object");
if (parsed && typeof parsed === "object") return [parsed];
} catch {
// Fall through to JSON lines when the CLI emits one object per line.
}
}
const rows = [];
for (const line of raw.split("\n")) {
const trimmed = line.trim().replace(/,$/, "");
if (!trimmed || trimmed === "[" || trimmed === "]") continue;
try {
const row = JSON.parse(trimmed);
containers.push({
id: row.ID || row.Id || "",
name: (row.Names || row.Name || "").replace(/^\//, ""),
image: row.Image || "",
status: row.Status || row.State || "",
state: row.State || "",
ports: row.Ports || "",
createdAt: row.CreatedAt || row.Created || "",
});
if (Array.isArray(row)) rows.push(...row.filter((item) => item && typeof item === "object"));
else if (row && typeof row === "object") rows.push(row);
} catch {
// skip malformed line
}
}
return containers;
return rows;
}
function portInRange(value) {
const port = Number(value);
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
}
function formatHostBind(ip, hostLabel) {
const bind = ip || "0.0.0.0";
const host = bind.includes(":") && !bind.startsWith("[") ? `[${bind}]` : bind;
return `${host}:${hostLabel}`;
}
function formatPortMapping(mapping) {
if (!mapping || typeof mapping !== "object") return [];
const containerPort = portInRange(mapping.container_port ?? mapping.containerPort ?? mapping.ContainerPort ?? mapping.PrivatePort);
if (containerPort === null) return [];
const hostPort = portInRange(mapping.host_port ?? mapping.hostPort ?? mapping.HostPort ?? mapping.PublicPort);
const requestedRange = Number(mapping.range ?? mapping.Range);
const range = Number.isInteger(requestedRange) && requestedRange > 1 ? requestedRange : 1;
if (containerPort + range - 1 > 65535) return [];
if (hostPort !== null && hostPort + range - 1 > 65535) return [];
const hostIp = String(mapping.host_ip ?? mapping.hostIP ?? mapping.HostIP ?? mapping.HostIp ?? mapping.IP ?? "").trim();
const protocols = String(mapping.protocol ?? mapping.Protocol ?? mapping.Type ?? "tcp")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const protocolList = protocols.length > 0 ? protocols : ["tcp"];
const containerLabel = range > 1 ? `${containerPort}-${containerPort + range - 1}` : String(containerPort);
const hostLabel = hostPort === null
? ""
: (range > 1 ? `${hostPort}-${hostPort + range - 1}` : String(hostPort));
return protocolList.map((protocol) => {
const keys = [];
for (let offset = 0; offset < range; offset += 1) keys.push(`${containerPort + offset}/${protocol}`);
const label = hostLabel
? `${formatHostBind(hostIp, hostLabel)}->${containerLabel}/${protocol}`
: `${containerLabel}/${protocol}`;
return { label, keys };
});
}
function formatExposedPorts(exposed, published) {
if (!exposed || typeof exposed !== "object" || Array.isArray(exposed)) return [];
const entries = [];
for (const [key, value] of Object.entries(exposed)) {
const slash = key.indexOf("/");
if (slash > 0) {
const port = portInRange(key.slice(0, slash));
const protocol = key.slice(slash + 1).trim() || "tcp";
if (port !== null) entries.push({ port, protocol });
continue;
}
const port = portInRange(key);
if (port === null) continue;
const protocols = Array.isArray(value) && value.length > 0 ? value : ["tcp"];
for (const protocol of protocols) {
const name = String(protocol || "tcp").trim();
if (name) entries.push({ port, protocol: name });
}
}
entries.sort((left, right) => left.port - right.port || left.protocol.localeCompare(right.protocol));
const labels = [];
for (const entry of entries) {
const label = `${entry.port}/${entry.protocol}`;
if (published.has(label) || labels.includes(label)) continue;
labels.push(label);
}
return labels;
}
function formatContainerPorts(row) {
const ports = row.Ports ?? row.ports;
if (typeof ports === "string") return ports;
const labels = [];
const published = new Set();
if (Array.isArray(ports)) {
for (const mapping of ports) {
for (const formatted of formatPortMapping(mapping)) {
if (labels.includes(formatted.label)) continue;
labels.push(formatted.label);
for (const key of formatted.keys) published.add(key);
}
}
}
labels.push(...formatExposedPorts(row.ExposedPorts ?? row.exposedPorts, published));
return labels.join(", ");
}
function parseDockerContainers(stdout) {
return parseJsonRecords(stdout).map((row) => ({
id: row.ID || row.Id || "",
name: String(Array.isArray(row.Names) ? (row.Names[0] || "") : (row.Names || row.Name || "")).replace(/^\//, ""),
image: row.Image || "",
status: row.Status || row.State || "",
state: row.State || "",
ports: formatContainerPorts(row),
createdAt: row.CreatedAt || row.Created || "",
})).filter((container) => container.id || container.name);
}
function parsePercent(value) {
if (typeof value === "number" && Number.isFinite(value)) return value;
return parseFloat(String(value || "0").replace("%", "")) || 0;
}
function sumNetworkBytes(network) {
if (!network || typeof network !== "object" || Array.isArray(network)) return null;
let rx = 0;
let tx = 0;
let saw = false;
for (const stats of Object.values(network)) {
if (!stats || typeof stats !== "object") continue;
const rxBytes = stats.RxBytes ?? stats.rx_bytes;
const txBytes = stats.TxBytes ?? stats.tx_bytes;
if (typeof rxBytes === "number" && Number.isFinite(rxBytes)) {
rx += rxBytes;
saw = true;
}
if (typeof txBytes === "number" && Number.isFinite(txBytes)) {
tx += txBytes;
saw = true;
}
}
return saw ? { rx, tx } : null;
}
function parseDockerStats(stdout) {
const stats = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const row = JSON.parse(trimmed);
stats.push({
id: row.ID || row.Container || "",
name: row.Name || "",
cpuPercent: parseFloat(String(row.CPUPerc || "0").replace("%", "")) || 0,
memUsage: row.MemUsage || "",
memPercent: parseFloat(String(row.MemPerc || "0").replace("%", "")) || 0,
netIO: row.NetIO || "",
blockIO: row.BlockIO || "",
pids: Number(row.PIDs || row.Pids || 0) || 0,
});
} catch {
// skip
}
return parseJsonRecords(stdout).map((row) => {
const memUsage = typeof row.MemUsage === "number" && typeof row.MemLimit === "number"
? `${formatImageSize(row.MemUsage)} / ${formatImageSize(row.MemLimit)}`
: String(row.MemUsage || row.mem_usage || "");
const networkTotals = sumNetworkBytes(row.Network || row.network);
const netIO = row.NetIO || row.net_io || row.netio
|| (networkTotals
? `${formatImageSize(networkTotals.rx)} / ${formatImageSize(networkTotals.tx)}`
: (typeof row.NetInput === "number" && typeof row.NetOutput === "number"
? `${formatImageSize(row.NetInput)} / ${formatImageSize(row.NetOutput)}`
: ""));
const blockIO = row.BlockIO || row.block_io
|| (typeof row.BlockInput === "number" && typeof row.BlockOutput === "number"
? `${formatImageSize(row.BlockInput)} / ${formatImageSize(row.BlockOutput)}`
: "");
return {
id: String(row.ID || row.Id || row.id || row.ContainerID || row.Container || ""),
name: String(row.Name || row.name || ""),
cpuPercent: parsePercent(row.CPUPerc ?? row.cpu_percent ?? row.CPU),
memUsage,
memPercent: parsePercent(row.MemPerc ?? row.mem_percent),
netIO: String(netIO),
blockIO: String(blockIO),
pids: Number(row.PIDs || row.Pids || row.pids || 0) || 0,
};
}).filter((stat) => stat.id || stat.name);
}
function splitImageName(name) {
const text = String(name || "");
const slash = text.lastIndexOf("/");
const colon = text.lastIndexOf(":");
if (colon > slash) {
return { repository: text.slice(0, colon), tag: text.slice(colon + 1) };
}
return stats;
return { repository: text, tag: "" };
}
function imageAliases(row) {
if (row.Repository || row.Tag) {
return [{ repository: String(row.Repository || ""), tag: String(row.Tag || "") }];
}
const names = Array.isArray(row.Names) && row.Names.length > 0
? row.Names
: (Array.isArray(row.RepoTags) ? row.RepoTags : []);
const aliases = names.filter(Boolean).map((name) => splitImageName(name));
return aliases.length > 0 ? aliases : [{ repository: "", tag: "" }];
}
function formatImageSize(size) {
if (typeof size !== "number" || !Number.isFinite(size)) return String(size || "");
const units = ["B", "KB", "MB", "GB", "TB"];
let value = size;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
const rounded = unit === 0 || value >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
return `${rounded}${units[unit]}`;
}
function formatImageCreated(row) {
if (row.CreatedAt) return String(row.CreatedAt);
if (row.CreatedSince) return String(row.CreatedSince);
if (typeof row.Created === "number" && Number.isFinite(row.Created)) {
const ms = row.Created > 1e12 ? row.Created : row.Created * 1000;
return new Date(ms).toISOString();
}
if (typeof row.Created === "string") return row.Created;
return "";
}
function parseDockerImages(stdout) {
const images = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const row = JSON.parse(trimmed);
const repository = row.Repository || "";
const tag = row.Tag || "";
images.push({
id: row.ID || row.Id || "",
repository,
tag,
size: row.Size || "",
createdAt: row.CreatedAt || row.CreatedSince || "",
digest: row.Digest || "",
name: repository && tag ? `${repository}:${tag}` : repository || tag || row.ID || "",
});
} catch {
// skip
for (const row of parseJsonRecords(stdout)) {
const id = String(row.ID || row.Id || "");
const size = formatImageSize(row.Size);
const createdAt = formatImageCreated(row);
const digest = String(row.Digest || (Array.isArray(row.RepoDigests) ? (row.RepoDigests[0] || "") : ""));
for (const alias of imageAliases(row)) {
const { repository, tag } = alias;
const name = repository && tag ? `${repository}:${tag}` : repository || tag || id;
if (!id && !name) continue;
images.push({ id, repository, tag, size, createdAt, digest, name });
}
}
return images;
@@ -168,19 +347,21 @@ function summarizeContainerInspect(info) {
}
function createDockerOpsApi({ execOnSession, getSession }) {
async function runDocker(event, sessionId, args, timeoutMs = 15000) {
const cmd = buildDockerCommand(args);
async function runDocker(event, sessionId, args, timeoutMs = 15000, runtime) {
const safeRuntime = normalizeContainerRuntime(runtime);
const bin = runtimeBinary(safeRuntime);
const cmd = buildRuntimeCommand(safeRuntime, args);
const result = await execOnSession(event, sessionId, cmd, timeoutMs);
if (isSuccessfulCommandResult(result)) return result;
if (isDockerSocketPermissionError(result)) {
if (isRuntimeSocketPermissionError(safeRuntime, result)) {
const sudoPassword = getSessionSudoPassword(getSession?.(sessionId));
let lastSudoResult = null;
const nopasswdResult = await execOnSession(
event,
sessionId,
buildPasswordlessSudoDockerCommand(args),
`sudo ${cmd}`,
timeoutMs,
);
if (isSuccessfulCommandResult(nopasswdResult)) return nopasswdResult;
@@ -190,7 +371,7 @@ function createDockerOpsApi({ execOnSession, getSession }) {
const sudoResult = await execOnSession(
event,
sessionId,
buildSudoDockerCommand(args),
`sudo -S -p '' ${cmd}`,
timeoutMs,
{ stdin: `${sudoPassword}\n` },
);
@@ -198,33 +379,34 @@ function createDockerOpsApi({ execOnSession, getSession }) {
lastSudoResult = sudoResult;
}
return {
success: false,
error: dockerCommandError(lastSudoResult, `sudo docker exited with code ${lastSudoResult?.code}`),
stderr: lastSudoResult?.stderr,
};
return userFacingRuntimeFailure(
safeRuntime,
lastSudoResult,
`sudo ${bin} exited with code ${lastSudoResult?.code}`,
);
}
if (!result.success) return result;
if (!result.success) {
if (safeRuntime === "nerdctl" && isNerdctlUnreachableError(dockerCommandError(result, ""))) {
return userFacingRuntimeFailure(safeRuntime, result, `${bin} failed`);
}
return result;
}
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
return {
success: false,
error: dockerCommandError(result, `docker exited with code ${result.code}`),
stderr: result.stderr,
};
return userFacingRuntimeFailure(safeRuntime, result, `${bin} exited with code ${result.code}`);
}
return result;
}
async function listContainers(event, sessionId) {
const result = await runDocker(event, sessionId, "ps -a --format '{{json .}}'", 12000);
if (!result.success) return { success: false, error: result.error };
async function listContainers(event, sessionId, runtime) {
const result = await runDocker(event, sessionId, "ps -a --format '{{json .}}'", 12000, runtime);
if (!result.success) return failurePayload(result);
return { success: true, containers: parseDockerContainers(result.stdout) };
}
async function listImages(event, sessionId) {
const result = await runDocker(event, sessionId, "images --format '{{json .}}'", 12000);
if (!result.success) return { success: false, error: result.error };
async function listImages(event, sessionId, runtime) {
const result = await runDocker(event, sessionId, "images --format '{{json .}}'", 12000, runtime);
if (!result.success) return failurePayload(result);
return { success: true, images: parseDockerImages(result.stdout) };
}
@@ -238,8 +420,9 @@ function createDockerOpsApi({ execOnSession, getSession }) {
sessionId,
`stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
15000,
payload?.runtime,
);
if (!result.success) return { success: false, error: result.error };
if (!result.success) return failurePayload(result);
return { success: true, stats: parseDockerStats(result.stdout) };
}
@@ -247,8 +430,8 @@ function createDockerOpsApi({ execOnSession, getSession }) {
const { sessionId, containerId } = payload || {};
if (!sessionId || !containerId) return { success: false, error: "Missing params" };
const safeId = sanitizeDockerId(containerId);
const result = await runDocker(event, sessionId, `inspect ${safeId}`, 10000);
if (!result.success) return { success: false, error: result.error };
const result = await runDocker(event, sessionId, `inspect ${safeId}`, 10000, payload?.runtime);
if (!result.success) return failurePayload(result);
try {
const parsed = JSON.parse(result.stdout || "[]");
const info = Array.isArray(parsed) ? parsed[0] : parsed;
@@ -262,8 +445,8 @@ function createDockerOpsApi({ execOnSession, getSession }) {
const { sessionId, imageId } = payload || {};
if (!sessionId || !imageId) return { success: false, error: "Missing params" };
const safeId = sanitizeDockerId(imageId);
const result = await runDocker(event, sessionId, `image inspect ${safeId}`, 10000);
if (!result.success) return { success: false, error: result.error };
const result = await runDocker(event, sessionId, `image inspect ${safeId}`, 10000, payload?.runtime);
if (!result.success) return failurePayload(result);
try {
const parsed = JSON.parse(result.stdout || "[]");
const info = Array.isArray(parsed) ? parsed[0] : parsed;
@@ -278,25 +461,26 @@ function createDockerOpsApi({ execOnSession, getSession }) {
if (!sessionId || !containerId || !action) return { success: false, error: "Missing params" };
const safeId = sanitizeDockerId(containerId);
const runtime = payload?.runtime;
switch (action) {
case "start":
return runDocker(event, sessionId, `start ${safeId}`);
return runDocker(event, sessionId, `start ${safeId}`, 15000, runtime);
case "stop":
return runDocker(event, sessionId, `stop ${safeId}`);
return runDocker(event, sessionId, `stop ${safeId}`, 15000, runtime);
case "restart":
return runDocker(event, sessionId, `restart ${safeId}`);
return runDocker(event, sessionId, `restart ${safeId}`, 15000, runtime);
case "rm":
return runDocker(event, sessionId, `rm -f ${safeId}`);
return runDocker(event, sessionId, `rm -f ${safeId}`, 15000, runtime);
case "pause":
return runDocker(event, sessionId, `pause ${safeId}`);
return runDocker(event, sessionId, `pause ${safeId}`, 15000, runtime);
case "unpause":
return runDocker(event, sessionId, `unpause ${safeId}`);
return runDocker(event, sessionId, `unpause ${safeId}`, 15000, runtime);
case "kill":
return runDocker(event, sessionId, `kill ${safeId}`);
return runDocker(event, sessionId, `kill ${safeId}`, 15000, runtime);
case "rename": {
const next = sanitizeContainerName(newName);
if (!next) return { success: false, error: "Invalid container name" };
return runDocker(event, sessionId, `rename ${safeId} ${shQuote(next)}`);
return runDocker(event, sessionId, `rename ${safeId} ${shQuote(next)}`, 15000, runtime);
}
default:
return { success: false, error: `Invalid container action: ${action}` };
@@ -307,21 +491,30 @@ function createDockerOpsApi({ execOnSession, getSession }) {
const { sessionId, action, imageRef, imageId, force, all, repository, tag } = payload || {};
if (!sessionId || !action) return { success: false, error: "Missing params" };
const runtime = payload?.runtime;
switch (action) {
case "pull": {
const ref = sanitizeImageRef(imageRef);
if (!ref) return { success: false, error: "Missing image reference" };
return runDocker(event, sessionId, `pull ${shQuote(ref)}`, 600000);
return runDocker(event, sessionId, `pull ${shQuote(ref)}`, 600000, runtime);
}
case "rm": {
const repo = String(repository || "").trim();
const tagName = String(tag || "").trim();
const tagged = repo && tagName && repo !== "<none>" && tagName !== "<none>";
if (tagged) {
const ref = sanitizeImageRef(`${repo}:${tagName}`);
if (!ref) return { success: false, error: "Invalid image reference" };
return runDocker(event, sessionId, `rmi ${shQuote(ref)}`, 15000, runtime);
}
const safeId = sanitizeDockerId(imageId);
if (!safeId) return { success: false, error: "Missing image id" };
const forceFlag = force ? " -f" : "";
return runDocker(event, sessionId, `rmi${forceFlag} ${safeId}`);
return runDocker(event, sessionId, `rmi${forceFlag} ${safeId}`, 15000, runtime);
}
case "prune": {
const allFlag = all ? " -a" : "";
return runDocker(event, sessionId, `image prune${allFlag} -f`, 120000);
return runDocker(event, sessionId, `image prune${allFlag} -f`, 120000, runtime);
}
case "tag": {
const safeId = sanitizeDockerId(imageId);
@@ -332,6 +525,8 @@ function createDockerOpsApi({ execOnSession, getSession }) {
event,
sessionId,
`tag ${safeId} ${shQuote(`${repo}:${tagName}`)}`,
15000,
runtime,
);
}
default:
@@ -2,7 +2,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createDockerOpsApi } = require("./dockerOps.cjs");
const { createDockerOpsApi, parseDockerContainers } = require("./dockerOps.cjs");
test("listContainers uses plain docker first even when a saved session password exists", async () => {
const calls = [];
@@ -232,3 +232,258 @@ test("docker image actions retry with sudo and send saved passwords through stdi
);
assert.deepEqual(calls[2].execOptions, { stdin: "pa'ss\n" });
});
test("listContainers runs podman and retries sudo only for the podman socket", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({ systemManagerSudoPassword: "host-secret" }),
execOnSession: async (_event, sessionId, command) => {
calls.push({ sessionId, command });
if (calls.length === 1) {
return {
success: true,
stdout: "",
stderr: "dial unix /run/podman/podman.sock: connect: permission denied",
code: 1,
};
}
return {
success: true,
stdout: '{"ID":"abc123","Names":["web"],"Image":"nginx","State":"running"}\n',
stderr: "",
code: 0,
};
},
});
const result = await dockerOps.listContainers(null, "s1", "podman");
assert.equal(result.success, true);
assert.equal(result.containers[0].name, "web");
assert.equal(calls[0].command, "podman ps -a --format '{{json .}}'");
assert.equal(calls[1].command, "sudo podman ps -a --format '{{json .}}'");
});
test("listImages reads Podman's JSON array and Docker's JSON lines", async () => {
const podman = createDockerOpsApi({
execOnSession: async () => ({
success: true,
stdout: `[
{
"Id": "abc123",
"Names": ["docker.io/library/alpine:3.20", "localhost/alpine:dev"],
"Size": 7340032,
"Created": 1700000000,
"Digest": "sha256:deadbeef"
}
]`,
stderr: "",
code: 0,
}),
});
const podmanResult = await podman.listImages(null, "s1", "podman");
assert.equal(podmanResult.success, true);
assert.equal(podmanResult.images.length, 2);
assert.equal(podmanResult.images[0].repository, "docker.io/library/alpine");
assert.equal(podmanResult.images[0].tag, "3.20");
assert.equal(podmanResult.images[0].name, "docker.io/library/alpine:3.20");
assert.equal(podmanResult.images[1].name, "localhost/alpine:dev");
assert.equal(podmanResult.images[1].id, "abc123");
assert.equal(podmanResult.images[0].size, "7MB");
assert.equal(podmanResult.images[0].digest, "sha256:deadbeef");
const docker = createDockerOpsApi({
execOnSession: async () => ({
success: true,
stdout: '{"ID":"fff","Repository":"nginx","Tag":"latest","Size":"10MB","CreatedAt":"2026-01-01"}\n',
stderr: "",
code: 0,
}),
});
const dockerResult = await docker.listImages(null, "s1", "docker");
assert.equal(dockerResult.images.length, 1);
assert.equal(dockerResult.images[0].name, "nginx:latest");
assert.equal(dockerResult.images[0].size, "10MB");
});
test("getStats reads Podman's JSON array and Docker's JSON lines", async () => {
const podman = createDockerOpsApi({
execOnSession: async () => ({
success: true,
stdout: JSON.stringify([{
id: "abc123def456",
name: "web",
cpu_percent: "1.50%",
mem_usage: "2MB / 1GB",
mem_percent: "0.20%",
net_io: "1kB / 2kB",
block_io: "3kB / 4kB",
pids: "4",
}]),
stderr: "",
code: 0,
}),
});
const podmanResult = await podman.getStats(null, { sessionId: "s1", ids: ["abc123def456"], runtime: "podman" });
assert.equal(podmanResult.success, true);
assert.equal(podmanResult.stats.length, 1);
assert.equal(podmanResult.stats[0].id, "abc123def456");
assert.equal(podmanResult.stats[0].name, "web");
assert.equal(podmanResult.stats[0].cpuPercent, 1.5);
assert.equal(podmanResult.stats[0].memUsage, "2MB / 1GB");
assert.equal(podmanResult.stats[0].pids, 4);
const docker = createDockerOpsApi({
execOnSession: async () => ({
success: true,
stdout: '{"ID":"fff111","Name":"db","CPUPerc":"0.25%","MemUsage":"1MiB / 2GiB","MemPerc":"0.01%","NetIO":"0B / 0B","BlockIO":"0B / 0B","PIDs":"2"}\n',
stderr: "",
code: 0,
}),
});
const dockerResult = await docker.getStats(null, { sessionId: "s1", runtime: "docker" });
assert.equal(dockerResult.stats[0].id, "fff111");
assert.equal(dockerResult.stats[0].cpuPercent, 0.25);
assert.equal(dockerResult.stats[0].memUsage, "1MiB / 2GiB");
});
test("image removal targets a selected tag and keeps id removal for dangling images", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
execOnSession: async (_event, _sessionId, command) => {
calls.push(command);
return { success: true, stdout: "", stderr: "", code: 0 };
},
});
const tagged = await dockerOps.imageAction(null, {
sessionId: "s1",
runtime: "podman",
action: "rm",
imageId: "abc123",
repository: "localhost/alpine",
tag: "dev",
});
assert.equal(tagged.success, true);
assert.equal(calls[0], "podman rmi 'localhost/alpine:dev'");
const dangling = await dockerOps.imageAction(null, {
sessionId: "s1",
runtime: "podman",
action: "rm",
imageId: "abc123",
repository: "<none>",
tag: "<none>",
force: true,
});
assert.equal(dangling.success, true);
assert.equal(calls[1], "podman rmi -f abc123");
});
test("getStats sums Podman per-interface Network counters", async () => {
const podman = createDockerOpsApi({
execOnSession: async () => ({
success: true,
stdout: JSON.stringify([{
ContainerID: "abc123def456",
Name: "web",
CPU: 1.5,
MemUsage: 2097152,
MemLimit: 1073741824,
MemPerc: 0.2,
Network: {
eth0: { RxBytes: 1000, TxBytes: 2000 },
eth1: { RxBytes: 500, TxBytes: 250 },
},
PIDs: 4,
}]),
stderr: "",
code: 0,
}),
});
const result = await podman.getStats(null, { sessionId: "s1", runtime: "podman" });
assert.equal(result.success, true);
assert.equal(result.stats[0].netIO, "1.5KB / 2.2KB");
assert.equal(result.stats[0].memUsage, "2MB / 1GB");
});
test("listContainers replaces raw nerdctl containerd failures with a stable code", async () => {
const logged = [];
const original = console.error;
console.error = (...args) => {
logged.push(args.map(String).join(" "));
};
try {
const dockerOps = createDockerOpsApi({
getSession: () => ({}),
execOnSession: async () => ({
success: true,
stdout: "",
stderr: 'time="2026-10-20T18:31:27+08:00" level=fatal msg="rootless containerd not running? environment variable XDG_RUNTIME_DIR is not set"',
code: 1,
}),
});
const result = await dockerOps.listContainers(null, "s1", "nerdctl");
const blob = JSON.stringify(result);
assert.equal(result.success, false);
assert.equal(result.errorCode, "nerdctlUnreachable");
assert.equal(result.error, undefined);
assert.equal(blob.includes("XDG_RUNTIME_DIR"), false);
assert.equal(blob.includes("level=fatal"), false);
assert.match(logged.join("\n"), /XDG_RUNTIME_DIR/);
} finally {
console.error = original;
}
});
test("listContainers keeps a specific nerdctl command error and docker stderr", async () => {
const nerdctl = createDockerOpsApi({
getSession: () => ({}),
execOnSession: async () => ({
success: true,
stdout: "",
stderr: "Error: no such container: abc",
code: 1,
}),
});
const specific = await nerdctl.listContainers(null, "s1", "nerdctl");
assert.equal(specific.success, false);
assert.equal(specific.errorCode, undefined);
assert.match(specific.error, /no such container/);
const docker = createDockerOpsApi({
getSession: () => ({}),
execOnSession: async () => ({
success: true,
stdout: "",
stderr: "permission denied while trying to connect to the Docker daemon socket",
code: 1,
}),
});
const denied = await docker.listContainers(null, "s1", "docker");
assert.match(denied.error, /permission denied/i);
assert.equal(denied.errorCode, undefined);
});
test("parseDockerContainers formats podman port mappings and exposed ports", () => {
const docker = parseDockerContainers('{"ID":"abc","Names":"web","Ports":"0.0.0.0:8080->80/tcp"}\n');
assert.equal(docker[0].ports, "0.0.0.0:8080->80/tcp");
const podman = parseDockerContainers(JSON.stringify({
Id: "def",
Names: ["api"],
Ports: [
{ host_ip: "127.0.0.1", container_port: 80, host_port: 8080, range: 1, protocol: "tcp" },
{ host_ip: "", container_port: 53, host_port: 5353, range: 2, protocol: "udp" },
{ host_ip: "::", container_port: 443, host_port: 8443, protocol: "tcp,udp" },
],
ExposedPorts: { 80: ["tcp"], 443: ["tcp"], 90: ["tcp"] },
}));
assert.equal(podman[0].name, "api");
assert.equal(
podman[0].ports,
"127.0.0.1:8080->80/tcp, 0.0.0.0:5353-5354->53-54/udp, [::]:8443->443/tcp, [::]:8443->443/udp, 90/tcp",
);
});
@@ -0,0 +1,107 @@
"use strict";
const {
buildKubectlContextsCommand,
buildKubectlDeletePodCommand,
buildKubectlNamespacesCommand,
buildKubectlPodsCommand,
isKubectlUnreachableError,
parseKubeconfigContexts,
parseKubectlPodList,
parseNamespaceList,
} = require("../../../domain/systemManager/containerRuntime.shared.cjs");
function isSuccessfulCommandResult(result) {
return result?.success && (result.code === 0 || result.code === null || result.code === undefined);
}
function commandError(result, fallback) {
return (result?.stderr || result?.error || "").trim() || fallback;
}
function failureResult(result, fallback) {
const raw = commandError(result, fallback);
if (isKubectlUnreachableError(raw)) {
console.error(`[netcatty] kubectl: ${raw}`);
return { success: false, errorCode: "clusterUnreachable" };
}
return { success: false, error: raw };
}
function backgroundShell(sessionId, isLocalSession, nodeProcess) {
if (
typeof isLocalSession === "function"
&& isLocalSession(sessionId)
&& nodeProcess.platform === "win32"
) {
return "powershell";
}
return "posix";
}
function createKubectlOpsApi({ execOnSession, isLocalSession, process: nodeProcess = process }) {
async function run(event, payload) {
const sessionId = payload?.sessionId;
const op = payload?.op;
if (!sessionId || !op) return { success: false, error: "Missing params" };
if (op === "contexts") {
const result = await execOnSession(event, sessionId, buildKubectlContextsCommand(), 10000);
if (!isSuccessfulCommandResult(result)) {
return failureResult(result, "kubectl exited");
}
try {
const parsed = parseKubeconfigContexts(result.stdout);
return { success: true, contexts: parsed.contexts, current: parsed.current };
} catch {
return { success: false, error: "Failed to parse Kubernetes contexts" };
}
}
const shell = backgroundShell(sessionId, isLocalSession, nodeProcess);
if (op === "namespaces") {
const command = buildKubectlNamespacesCommand(payload.context, shell);
if (!command) return { success: false, error: "Invalid context" };
const result = await execOnSession(event, sessionId, command, 12000);
if (!isSuccessfulCommandResult(result)) {
return failureResult(result, "kubectl exited");
}
try {
return { success: true, namespaces: parseNamespaceList(result.stdout) };
} catch {
return { success: false, error: "Failed to parse Kubernetes namespaces" };
}
}
if (op === "pods") {
const command = buildKubectlPodsCommand(payload.context, payload.namespace, shell);
if (!command) return { success: false, error: "Invalid context or namespace" };
const result = await execOnSession(event, sessionId, command, 15000);
if (!isSuccessfulCommandResult(result)) {
return failureResult(result, "kubectl exited");
}
try {
return { success: true, pods: parseKubectlPodList(result.stdout) };
} catch {
return { success: false, error: "Failed to parse Kubernetes pods" };
}
}
if (op === "delete") {
const command = buildKubectlDeletePodCommand(payload.context, payload.namespace, payload.pod, shell);
if (!command) return { success: false, error: "Invalid pod" };
const result = await execOnSession(event, sessionId, command, 20000);
if (!isSuccessfulCommandResult(result)) {
return failureResult(result, "kubectl delete failed");
}
return { success: true };
}
return { success: false, error: `Invalid kubectl op: ${op}` };
}
return { run };
}
module.exports = { createKubectlOpsApi };
@@ -0,0 +1,127 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createKubectlOpsApi } = require("./kubectlOps.cjs");
test("background kubectl quotes for the executor, not the interactive cmd shell", async () => {
const commands = [];
const api = createKubectlOpsApi({
execOnSession: async (_event, _sessionId, command) => {
commands.push(command);
return { success: true, stdout: '{"items":[]}', stderr: "", code: 0 };
},
isLocalSession: () => true,
process: { platform: "win32" },
});
const result = await api.run(null, {
sessionId: "local-cmd",
op: "namespaces",
context: "prod-$east",
shell: "cmd",
});
assert.equal(result.success, true);
assert.match(commands[0], /--context 'prod-\$east'/);
assert.doesNotMatch(commands[0], /"/);
await api.run(null, {
sessionId: "local-cmd",
op: "delete",
context: "prod-$east",
namespace: "kube-system",
pod: "coredns-$east",
shell: "cmd",
});
assert.match(commands[1], /--context 'prod-\$east'/);
assert.match(commands[1], /-n 'kube-system'/);
assert.match(commands[1], /delete pod 'coredns-\$east'/);
assert.doesNotMatch(commands[1], /"/);
});
test("background kubectl on a remote session stays POSIX even when the payload asks for cmd", async () => {
let command = "";
const api = createKubectlOpsApi({
execOnSession: async (_event, _sessionId, next) => {
command = next;
return { success: true, stdout: '{"items":[]}', stderr: "", code: 0 };
},
isLocalSession: () => false,
process: { platform: "win32" },
});
await api.run(null, {
sessionId: "ssh",
op: "pods",
context: "o'brien",
namespace: "default",
shell: "cmd",
});
assert.match(command, /--context 'o'"'"'brien'/);
assert.doesNotMatch(command, /--context "o'brien"/);
});
test("pods replace unreachable API discovery spam with a stable code", async () => {
const logged = [];
const original = console.error;
console.error = (...args) => {
logged.push(args.map(String).join(" "));
};
try {
const api = createKubectlOpsApi({
execOnSession: async () => ({
success: true,
stdout: "",
stderr: [
'memcache.go:381] "Couldn\'t get current server API group list"',
'Get "https://127.0.0.1:6443/api?timeout=32s": dial tcp 127.0.0.1:6443: connect: connection refused',
].join("\n"),
code: 1,
}),
isLocalSession: () => false,
process: { platform: "linux" },
});
const result = await api.run(null, {
sessionId: "s1",
op: "pods",
context: "broken-cluster",
namespace: "default",
});
const blob = JSON.stringify(result);
assert.equal(result.success, false);
assert.equal(result.errorCode, "clusterUnreachable");
assert.equal(result.error, undefined);
assert.equal(blob.includes("memcache"), false);
assert.equal(blob.includes("connection refused"), false);
assert.match(logged.join("\n"), /connection refused/);
} finally {
console.error = original;
}
});
test("namespaces keep Forbidden text for the namespace fallback", async () => {
const api = createKubectlOpsApi({
execOnSession: async () => ({
success: true,
stdout: "",
stderr: 'Error from server (Forbidden): namespaces is forbidden: User "view" cannot list resource "namespaces"',
code: 1,
}),
isLocalSession: () => false,
process: { platform: "linux" },
});
const result = await api.run(null, {
sessionId: "s1",
op: "namespaces",
context: "prod",
});
assert.equal(result.success, false);
assert.equal(result.errorCode, undefined);
assert.match(result.error, /\bForbidden\b/);
});
+51 -3
View File
@@ -3,16 +3,35 @@
const { createExecOnSessionApi } = require("./systemManager/execOnSession.cjs");
const { createTmuxOpsApi } = require("./systemManager/tmuxOps.cjs");
const { createDockerOpsApi } = require("./systemManager/dockerOps.cjs");
const { createKubectlOpsApi } = require("./systemManager/kubectlOps.cjs");
const { parseRuntimeProbe } = require("../../domain/systemManager/containerRuntime.shared.cjs");
const { createGpuOpsApi } = require("./systemManager/gpuOps.cjs");
const { createPortOpsApi } = require("./systemManager/portOps.cjs");
const { createServiceOpsApi } = require("./systemManager/serviceOps.cjs");
function nerdctlUsableClause(markCommand) {
// Offer nerdctl only when `nerdctl info` works. A socket permission error
// still counts: later commands can fall back to sudo. A dead containerd
// (rootless not running, connection refused) does not.
return [
"if command -v nerdctl >/dev/null 2>&1; then ",
"_nc_nerdctl_err=$( { if command -v timeout >/dev/null 2>&1; then timeout 3 nerdctl info; else nerdctl info; fi; } 2>&1 >/dev/null ); ",
"_nc_nerdctl_code=$?; ",
'if [ "$_nc_nerdctl_code" -eq 0 ] || printf "%s\\n" "$_nc_nerdctl_err" | grep -i "permission denied" >/dev/null 2>&1; then ',
markCommand,
"; fi; fi; ",
].join("");
}
const CAPABILITY_SCRIPT_POSIX = [
"exec sh -c ",
"'",
'printf "%s\\n" "__NC_OS__=$(uname -s)"; ',
'command -v tmux >/dev/null 2>&1 && printf "%s\\n" __NC_TMUX__=1; ',
'command -v docker >/dev/null 2>&1 && printf "%s\\n" __NC_DOCKER__=1; ',
'command -v podman >/dev/null 2>&1 && printf "%s\\n" __NC_PODMAN__=1; ',
nerdctlUsableClause('printf "%s\\n" __NC_NERDCTL__=1'),
'command -v kubectl >/dev/null 2>&1 && printf "%s\\n" __NC_KUBECTL__=1; ',
'command -v nvidia-smi >/dev/null 2>&1 && printf "%s\\n" __NC_NVIDIA_SMI__=1; ',
'command -v npu-smi >/dev/null 2>&1 && printf "%s\\n" __NC_NPU_SMI__=1; ',
'command -v ss >/dev/null 2>&1 && printf "%s\\n" __NC_SS__=1; ',
@@ -48,7 +67,11 @@ function parseCapabilities(stdout, isLocal, localPlatform) {
// noisy shells / vendor CLIs that reprint the command text.
const hasFlag = (name) => new RegExp(`(?:^|\\r?\\n)${name}=1(?:\\r?\\n|$)`).test(text);
const hasTmux = hasFlag("__NC_TMUX__");
const hasDocker = hasFlag("__NC_DOCKER__");
const runtimeProbe = parseRuntimeProbe(text);
const hasDocker = runtimeProbe.docker;
const hasPodman = runtimeProbe.podman;
const hasNerdctl = runtimeProbe.nerdctl;
const hasKubectl = runtimeProbe.kubernetes;
const hasNvidiaSmi = hasFlag("__NC_NVIDIA_SMI__");
const hasNpuSmi = hasFlag("__NC_NPU_SMI__");
const hasSs = hasFlag("__NC_SS__");
@@ -59,6 +82,9 @@ function parseCapabilities(stdout, isLocal, localPlatform) {
targetOs,
hasTmux,
hasDocker,
hasPodman,
hasNerdctl,
hasKubectl,
hasNvidiaSmi,
hasNpuSmi,
hasSs,
@@ -185,6 +211,7 @@ function createSystemManagerBridge(deps) {
const tmuxOps = createTmuxOpsApi({ execOnSession });
const dockerOps = createDockerOpsApi({ execOnSession, getSession });
const kubectlOps = createKubectlOpsApi({ execOnSession, isLocalSession, process });
const gpuOps = createGpuOpsApi({
execOnSession,
execOnLocalMachine,
@@ -212,6 +239,9 @@ function createSystemManagerBridge(deps) {
'Write-Output "__NC_OS__=Windows"; ',
"if (Get-Command tmux -ErrorAction SilentlyContinue) { Write-Output '__NC_TMUX__=1' }; ",
"docker info 2>$null; if ($LASTEXITCODE -eq 0) { Write-Output '__NC_DOCKER__=1' }; ",
"if (Get-Command podman -ErrorAction SilentlyContinue) { Write-Output '__NC_PODMAN__=1' }; ",
"if (Get-Command nerdctl -ErrorAction SilentlyContinue) { nerdctl info 2>$null; if ($LASTEXITCODE -eq 0) { Write-Output '__NC_NERDCTL__=1' } }; ",
"if (Get-Command kubectl -ErrorAction SilentlyContinue) { Write-Output '__NC_KUBECTL__=1' }; ",
"if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) { Write-Output '__NC_NVIDIA_SMI__=1' }; ",
"if (Get-Command npu-smi -ErrorAction SilentlyContinue) { Write-Output '__NC_NPU_SMI__=1' }; ",
// Local Windows uses Get-NetTCPConnection; treat as netstat-capable for tab visibility.
@@ -235,6 +265,9 @@ function createSystemManagerBridge(deps) {
"uname -s; ",
"command -v tmux; ",
"command -v docker >/dev/null 2>&1 && echo docker_ok; ",
"command -v podman >/dev/null 2>&1 && echo podman_ok; ",
nerdctlUsableClause("echo nerdctl_ok"),
"command -v kubectl >/dev/null 2>&1 && echo kubectl_ok; ",
"command -v nvidia-smi >/dev/null 2>&1 && echo nvidia_ok; ",
"command -v npu-smi >/dev/null 2>&1 && echo npu_ok; ",
"command -v ss >/dev/null 2>&1 && echo ss_ok; ",
@@ -252,6 +285,9 @@ function createSystemManagerBridge(deps) {
targetOs: platform === "linux" ? "linux" : platform === "darwin" ? "darwin" : "unknown",
hasTmux: text.includes("tmux") && !text.includes("not found"),
hasDocker: text.includes("docker_ok"),
hasPodman: text.includes("podman_ok"),
hasNerdctl: text.includes("nerdctl_ok"),
hasKubectl: text.includes("kubectl_ok"),
hasNvidiaSmi: text.includes("nvidia_ok"),
hasNpuSmi: text.includes("npu_ok"),
hasSs: text.includes("ss_ok"),
@@ -426,13 +462,13 @@ function createSystemManagerBridge(deps) {
async function listDockerContainers(event, payload) {
const sessionId = payload?.sessionId;
if (!sessionId) return { success: false, error: "Missing sessionId" };
return dockerOps.listContainers(event, sessionId);
return dockerOps.listContainers(event, sessionId, payload?.runtime);
}
async function listDockerImages(event, payload) {
const sessionId = payload?.sessionId;
if (!sessionId) return { success: false, error: "Missing sessionId" };
return dockerOps.listImages(event, sessionId);
return dockerOps.listImages(event, sessionId, payload?.runtime);
}
async function dockerStats(event, payload) {
@@ -450,14 +486,24 @@ function createSystemManagerBridge(deps) {
async function dockerAction(event, payload) {
const result = await dockerOps.containerAction(event, payload);
if (result.success === false) {
if (result.errorCode === "nerdctlUnreachable") {
return { success: false, errorCode: result.errorCode };
}
return { success: false, error: result.error || result.stderr || "docker command failed" };
}
return { success: true };
}
async function kubectl(event, payload) {
return kubectlOps.run(event, payload);
}
async function dockerImageAction(event, payload) {
const result = await dockerOps.imageAction(event, payload);
if (result.success === false) {
if (result.errorCode === "nerdctlUnreachable") {
return { success: false, errorCode: result.errorCode };
}
return { success: false, error: result.error || result.stderr || "docker command failed" };
}
return { success: true, output: result.stdout };
@@ -512,6 +558,7 @@ function createSystemManagerBridge(deps) {
"netcatty:system:dockerImageInspect",
"netcatty:system:dockerAction",
"netcatty:system:dockerImageAction",
"netcatty:system:kubectl",
"netcatty:system:listAccelerators",
"netcatty:system:listListeningPorts",
"netcatty:system:listSystemServices",
@@ -536,6 +583,7 @@ function createSystemManagerBridge(deps) {
ipcMain.handle("netcatty:system:dockerImageInspect", dockerImageInspect);
ipcMain.handle("netcatty:system:dockerAction", dockerAction);
ipcMain.handle("netcatty:system:dockerImageAction", dockerImageAction);
ipcMain.handle("netcatty:system:kubectl", kubectl);
ipcMain.handle("netcatty:system:listAccelerators", listAccelerators);
ipcMain.handle("netcatty:system:listListeningPorts", listListeningPorts);
ipcMain.handle("netcatty:system:listSystemServices", listSystemServices);
@@ -335,12 +335,48 @@ test("probeCapabilities reports Docker when docker is installed even if plain do
assert.equal(result.success, true);
assert.equal(result.capabilities.hasDocker, true);
assert.equal(result.capabilities.hasPodman, false);
assert.equal(result.capabilities.hasNerdctl, false);
assert.equal(result.capabilities.hasKubectl, false);
assert.equal(result.capabilities.hasNvidiaSmi, true);
assert.equal(result.capabilities.hasNpuSmi, false);
assert.equal(result.capabilities.hasSs, true);
assert.equal(result.capabilities.hasSystemctl, true);
});
test("probeCapabilities reports podman, nerdctl, and kubectl from command -v markers", async () => {
const conn = {
exec(command, callback) {
assert.match(command, /command -v podman/);
assert.match(command, /command -v nerdctl/);
assert.match(command, /nerdctl info/);
assert.match(command, /permission denied/);
assert.doesNotMatch(command, /command -v nerdctl >\/dev\/null 2>&1 && printf/);
assert.match(command, /command -v kubectl/);
callback(null, createFakeExecStream(
"__NC_OS__=Linux\n__NC_PODMAN__=1\n__NC_NERDCTL__=1\n__NC_KUBECTL__=1\n",
));
},
};
const sessions = new Map([["s1", { conn, type: "ssh" }]]);
const bridge = createSystemManagerBridge({
getSessions: () => sessions,
process,
});
const result = await bridge.probeCapabilities(null, { sessionId: "s1" });
assert.equal(result.success, true);
assert.equal(result.capabilities.hasDocker, false);
assert.equal(result.capabilities.hasPodman, true);
assert.equal(result.capabilities.hasNerdctl, true);
assert.equal(result.capabilities.hasKubectl, true);
const source = fs.readFileSync(path.join(__dirname, "systemManagerBridge.cjs"), "utf8");
assert.match(source, /nerdctl info 2>\$null; if \(\$LASTEXITCODE -eq 0\)/);
assert.match(source, /echo nerdctl_ok/);
});
test("setupOsc7Tracking runs the setup command through the active session executor", async () => {
let seenCommand = "";
const conn = {
@@ -0,0 +1,127 @@
"use strict";
function area(rect) {
return Math.max(0, rect.width) * Math.max(0, rect.height);
}
function intersects(a, b) {
return a.x < b.x + b.width
&& a.x + a.width > b.x
&& a.y < b.y + b.height
&& a.y + a.height > b.y;
}
function clamp(value, min, max) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
function finishFrame(x, y, width, height, workArea) {
const frame = {
width: Math.round(Math.min(width, workArea.width)),
height: Math.round(Math.min(height, workArea.height)),
};
const maxX = workArea.x + workArea.width - frame.width;
const maxY = workArea.y + workArea.height - frame.height;
frame.x = Math.round(clamp(x, workArea.x, maxX));
frame.y = Math.round(clamp(y, workArea.y, maxY));
return frame;
}
function intersection(a, b) {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
const right = Math.min(a.x + a.width, b.x + b.width);
const bottom = Math.min(a.y + a.height, b.y + b.height);
return {
x: Math.ceil(x),
y: Math.ceil(y),
width: Math.floor(right) - Math.ceil(x),
height: Math.floor(bottom) - Math.ceil(y),
};
}
/**
* Place a terminal popup in the largest region of the source window that does
* not cover `avoid` (the system-manager panel). Without a usable gap, center
* on the source window.
*/
function placeTerminalPopup({
workArea,
sourceBounds,
avoid,
width,
height,
minWidth = 480,
minHeight = 320,
}) {
const preferred = {
width: Math.min(width, workArea.width),
height: Math.min(height, workArea.height),
};
const slots = [];
if (avoid && avoid.width > 0 && avoid.height > 0) {
const sourceRight = sourceBounds.x + sourceBounds.width;
const sourceBottom = sourceBounds.y + sourceBounds.height;
const avoidRight = avoid.x + avoid.width;
const avoidBottom = avoid.y + avoid.height;
const push = (rect) => {
if (rect.width >= 1 && rect.height >= 1) slots.push(rect);
};
push({
x: sourceBounds.x,
y: sourceBounds.y,
width: avoid.x - sourceBounds.x,
height: sourceBounds.height,
});
push({
x: avoidRight,
y: sourceBounds.y,
width: sourceRight - avoidRight,
height: sourceBounds.height,
});
push({
x: sourceBounds.x,
y: sourceBounds.y,
width: sourceBounds.width,
height: avoid.y - sourceBounds.y,
});
push({
x: sourceBounds.x,
y: avoidBottom,
width: sourceBounds.width,
height: sourceBottom - avoidBottom,
});
}
const centeredFallback = () => finishFrame(
sourceBounds.x + (sourceBounds.width - preferred.width) / 2,
sourceBounds.y + (sourceBounds.height - preferred.height) / 2,
preferred.width,
preferred.height,
workArea,
);
const visibleSlot = slots
.map((candidate) => intersection(candidate, workArea))
.filter((candidate) => candidate.width >= minWidth && candidate.height >= minHeight)
.sort((a, b) => area(b) - area(a))[0];
if (!visibleSlot) return centeredFallback();
const popupWidth = Math.min(preferred.width, visibleSlot.width);
const popupHeight = Math.min(preferred.height, visibleSlot.height);
const centered = finishFrame(
visibleSlot.x + (visibleSlot.width - popupWidth) / 2,
visibleSlot.y + (visibleSlot.height - popupHeight) / 2,
popupWidth,
popupHeight,
workArea,
);
if (!intersects(centered, avoid)) return centered;
const pinned = finishFrame(visibleSlot.x, visibleSlot.y, popupWidth, popupHeight, workArea);
return intersects(pinned, avoid) ? centeredFallback() : pinned;
}
module.exports = {
intersects,
placeTerminalPopup,
};
@@ -0,0 +1,71 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { intersects, placeTerminalPopup } = require("./terminalPopupPlacement.cjs");
const workArea = { x: 0, y: 0, width: 1440, height: 900 };
const sourceBounds = { x: 0, y: 0, width: 1440, height: 900 };
test("a popup beside a right-side panel does not cover the panel", () => {
const avoid = { x: 1000, y: 40, width: 440, height: 860 };
const frame = placeTerminalPopup({
workArea,
sourceBounds,
avoid,
width: 920,
height: 580,
});
assert.equal(intersects(frame, avoid), false);
assert.ok(frame.x + frame.width <= avoid.x);
assert.equal(frame.width, 920);
assert.equal(frame.height, 580);
});
test("a popup beside a left-side panel stays in the remaining terminal area", () => {
const avoid = { x: 0, y: 36, width: 420, height: 864 };
const frame = placeTerminalPopup({
workArea,
sourceBounds,
avoid,
width: 920,
height: 580,
});
assert.equal(intersects(frame, avoid), false);
assert.ok(frame.x >= avoid.x + avoid.width);
});
test("a popup above a bottom dock does not cover the dock", () => {
const avoid = { x: 0, y: 620, width: 1440, height: 280 };
const frame = placeTerminalPopup({
workArea,
sourceBounds,
avoid,
width: 920,
height: 580,
});
assert.equal(intersects(frame, avoid), false);
assert.ok(frame.y + frame.height <= avoid.y);
});
test("a popup for an off-screen source stays in the work area and off the panel", () => {
const avoid = { x: 500, y: 0, width: 440, height: 900 };
const frame = placeTerminalPopup({
workArea,
sourceBounds: { x: -500, y: 0, width: 1440, height: 900 },
avoid,
width: 920,
height: 580,
});
assert.equal(intersects(frame, avoid), false);
assert.ok(frame.x >= workArea.x);
assert.ok(frame.y >= workArea.y);
assert.ok(frame.x + frame.width <= workArea.x + workArea.width);
assert.ok(frame.y + frame.height <= workArea.y + workArea.height);
assert.ok(frame.width >= 480);
assert.ok(frame.height >= 320);
});
@@ -12,6 +12,7 @@ const {
const {
windowsFramelessContentChromeOptions,
} = require("./windowsWindowChrome.cjs");
const { placeTerminalPopup } = require("./terminalPopupPlacement.cjs");
const ATTACH_CLOSE_PREPARE_TIMEOUT_MS = 2000;
@@ -25,6 +26,62 @@ function createTerminalPopupWindowApi(ctx) {
return Boolean(win && typeof win.isDestroyed === "function" && !win.isDestroyed());
}
function isClientRect(value) {
if (!value || typeof value !== "object") return false;
const { x, y, width, height } = value;
return [x, y, width, height].every((entry) => typeof entry === "number" && Number.isFinite(entry))
&& width > 0
&& height > 0
&& width < 10000
&& height < 10000;
}
function resolveTerminalPopupFrame(electronModule, sourceWindow, avoidClientRect, preferred) {
const centered = resolveSettingsWindowBounds(electronModule, {
sourceWindow,
settingsWidth: preferred.width,
settingsHeight: preferred.height,
});
const fallback = {
x: centered.x,
y: centered.y,
width: preferred.width,
height: preferred.height,
};
if (!isClientRect(avoidClientRect) || !isLiveWindow(sourceWindow)) return fallback;
let content;
let zoom = 1;
try {
content = sourceWindow.getContentBounds();
const factor = sourceWindow.webContents?.getZoomFactor?.();
if (typeof factor === "number" && Number.isFinite(factor) && factor > 0) zoom = factor;
} catch {
return fallback;
}
if (!content || ![content.x, content.y, content.width, content.height].every((entry) => Number.isFinite(entry))) {
return fallback;
}
let workArea;
try {
workArea = electronModule?.screen?.getDisplayMatching?.(content)?.workArea;
} catch {
workArea = null;
}
if (!workArea || !Number.isFinite(workArea.width) || workArea.width <= 0) return fallback;
return placeTerminalPopup({
workArea,
sourceBounds: content,
avoid: {
x: content.x + avoidClientRect.x * zoom,
y: content.y + avoidClientRect.y * zoom,
width: avoidClientRect.width * zoom,
height: avoidClientRect.height * zoom,
},
width: preferred.width,
height: preferred.height,
});
}
async function openTerminalPopupWindow(electronModule, options, payload) {
const { BrowserWindow, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir, sourceWindow } = options;
@@ -52,13 +109,16 @@ function createTerminalPopupWindowApi(ctx) {
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
const backgroundColor = frontendBackground || "#1a1a1a";
const popupWidth = 920;
const popupHeight = 580;
const { x: popupX, y: popupY } = resolveSettingsWindowBounds(electronModule, {
sourceWindow: sourceWindow || mainWindow,
settingsWidth: popupWidth,
settingsHeight: popupHeight,
const preferredWidth = 920;
const preferredHeight = 580;
const placed = resolveTerminalPopupFrame(electronModule, sourceWindow || mainWindow, payload?.avoidClientRect, {
width: preferredWidth,
height: preferredHeight,
});
const popupWidth = placed.width;
const popupHeight = placed.height;
const popupX = placed.x;
const popupY = placed.y;
const title = typeof payload?.title === "string" && payload.title.trim()
? payload.title.trim()
@@ -282,3 +282,70 @@ test("attach popup waits for renderer handoff before closing", async () => {
api.closeTerminalPopupWindow("attach-popup");
assert.equal(popupWindow.destroyed, true);
});
test("a system-manager shell popup is placed beside the panel instead of over it", async () => {
let created = null;
class PlacedBrowserWindowStub {
constructor(options) {
created = options;
this.handlers = new Map();
this.webContents = { id: 77, on() {}, send() {}, setWindowOpenHandler() {} };
}
on(channel, handler) { this.handlers.set(channel, handler); }
isDestroyed() { return false; }
isVisible() { return true; }
loadURL() { return Promise.resolve(); }
setBackgroundColor() {}
}
const api = createTerminalPopupWindowApi({
mainWindow: null,
currentTheme: "light",
V8_CACHE_OPTIONS: "bypassHeatCheck",
resolveFrontendBackgroundColor() { return "#fff"; },
resolveSettingsWindowBounds() { return { x: 260, y: 160 }; },
createExternalOnlyWindowOpenHandler() { return {}; },
applyWindowOpacityToWindow() {},
getDevRendererBaseUrl(url) { return url; },
showAndFocusWindow() {},
registerAppContentWindow() {},
unregisterAppContentWindow() {},
notifyAppContentWindowClosed() {},
});
await api.openTerminalPopupWindow(
{
BrowserWindow: PlacedBrowserWindowStub,
nativeTheme: {},
shell: {},
screen: {
getDisplayMatching() {
return { workArea: { x: 0, y: 0, width: 1440, height: 900 } };
},
},
},
{
preload: "/tmp/preload.cjs",
isDev: false,
appIcon: null,
isMac: false,
electronDir: __dirname,
sourceWindow: {
isDestroyed() { return false; },
getContentBounds() { return { x: 0, y: 0, width: 1440, height: 900 }; },
webContents: { getZoomFactor() { return 1; } },
},
},
{
popupId: "shell-popup",
title: "pod: multi-box",
avoidClientRect: { x: 1000, y: 40, width: 440, height: 860 },
},
);
assert.ok(created);
assert.ok(created.x + created.width <= 1000);
assert.equal(created.width, 920);
assert.notEqual(created.x, 260);
});
+7 -4
View File
@@ -447,11 +447,11 @@ function createPreloadApi(ctx) {
tmuxAction: async (options) => {
return ipcRenderer.invoke("netcatty:system:tmuxAction", options);
},
listDockerContainers: async (sessionId) => {
return ipcRenderer.invoke("netcatty:system:listDockerContainers", { sessionId });
listDockerContainers: async (sessionId, runtime) => {
return ipcRenderer.invoke("netcatty:system:listDockerContainers", { sessionId, runtime });
},
listDockerImages: async (sessionId) => {
return ipcRenderer.invoke("netcatty:system:listDockerImages", { sessionId });
listDockerImages: async (sessionId, runtime) => {
return ipcRenderer.invoke("netcatty:system:listDockerImages", { sessionId, runtime });
},
getDockerStats: async (options) => {
return ipcRenderer.invoke("netcatty:system:dockerStats", options);
@@ -480,6 +480,9 @@ function createPreloadApi(ctx) {
dockerImageAction: async (options) => {
return ipcRenderer.invoke("netcatty:system:dockerImageAction", options);
},
kubectl: async (options) => {
return ipcRenderer.invoke("netcatty:system:kubectl", options);
},
openTerminalPopup: async (payload) => {
return ipcRenderer.invoke("netcatty:window:openTerminalPopup", payload);
},
+2
View File
@@ -153,6 +153,8 @@ export const STORAGE_KEY_TERMINAL_HOST_TREE_TOOLBAR_LAYOUT =
export const STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT = 'netcatty_terminal_side_panel_tab_layout_v1';
/** System Manager sub-tabs (Overview / Processes / …): show / collapse / hide + order. */
export const STORAGE_KEY_SYSTEM_MANAGER_TAB_LAYOUT = 'netcatty_system_manager_tab_layout_v1';
/** Last container runtime, context, and namespace chosen per host. */
export const STORAGE_KEY_CONTAINER_RUNTIME = 'netcatty_container_runtime_v1';
export const STORAGE_KEY_SFTP_TRANSFER_PANEL_HEIGHT = 'netcatty_sftp_transfer_panel_height_v1';
export const STORAGE_KEY_SFTP_TRANSFER_CHILD_NAME_WIDTH = 'netcatty_sftp_transfer_child_name_width_v1';
+1 -1
View File
@@ -55,7 +55,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"pretest": "npm run build:plugin-runtime-deps && npm run check:codebuddy-sdk-contract",
"test": "node --test --import tsx electron/*.test.cjs electron/preload/*.test.cjs electron/mcp/*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/hostBrowser/*Store.test.cjs electron/hostBrowser/hostBrowserManager.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.ts components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/monaco/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
"test": "node --test --import tsx electron/*.test.cjs electron/preload/*.test.cjs electron/mcp/*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/hostBrowser/*Store.test.cjs electron/hostBrowser/hostBrowserManager.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.ts components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts domain/*/*.test.cjs infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/monaco/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
"test:plugin-contract": "node --test --import tsx packages/*/src/*.test.ts",
"pretest:plugin-runtime": "npm run build:plugin-runtime-deps",
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs",
+7
View File
@@ -40,6 +40,13 @@ test("build.files includes the prompt classifier required by Mosh", () => {
assert.equal(promptSecurity.isUntrustedTerminalInputPrompt("验证码:"), true);
});
test("build.files includes the container runtime adapter", () => {
assert.ok(
config.files.includes("domain/systemManager/containerRuntime.shared.cjs"),
"packaged system manager requires the shared container runtime adapter",
);
});
test("build.files includes the single-channel SSH banner matcher", () => {
assert.ok(
config.files.includes("domain/singleChannelSshBanner.shared.cjs"),
+48 -6
View File
@@ -70,19 +70,32 @@ declare global {
tmuxAction?(options: {
sessionId: string;
} & import("../../domain/systemManager/types").TmuxManageAction): Promise<{ success: boolean; error?: string }>;
listDockerContainers?(sessionId: string): Promise<{
listDockerContainers?(
sessionId: string,
runtime?: import("../../domain/systemManager/containerRuntime").ContainerRuntimeId,
): Promise<{
success: boolean;
error?: string;
errorCode?: string;
containers?: import("../../domain/systemManager/types").DockerContainerInfo[];
}>;
listDockerImages?(sessionId: string): Promise<{
listDockerImages?(
sessionId: string,
runtime?: import("../../domain/systemManager/containerRuntime").ContainerRuntimeId,
): Promise<{
success: boolean;
error?: string;
errorCode?: string;
images?: import("../../domain/systemManager/types").DockerImageInfo[];
}>;
getDockerStats?(options: { sessionId: string; ids?: string[] }): Promise<{
getDockerStats?(options: {
sessionId: string;
ids?: string[];
runtime?: import("../../domain/systemManager/containerRuntime").ContainerRuntimeId;
}): Promise<{
success: boolean;
error?: string;
errorCode?: string;
stats?: import("../../domain/systemManager/types").DockerStatInfo[];
}>;
listAccelerators?(sessionId: string): Promise<{
@@ -112,14 +125,24 @@ declare global {
action: import("../../domain/systemManager/types").SystemdUnitAction;
scope?: import("../../domain/systemManager/types").SystemdUnitInfo['scope'];
}): Promise<{ success: boolean; pending?: boolean; error?: string }>;
dockerInspect?(options: { sessionId: string; containerId: string }): Promise<{
dockerInspect?(options: {
sessionId: string;
containerId: string;
runtime?: import("../../domain/systemManager/containerRuntime").ContainerRuntimeId;
}): Promise<{
success: boolean;
error?: string;
errorCode?: string;
inspect?: Record<string, unknown>;
}>;
dockerImageInspect?(options: { sessionId: string; imageId: string }): Promise<{
dockerImageInspect?(options: {
sessionId: string;
imageId: string;
runtime?: import("../../domain/systemManager/containerRuntime").ContainerRuntimeId;
}): Promise<{
success: boolean;
error?: string;
errorCode?: string;
inspect?: Record<string, unknown>;
}>;
dockerAction?(options: {
@@ -127,14 +150,33 @@ declare global {
containerId: string;
action: import("../../domain/systemManager/types").DockerContainerAction;
newName?: string;
}): Promise<{ success: boolean; error?: string }>;
runtime?: import("../../domain/systemManager/containerRuntime").ContainerRuntimeId;
}): Promise<{ success: boolean; error?: string; errorCode?: string }>;
dockerImageAction?(options: {
sessionId: string;
runtime?: import("../../domain/systemManager/containerRuntime").ContainerRuntimeId;
} & import("../../domain/systemManager/types").DockerImageManageAction): Promise<{
success: boolean;
error?: string;
errorCode?: string;
output?: string;
}>;
kubectl?(options: {
sessionId: string;
op: "contexts" | "namespaces" | "pods" | "delete";
context?: string;
namespace?: string;
pod?: string;
shell?: string;
}): Promise<{
success: boolean;
error?: string;
errorCode?: string;
contexts?: import("../../domain/systemManager/containerRuntime").KubeContextInfo[];
current?: string;
namespaces?: string[];
pods?: import("../../domain/systemManager/containerRuntime").KubectlPodInfo[];
}>;
openTerminalPopup?(payload: import("../../domain/systemManager/types").TerminalPopupPayload): Promise<{
success: boolean;
error?: string;