mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 16:41:30 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a34c852ae1 | ||
|
|
1667b88ce8 | ||
|
|
a40fcf40b1 | ||
|
|
04883a8ce5 | ||
|
|
21706be69e | ||
|
|
991e400118 | ||
|
|
71d03b9b85 | ||
|
|
655eeeade7 | ||
|
|
8e008681e7 | ||
|
|
5cca5beab4 | ||
|
|
6ff86aad61 | ||
|
|
930488d36e | ||
|
|
bfffaa0f81 | ||
|
|
22d91a6881 | ||
|
|
584324ff13 | ||
|
|
50f9295393 | ||
|
|
db1a73071d | ||
|
|
c9fa5eaa0b | ||
|
|
b82f4907ed | ||
|
|
9481ed3579 | ||
|
|
085c6427aa | ||
|
|
69af63da10 | ||
|
|
c6f7b7468f | ||
|
|
d711499458 | ||
|
|
f811171ea5 | ||
|
|
e6ff7c281f |
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': 'Показывать статистику сервера',
|
||||
|
||||
@@ -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': '正在加载监听端口…',
|
||||
|
||||
@@ -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': '正在載入監聽連接埠…',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user