Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 daa1d50d82 fix(system-manager): shrink port collector output and filter Windows UDP
Raise ports maxBuffer to 16MB, grep LISTEN on full netstat dumps, and
drop Windows ephemeral UDP binds on non-wildcard addresses.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:49:51 +00:00
Cursor Agentand陈大猫 159e82d677 fix(system-manager): parse full netstat pid/program tail for UDP
Capture the remainder after the peer address so spaced program names
survive when the State column is omitted.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:30:30 +00:00
Cursor Agentand陈大猫 ac220dbacc fix(system-manager): parse BusyBox UDP pid when State is omitted
On netstat -lnup rows without a State column, the pid/program token is
captured as state; promote it so Terminate keeps a process identity.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:28:50 +00:00
Cursor Agentand陈大猫 5f76503d46 fix(system-manager): map macOS tcp46/udp46 to tcp6/udp6 for merge
Dual-stack netstat rows must share keys with lsof IPv6 listeners so
anonymous placeholders collapse instead of duplicating the port.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:26:17 +00:00
Cursor Agentand陈大猫 cba9e02c63 fix(system-manager): collect Linux UDP and macOS dual-stack ports
Prefer netstat -lntup (or -lntp/-lnup), drop the Linux lsof -iUDP dump
fallback, accept tcp46/udp46, expand multi-pid ss users tuples, and keep
systemd units with empty descriptions.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:22:49 +00:00
Cursor Agentand陈大猫 b631ee0ab6 fix(system-manager): close remaining Bugbot findings on ports
Collect macOS/Windows UDP listeners, keep SO_REUSEPORT rows distinct,
ignore late action results after session switches, and parse capability
flags with line-anchored markers.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:18:46 +00:00
Cursor Agentand陈大猫 6d5d7f6b27 fix(system-manager): treat kill non-zero exits and pending actions as failures
SignalProcess now fails on non-zero kill/Stop-Process codes and propagates
pending channel state. Ports/Services UI surfaces pending instead of a silent
refresh success, with regression coverage.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:14:14 +00:00
Cursor Agentand陈大猫 1400d1abc3 fix(system-manager): close Bugbot findings on ports/services
Clear confirm state on session switch, harden IPv6/ss UDP parsing, map
Windows terminate to Stop-Process (TERM/KILL only), propagate sudo pending,
and extend confirm-dialog regression coverage.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:03:40 +00:00
Cursor Agentand陈大猫 1f8659d057 fix(system-manager): tighten port parsers and pending-state races
Reject non-LISTEN TCP / connected UDP in netstat+lsof parsers, stop dumping
all UDP via lsof, and reset Ports/Services pending flags on session switch.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 01:47:02 +00:00
Cursor Agentand陈大猫 34813639c9 fix(system-manager): harden ports/services collectors and network gating
Merge ss/netstat/lsof collectors so macOS is not stuck on empty netstat-only
output, accept BusyBox UDP and macOS dotted addresses, fall back when
systemctl --plain is unsupported, normalize new capability flags, and keep
port/service mutations read-only on network appliances.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 01:26:20 +00:00
Cursor Agentand陈大猫 9973309a15 feat(system-manager): add Ports and Services tabs with capability gating
Extend the System side panel for #2561 follow-ups: listening ports (ss/netstat)
and systemd services (systemctl), using the same detect-first visibility model
as GPU. Network appliances hide guessed tabs until tools/OS are confirmed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 01:20:18 +00:00
22 changed files with 2255 additions and 57 deletions

No files matched your search

@@ -8,6 +8,8 @@ export const enSystemManagerMessages: Messages = {
'systemManager.empty': 'No data available.',
'systemManager.tabs.overview': 'Overview',
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.ports': 'Ports',
'systemManager.tabs.services': 'Services',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
@@ -25,6 +27,8 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerImages': 'Failed to load images',
'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',
'systemManager.errors.loadServices': 'Failed to load systemd services',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.overview.empty': 'No system overview data yet.',
@@ -189,6 +193,34 @@ export const enSystemManagerMessages: Messages = {
'systemManager.docker.start': 'Start',
'systemManager.docker.stop': 'Stop',
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
'systemManager.ports.loading': 'Loading listening ports…',
'systemManager.ports.empty': 'No listening ports reported.',
'systemManager.ports.search': 'Search ports…',
'systemManager.ports.meta': '{{count}} listener(s)',
'systemManager.ports.filter.all': 'All',
'systemManager.ports.unknownProcess': 'Unknown process',
'systemManager.ports.terminate': 'Terminate',
'systemManager.ports.confirmTerminate': 'Send SIGTERM to process {{pid}} that holds this port?',
'systemManager.services.unavailable': 'systemctl is not available on this host.',
'systemManager.services.loading': 'Loading systemd services…',
'systemManager.services.empty': 'No systemd services found.',
'systemManager.services.search': 'Search services…',
'systemManager.services.meta': '{{count}} service(s)',
'systemManager.services.filter.all': 'All',
'systemManager.services.filter.running': 'Running',
'systemManager.services.filter.failed': 'Failed',
'systemManager.services.filter.inactive': 'Inactive',
'systemManager.services.start': 'Start',
'systemManager.services.stop': 'Stop',
'systemManager.services.restart': 'Restart',
'systemManager.services.enable': 'Enable',
'systemManager.services.disable': 'Disable',
'systemManager.services.reload': 'Reload',
'systemManager.services.scope.user': 'user',
'systemManager.services.confirmAction': '{{action}} {{name}}?',
'systemManager.gpu.unavailable': 'No NVIDIA GPU or Ascend NPU tools detected on this host.',
'systemManager.gpu.loading': 'Loading accelerator stats…',
'systemManager.gpu.empty': 'Accelerator tools are present, but no devices were reported.',
@@ -8,6 +8,8 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.empty': 'Нет данных.',
'systemManager.tabs.overview': 'Обзор',
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.ports': 'Порты',
'systemManager.tabs.services': 'Службы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
@@ -25,6 +27,8 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.overview.empty': 'Данных обзора пока нет.',
@@ -189,6 +193,34 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.docker.start': 'Запустить',
'systemManager.docker.stop': 'Остановить',
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
'systemManager.ports.loading': 'Загрузка слушающих портов…',
'systemManager.ports.empty': 'Слушающие порты не найдены.',
'systemManager.ports.search': 'Поиск портов…',
'systemManager.ports.meta': '{{count}} слушающих',
'systemManager.ports.filter.all': 'Все',
'systemManager.ports.unknownProcess': 'Неизвестный процесс',
'systemManager.ports.terminate': 'Завершить',
'systemManager.ports.confirmTerminate': 'Отправить SIGTERM процессу {{pid}}, занимающему этот порт?',
'systemManager.services.unavailable': 'systemctl недоступен на этом хосте.',
'systemManager.services.loading': 'Загрузка служб systemd…',
'systemManager.services.empty': 'Службы systemd не найдены.',
'systemManager.services.search': 'Поиск служб…',
'systemManager.services.meta': '{{count}} служб',
'systemManager.services.filter.all': 'Все',
'systemManager.services.filter.running': 'Запущены',
'systemManager.services.filter.failed': 'С ошибкой',
'systemManager.services.filter.inactive': 'Неактивны',
'systemManager.services.start': 'Запустить',
'systemManager.services.stop': 'Остановить',
'systemManager.services.restart': 'Перезапустить',
'systemManager.services.enable': 'Включить автозапуск',
'systemManager.services.disable': 'Отключить автозапуск',
'systemManager.services.reload': 'Перезагрузить',
'systemManager.services.scope.user': 'user',
'systemManager.services.confirmAction': '{{action}} {{name}}?',
'systemManager.gpu.unavailable': 'На этом хосте не обнаружены инструменты NVIDIA GPU или Ascend NPU.',
'systemManager.gpu.loading': 'Загрузка статистики ускорителей…',
'systemManager.gpu.empty': 'Инструменты ускорителей найдены, но устройства не сообщены.',
@@ -8,6 +8,8 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.empty': '暂无数据。',
'systemManager.tabs.overview': '概览',
'systemManager.tabs.processes': '进程',
'systemManager.tabs.ports': '端口',
'systemManager.tabs.services': '服务',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
@@ -25,6 +27,8 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.loadOverview': '加载系统概览失败',
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
'systemManager.errors.loadPorts': '加载监听端口失败',
'systemManager.errors.loadServices': '加载 systemd 服务失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.overview.empty': '暂无系统概览数据。',
@@ -189,6 +193,34 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.docker.start': '启动',
'systemManager.docker.stop': '停止',
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
'systemManager.ports.loading': '正在加载监听端口…',
'systemManager.ports.empty': '暂无监听端口。',
'systemManager.ports.search': '搜索端口…',
'systemManager.ports.meta': '{{count}} 个监听',
'systemManager.ports.filter.all': '全部',
'systemManager.ports.unknownProcess': '未知进程',
'systemManager.ports.terminate': '结束进程',
'systemManager.ports.confirmTerminate': '向占用该端口的进程 {{pid}} 发送 SIGTERM?',
'systemManager.services.unavailable': '此主机未检测到 systemctl。',
'systemManager.services.loading': '正在加载 systemd 服务…',
'systemManager.services.empty': '暂无 systemd 服务。',
'systemManager.services.search': '搜索服务…',
'systemManager.services.meta': '{{count}} 个服务',
'systemManager.services.filter.all': '全部',
'systemManager.services.filter.running': '运行中',
'systemManager.services.filter.failed': '失败',
'systemManager.services.filter.inactive': '未运行',
'systemManager.services.start': '启动',
'systemManager.services.stop': '停止',
'systemManager.services.restart': '重启',
'systemManager.services.enable': '开机启用',
'systemManager.services.disable': '禁用开机',
'systemManager.services.reload': '重载',
'systemManager.services.scope.user': '用户',
'systemManager.services.confirmAction': '确认对 {{name}} 执行「{{action}}」?',
'systemManager.gpu.unavailable': '此主机未检测到 NVIDIA GPU 或昇腾 NPU 工具。',
'systemManager.gpu.loading': '正在加载加速卡状态…',
'systemManager.gpu.empty': '已检测到加速卡工具,但未返回设备信息。',
@@ -8,6 +8,8 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.empty': '暫無資料。',
'systemManager.tabs.overview': '概覽',
'systemManager.tabs.processes': '程序',
'systemManager.tabs.ports': '連接埠',
'systemManager.tabs.services': '服務',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
@@ -25,6 +27,8 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
'systemManager.errors.loadOverview': '載入系統概覽失敗',
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
'systemManager.overview.empty': '暫無系統概覽資料。',
@@ -189,6 +193,34 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.docker.start': '啟動',
'systemManager.docker.stop': '停止',
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
'systemManager.ports.loading': '正在載入監聽連接埠…',
'systemManager.ports.empty': '暫無監聽連接埠。',
'systemManager.ports.search': '搜尋連接埠…',
'systemManager.ports.meta': '{{count}} 個監聽',
'systemManager.ports.filter.all': '全部',
'systemManager.ports.unknownProcess': '未知程序',
'systemManager.ports.terminate': '結束程序',
'systemManager.ports.confirmTerminate': '向佔用該連接埠的程序 {{pid}} 傳送 SIGTERM?',
'systemManager.services.unavailable': '此主機未偵測到 systemctl。',
'systemManager.services.loading': '正在載入 systemd 服務…',
'systemManager.services.empty': '暫無 systemd 服務。',
'systemManager.services.search': '搜尋服務…',
'systemManager.services.meta': '{{count}} 個服務',
'systemManager.services.filter.all': '全部',
'systemManager.services.filter.running': '執行中',
'systemManager.services.filter.failed': '失敗',
'systemManager.services.filter.inactive': '未執行',
'systemManager.services.start': '啟動',
'systemManager.services.stop': '停止',
'systemManager.services.restart': '重新啟動',
'systemManager.services.enable': '開機啟用',
'systemManager.services.disable': '停用開機',
'systemManager.services.reload': '重新載入',
'systemManager.services.scope.user': '使用者',
'systemManager.services.confirmAction': '確認對 {{name}} 執行「{{action}}」?',
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
'systemManager.gpu.loading': '正在載入加速卡狀態…',
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
+12 -2
View File
@@ -15,6 +15,16 @@ function isExpired(entry: StoreEntry): boolean {
return Date.now() > entry.expiresAt;
}
function normalizeCapabilities(capabilities: SessionCapabilities): SessionCapabilities {
return {
...capabilities,
hasSs: capabilities.hasSs === true,
hasNetstat: capabilities.hasNetstat === true,
hasLsof: capabilities.hasLsof === true,
hasSystemctl: capabilities.hasSystemctl === true,
};
}
function notifySession(sessionId: string) {
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
}
@@ -28,13 +38,13 @@ export const sessionCapabilitiesStore = {
notifySession(sessionId);
return undefined;
}
return entry.capabilities;
return normalizeCapabilities(entry.capabilities);
},
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
const entry: StoreEntry = {
capabilities: {
...capabilities,
...normalizeCapabilities(capabilities),
probedAt: Date.now(),
},
expiresAt: Date.now() + ttlMs,
+42 -1
View File
@@ -1,5 +1,11 @@
import { useCallback, useMemo } from 'react';
import type { DockerContainerAction, DockerImageManageAction, TmuxManageAction } from '../../domain/systemManager/types';
import type {
DockerContainerAction,
DockerImageManageAction,
SystemdUnitAction,
SystemdUnitInfo,
TmuxManageAction,
} from '../../domain/systemManager/types';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function useSystemManagerBackend() {
@@ -120,6 +126,35 @@ export function useSystemManagerBackend() {
return bridge.listAccelerators(sessionId);
}, []);
const listListeningPorts = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listListeningPorts) {
return { success: false as const, error: 'listListeningPorts unavailable' };
}
return bridge.listListeningPorts(sessionId);
}, []);
const listSystemServices = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listSystemServices) {
return { success: false as const, error: 'listSystemServices unavailable' };
}
return bridge.listSystemServices(sessionId);
}, []);
const systemServiceAction = useCallback(async (options: {
sessionId: string;
unitName: string;
action: SystemdUnitAction;
scope?: SystemdUnitInfo['scope'];
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.systemServiceAction) {
return { success: false as const, error: 'systemServiceAction unavailable' };
}
return bridge.systemServiceAction(options);
}, []);
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerInspect) {
@@ -181,6 +216,9 @@ export function useSystemManagerBackend() {
listDockerImages,
getDockerStats,
listAccelerators,
listListeningPorts,
listSystemServices,
systemServiceAction,
dockerInspect,
dockerImageInspect,
dockerAction,
@@ -200,6 +238,9 @@ export function useSystemManagerBackend() {
listDockerImages,
getDockerStats,
listAccelerators,
listListeningPorts,
listSystemServices,
systemServiceAction,
dockerInspect,
dockerImageInspect,
dockerAction,
@@ -0,0 +1,242 @@
import { Network, Skull } 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 { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { listeningPortInfoEqual } from '../../domain/systemManager/pollEquals';
import type { ListeningPortInfo } from '../../domain/systemManager/types';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { mergePollListByKey, useStableListOrder } from './listStable';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelRoundButton,
SystemPanelRow,
SystemPanelSearch,
SystemPanelSegmented,
SystemPanelShell,
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
type Backend = ReturnType<typeof useSystemManagerBackend>;
type PortFilter = 'all' | 'tcp' | 'udp';
const mergePorts = (
prev: ListeningPortInfo[] | null,
next: ListeningPortInfo[],
) => mergePollListByKey(prev, next, (p) => p.id, listeningPortInfoEqual);
interface PortsManagerTabProps {
sessionId: string;
isVisible: boolean;
backend: Backend;
refreshIntervalSec: number;
/** Network appliances: list only, no process terminate. */
allowMutations?: boolean;
}
export const PortsManagerTab = memo(function PortsManagerTab({
sessionId,
isVisible,
backend,
refreshIntervalSec,
allowMutations = true,
}: PortsManagerTabProps) {
const { t } = useI18n();
const stableT = useStableTranslate();
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<PortFilter>('all');
const [pendingKillPid, setPendingKillPid] = useState<number | null>(null);
const [killBusy, setKillBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [listPending, setListPending] = useState(false);
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
useEffect(() => {
setListPending(false);
setPendingKillPid(null);
setKillBusy(false);
setActionError(null);
}, [sessionId]);
const fetcher = useCallback(async (): Promise<ListeningPortInfo[] | null> => {
const requestedSessionId = sessionId;
try {
const result = await backend.listListeningPorts(requestedSessionId);
if (sessionIdRef.current !== requestedSessionId) return null;
if (result.pending) {
setListPending(true);
return null;
}
setListPending(false);
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.loadPorts'));
}
return result.ports || [];
} catch (error) {
if (sessionIdRef.current === requestedSessionId) setListPending(false);
throw error;
}
}, [backend, sessionId, stableT]);
const { data, error, loading, refresh } = usePolling(
fetcher,
intervalMs,
isVisible,
mergePorts,
{ resetKey: sessionId },
);
const isRefreshActive = loading || listPending;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return (data || []).filter((port) => {
if (filter === 'tcp' && !port.protocol.startsWith('tcp')) return false;
if (filter === 'udp' && !port.protocol.startsWith('udp')) return false;
if (!q) return true;
return (
String(port.port).includes(q)
|| port.address.toLowerCase().includes(q)
|| port.processName.toLowerCase().includes(q)
|| (port.pid != null && String(port.pid).includes(q))
|| port.protocol.toLowerCase().includes(q)
);
});
}, [data, filter, query]);
const ports = useStableListOrder(
filtered,
(p) => p.id,
`${filter}|${query}`,
(a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol),
);
const executeTerminate = useCallback(async (pid: number) => {
const requestedSessionId = sessionId;
setKillBusy(true);
setActionError(null);
try {
const result = await backend.signalSystemProcess({
sessionId: requestedSessionId,
pid,
signal: 'TERM',
});
if (sessionIdRef.current !== requestedSessionId) return;
if (result.pending) {
setActionError(t('systemManager.errors.sshChannelUnavailable'));
return;
}
if (!result.success) {
setActionError(result.error || t('systemManager.errors.actionFailed'));
return;
}
void refresh();
} finally {
if (sessionIdRef.current === requestedSessionId) setKillBusy(false);
}
}, [backend, refresh, sessionId, t]);
return (
<SystemPanelShell section="system-manager-ports">
<SystemPanelToolbar
trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={isRefreshActive}
onClick={() => void refresh()}
/>
)}
>
<SystemPanelSearch
value={query}
onChange={setQuery}
placeholder={t('systemManager.ports.search')}
/>
</SystemPanelToolbar>
<SystemPanelSegmented
value={filter}
onChange={setFilter}
options={[
{ id: 'all', label: t('systemManager.ports.filter.all') },
{ id: 'tcp', label: 'TCP' },
{ id: 'udp', label: 'UDP' },
]}
/>
<SystemPanelMetaBar>
{t('systemManager.ports.meta', { count: ports.length })}
</SystemPanelMetaBar>
{actionError ? <SystemPanelInlineError message={actionError} /> : null}
{error && !(data?.length) ? (
<SystemPanelError
message={error}
onRetry={() => void refresh()}
retryLabel={t('history.action.retry')}
loading={loading}
/>
) : !(data?.length) && (loading || listPending) ? (
<SystemPanelLoading message={t('systemManager.ports.loading')} />
) : !ports.length ? (
<SystemPanelEmpty icon={Network} message={t('systemManager.ports.empty')} />
) : (
<SystemPanelList>
{ports.map((port) => (
<SystemPanelRow
key={port.id}
title={`${port.address}:${port.port}`}
subtitle={
port.processName
? `${port.processName}${port.pid != null ? ` · PID ${port.pid}` : ''}`
: (port.pid != null ? `PID ${port.pid}` : t('systemManager.ports.unknownProcess'))
}
trailing={(
<SystemPanelStatusBadge tone="muted">
{port.protocol.toUpperCase()}
</SystemPanelStatusBadge>
)}
actions={allowMutations && port.pid != null ? (
<SystemPanelRoundButton
title={t('systemManager.ports.terminate')}
destructive
onClick={() => setPendingKillPid(port.pid)}
>
<Skull size={12} />
</SystemPanelRoundButton>
) : null}
/>
))}
</SystemPanelList>
)}
<SystemPanelConfirmDialog
open={allowMutations && pendingKillPid != null}
title={t('systemManager.ports.terminate')}
message={t('systemManager.ports.confirmTerminate', { pid: String(pendingKillPid ?? 0) })}
confirmLabel={t('systemManager.ports.terminate')}
destructive
busy={killBusy}
onOpenChange={(open) => {
if (!open && !killBusy) setPendingKillPid(null);
}}
onConfirm={() => {
const pid = pendingKillPid;
if (pid == null) return;
setPendingKillPid(null);
void executeTerminate(pid);
}}
/>
</SystemPanelShell>
);
});
@@ -0,0 +1,300 @@
import {
Play, RefreshCw, Square, Cog,
} 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 { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { systemdUnitInfoEqual } from '../../domain/systemManager/pollEquals';
import type { SystemdUnitAction, SystemdUnitInfo } from '../../domain/systemManager/types';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { mergePollListByKey, useStableListOrder } from './listStable';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelRoundButton,
SystemPanelRow,
SystemPanelSearch,
SystemPanelSegmented,
SystemPanelShell,
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
type Backend = ReturnType<typeof useSystemManagerBackend>;
type ServiceFilter = 'all' | 'running' | 'failed' | 'inactive';
interface PendingServiceAction {
unit: SystemdUnitInfo;
action: SystemdUnitAction;
}
const mergeUnits = (
prev: SystemdUnitInfo[] | null,
next: SystemdUnitInfo[],
) => mergePollListByKey(prev, next, (u) => `${u.scope}:${u.name}`, systemdUnitInfoEqual);
function activeTone(state: SystemdUnitInfo['activeState']): 'success' | 'warning' | 'muted' {
if (state === 'active') return 'success';
if (state === 'failed' || state === 'deactivating') return 'warning';
return 'muted';
}
function actionTitleKey(action: SystemdUnitAction): string {
switch (action) {
case 'start': return 'systemManager.services.start';
case 'stop': return 'systemManager.services.stop';
case 'restart': return 'systemManager.services.restart';
case 'enable': return 'systemManager.services.enable';
case 'disable': return 'systemManager.services.disable';
case 'reload': return 'systemManager.services.reload';
default: {
const _exhaustive: never = action;
return _exhaustive;
}
}
}
interface ServicesManagerTabProps {
sessionId: string;
isVisible: boolean;
backend: Backend;
refreshIntervalSec: number;
/** Network appliances: list only, no start/stop/restart. */
allowMutations?: boolean;
}
export const ServicesManagerTab = memo(function ServicesManagerTab({
sessionId,
isVisible,
backend,
refreshIntervalSec,
allowMutations = true,
}: ServicesManagerTabProps) {
const { t } = useI18n();
const stableT = useStableTranslate();
const intervalMs = Math.max(3, refreshIntervalSec) * 1000;
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<ServiceFilter>('all');
const [pending, setPending] = useState<PendingServiceAction | null>(null);
const [actionBusy, setActionBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [listPending, setListPending] = useState(false);
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
useEffect(() => {
setListPending(false);
setPending(null);
setActionBusy(false);
setActionError(null);
}, [sessionId]);
const fetcher = useCallback(async (): Promise<SystemdUnitInfo[] | null> => {
const requestedSessionId = sessionId;
try {
const result = await backend.listSystemServices(requestedSessionId);
if (sessionIdRef.current !== requestedSessionId) return null;
if (result.pending) {
setListPending(true);
return null;
}
setListPending(false);
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.loadServices'));
}
return result.units || [];
} catch (error) {
if (sessionIdRef.current === requestedSessionId) setListPending(false);
throw error;
}
}, [backend, sessionId, stableT]);
const { data, error, loading, refresh } = usePolling(
fetcher,
intervalMs,
isVisible,
mergeUnits,
{ resetKey: sessionId },
);
const isRefreshActive = loading || listPending;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return (data || []).filter((unit) => {
if (filter === 'running' && unit.activeState !== 'active') return false;
if (filter === 'failed' && unit.activeState !== 'failed') return false;
if (filter === 'inactive' && unit.activeState !== 'inactive') return false;
if (!q) return true;
return (
unit.name.toLowerCase().includes(q)
|| unit.description.toLowerCase().includes(q)
|| unit.subState.toLowerCase().includes(q)
|| unit.scope.toLowerCase().includes(q)
);
});
}, [data, filter, query]);
const units = useStableListOrder(
filtered,
(u) => `${u.scope}:${u.name}`,
`${filter}|${query}`,
(a, b) => {
if (a.activeState === 'failed' && b.activeState !== 'failed') return -1;
if (b.activeState === 'failed' && a.activeState !== 'failed') return 1;
return a.name.localeCompare(b.name);
},
);
const executeAction = useCallback(async (unit: SystemdUnitInfo, action: SystemdUnitAction) => {
const requestedSessionId = sessionId;
setActionBusy(true);
setActionError(null);
try {
const result = await backend.systemServiceAction({
sessionId: requestedSessionId,
unitName: unit.name,
action,
scope: unit.scope,
});
if (sessionIdRef.current !== requestedSessionId) return;
if (result.pending) {
setActionError(t('systemManager.errors.sshChannelUnavailable'));
return;
}
if (!result.success) {
setActionError(result.error || t('systemManager.errors.actionFailed'));
return;
}
void refresh();
} finally {
if (sessionIdRef.current === requestedSessionId) setActionBusy(false);
}
}, [backend, refresh, sessionId, t]);
return (
<SystemPanelShell section="system-manager-services">
<SystemPanelToolbar
trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={isRefreshActive}
onClick={() => void refresh()}
/>
)}
>
<SystemPanelSearch
value={query}
onChange={setQuery}
placeholder={t('systemManager.services.search')}
/>
</SystemPanelToolbar>
<SystemPanelSegmented
value={filter}
onChange={setFilter}
options={[
{ id: 'all', label: t('systemManager.services.filter.all') },
{ id: 'running', label: t('systemManager.services.filter.running') },
{ id: 'failed', label: t('systemManager.services.filter.failed') },
{ id: 'inactive', label: t('systemManager.services.filter.inactive') },
]}
/>
<SystemPanelMetaBar>
{t('systemManager.services.meta', { count: units.length })}
</SystemPanelMetaBar>
{actionError ? <SystemPanelInlineError message={actionError} /> : null}
{error && !(data?.length) ? (
<SystemPanelError
message={error}
onRetry={() => void refresh()}
retryLabel={t('history.action.retry')}
loading={loading}
/>
) : !(data?.length) && (loading || listPending) ? (
<SystemPanelLoading message={t('systemManager.services.loading')} />
) : !units.length ? (
<SystemPanelEmpty icon={Cog} message={t('systemManager.services.empty')} />
) : (
<SystemPanelList>
{units.map((unit) => {
const isActive = unit.activeState === 'active';
return (
<SystemPanelRow
key={`${unit.scope}:${unit.name}`}
title={unit.name}
subtitle={
unit.description
? `${unit.description}${unit.scope === 'user' ? ` · ${t('systemManager.services.scope.user')}` : ''}`
: (unit.scope === 'user' ? t('systemManager.services.scope.user') : unit.subState)
}
trailing={(
<SystemPanelStatusBadge tone={activeTone(unit.activeState)}>
{unit.activeState}
</SystemPanelStatusBadge>
)}
actions={allowMutations ? (
<div className="flex shrink-0 items-center justify-end gap-1">
{!isActive ? (
<SystemPanelRoundButton
title={t('systemManager.services.start')}
onClick={() => setPending({ unit, action: 'start' })}
>
<Play size={12} />
</SystemPanelRoundButton>
) : (
<SystemPanelRoundButton
title={t('systemManager.services.stop')}
onClick={() => setPending({ unit, action: 'stop' })}
>
<Square size={12} />
</SystemPanelRoundButton>
)}
<SystemPanelRoundButton
title={t('systemManager.services.restart')}
onClick={() => setPending({ unit, action: 'restart' })}
>
<RefreshCw size={12} />
</SystemPanelRoundButton>
</div>
) : null}
/>
);
})}
</SystemPanelList>
)}
<SystemPanelConfirmDialog
open={allowMutations && pending !== null}
title={pending ? t(actionTitleKey(pending.action)) : ''}
message={pending
? t('systemManager.services.confirmAction', {
action: t(actionTitleKey(pending.action)),
name: pending.unit.name,
})
: ''}
confirmLabel={pending ? t(actionTitleKey(pending.action)) : ''}
destructive={pending?.action === 'stop' || pending?.action === 'disable'}
busy={actionBusy}
onOpenChange={(open) => {
if (!open && !actionBusy) setPending(null);
}}
onConfirm={() => {
const target = pending;
if (!target) return;
setPending(null);
void executeAction(target.unit, target.action);
}}
/>
</SystemPanelShell>
);
});
@@ -1,4 +1,4 @@
import { Activity, Box, CircuitBoard, Gauge, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
import { Activity, Box, CircuitBoard, Cog, Gauge, LayoutList, Loader2, Network, TerminalSquare } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import React, { memo, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -7,12 +7,18 @@ import type { TerminalSettings } from '../../domain/models';
import type { Host } from '../../domain/models/connection';
import type { SystemManagerSubTab } from '../../domain/systemManager/types';
import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelState';
import { buildSystemManagerTabs, shouldCollectServerStats } from '../../domain/systemManager/systemTarget';
import {
allowSystemManagerMutations,
buildSystemManagerTabs,
shouldCollectServerStats,
} from '../../domain/systemManager/systemTarget';
import type { Snippet, TerminalSession } from '../../types';
import { cn } from '../../lib/utils';
import { DockerManagerTab } from './DockerManagerTab';
import { GpuManagerTab } from './GpuManagerTab';
import { PortsManagerTab } from './PortsManagerTab';
import { ProcessManagerTab } from './ProcessManagerTab';
import { ServicesManagerTab } from './ServicesManagerTab';
import { SystemOverviewTab } from './SystemOverviewTab';
import { TmuxManagerTab } from './TmuxManagerTab';
import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHostHeader';
@@ -69,6 +75,10 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
() => shouldCollectServerStats(sessionHost, capabilities, session),
[capabilities, session, sessionHost],
);
const allowMutations = useMemo(
() => allowSystemManagerMutations(sessionHost),
[sessionHost],
);
const [activeTab, setActiveTab] = useState<SystemManagerSubTab>('overview');
const resolvedTab = availableTabs.includes(activeTab) ? activeTab : 'overview';
@@ -164,6 +174,8 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
const tabDefs: { id: SystemManagerSubTab; icon: LucideIcon; label: string }[] = [
{ id: 'overview', icon: Gauge, label: t('systemManager.tabs.overview') },
{ id: 'processes', icon: LayoutList, label: t('systemManager.tabs.processes') },
{ id: 'ports', icon: Network, label: t('systemManager.tabs.ports') },
{ id: 'services', icon: Cog, label: t('systemManager.tabs.services') },
{ id: 'tmux', icon: TerminalSquare, label: t('systemManager.tabs.tmux') },
{ id: 'docker', icon: Box, label: t('systemManager.tabs.docker') },
{ id: 'gpu', icon: CircuitBoard, label: t('systemManager.tabs.gpu') },
@@ -172,6 +184,12 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
const tmuxReady = capabilities?.hasTmux === true;
const dockerReady = capabilities?.hasDocker === true;
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
const portsReady = (
capabilities?.hasSs === true
|| capabilities?.hasNetstat === true
|| capabilities?.hasLsof === true
);
const servicesReady = capabilities?.hasSystemctl === true;
const tmuxPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'tmux',
ready: tmuxReady,
@@ -187,6 +205,16 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
ready: gpuReady,
capabilitiesKnown: capabilities !== undefined,
});
const portsPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'ports',
ready: portsReady,
capabilitiesKnown: capabilities !== undefined,
});
const servicesPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'services',
ready: servicesReady,
capabilitiesKnown: capabilities !== undefined,
});
return (
<SystemPanelShell section="system-manager-panel">
@@ -224,14 +252,54 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
/>
)}
</div>
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'processes' && 'hidden')}>
<ProcessManagerTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'processes'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
/>
</div>
{availableTabs.includes('processes') ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'processes' && 'hidden')}>
<ProcessManagerTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'processes'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
/>
</div>
) : null}
{portsPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={Network} message={t('systemManager.ports.unavailable')} />
</div>
) : portsPanelState === 'checking' ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : portsPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'ports' && 'hidden')}>
<PortsManagerTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'ports'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
allowMutations={allowMutations}
/>
</div>
) : null}
{servicesPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={Cog} message={t('systemManager.services.unavailable')} />
</div>
) : servicesPanelState === 'checking' ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : servicesPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'services' && 'hidden')}>
<ServicesManagerTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'services'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
allowMutations={allowMutations}
/>
</div>
) : null}
{tmuxPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.unavailable')} />
@@ -15,6 +15,8 @@ const SYSTEM_MANAGER_PANELS = [
"components/systemManager/TmuxSessionCard.tsx",
"components/systemManager/DockerContainersPanel.tsx",
"components/systemManager/DockerImagesPanel.tsx",
"components/systemManager/PortsManagerTab.tsx",
"components/systemManager/ServicesManagerTab.tsx",
] as const;
test("system manager destructive actions use in-app confirm dialogs", () => {
@@ -53,3 +55,36 @@ test("process and docker confirm dialogs reset when sessionId changes", () => {
assert.match(imagesSource, /setActionBusy\(false\)/);
assert.match(imagesSource, /}, \[sessionId\]\);/);
});
test("ports and services confirm dialogs reset when sessionId changes", () => {
const portsSource = readProjectFile("components/systemManager/PortsManagerTab.tsx");
const servicesSource = readProjectFile("components/systemManager/ServicesManagerTab.tsx");
assert.match(portsSource, /setPendingKillPid\(null\)/);
assert.match(portsSource, /setKillBusy\(false\)/);
assert.match(portsSource, /setActionError\(null\)/);
assert.match(portsSource, /}, \[sessionId\]\);/);
assert.match(servicesSource, /setPending\(null\)/);
assert.match(servicesSource, /setActionBusy\(false\)/);
assert.match(servicesSource, /setActionError\(null\)/);
assert.match(servicesSource, /}, \[sessionId\]\);/);
});
test("ports and services surface pending channel results instead of treating them as success", () => {
const portsSource = readProjectFile("components/systemManager/PortsManagerTab.tsx");
const servicesSource = readProjectFile("components/systemManager/ServicesManagerTab.tsx");
assert.match(portsSource, /result\.pending/);
assert.match(portsSource, /systemManager\.errors\.sshChannelUnavailable/);
assert.match(servicesSource, /result\.pending/);
assert.match(servicesSource, /systemManager\.errors\.sshChannelUnavailable/);
});
test("ports and services ignore late action results after session switches", () => {
const portsSource = readProjectFile("components/systemManager/PortsManagerTab.tsx");
const servicesSource = readProjectFile("components/systemManager/ServicesManagerTab.tsx");
assert.match(portsSource, /sessionIdRef\.current !== requestedSessionId/);
assert.match(servicesSource, /sessionIdRef\.current !== requestedSessionId/);
});
+20
View File
@@ -1,6 +1,8 @@
import type {
DockerContainerInfo,
DockerImageInfo,
ListeningPortInfo,
SystemdUnitInfo,
SystemProcessInfo,
TmuxSessionInfo,
} from './types';
@@ -45,3 +47,21 @@ export function dockerImageInfoEqual(a: DockerImageInfo, b: DockerImageInfo): bo
&& a.size === b.size
&& a.createdAt === b.createdAt;
}
export function listeningPortInfoEqual(a: ListeningPortInfo, b: ListeningPortInfo): boolean {
return a.id === b.id
&& a.protocol === b.protocol
&& a.address === b.address
&& a.port === b.port
&& a.pid === b.pid
&& a.processName === b.processName;
}
export function systemdUnitInfoEqual(a: SystemdUnitInfo, b: SystemdUnitInfo): boolean {
return a.name === b.name
&& a.loadState === b.loadState
&& a.activeState === b.activeState
&& a.subState === b.subState
&& a.description === b.description
&& a.scope === b.scope;
}
+82 -29
View File
@@ -1,7 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildSystemManagerTabs, shouldCollectServerStats, shouldShowGpuTab } from "./systemTarget.ts";
import {
allowSystemManagerMutations,
buildSystemManagerTabs,
shouldCollectServerStats,
shouldShowGpuTab,
shouldShowPortsTab,
shouldShowProcessesTab,
shouldShowServicesTab,
} from "./systemTarget.ts";
import type { SessionCapabilities } from "./types.ts";
function caps(partial: Partial<SessionCapabilities> = {}): SessionCapabilities {
return {
targetOs: "linux",
hasTmux: false,
hasDocker: false,
hasNvidiaSmi: false,
hasNpuSmi: false,
hasSs: false,
hasNetstat: false,
hasLsof: false,
hasSystemctl: false,
probedAt: 1,
...partial,
};
}
test("system manager shows overview before detailed management tabs", () => {
assert.deepEqual(buildSystemManagerTabs(null, undefined, null), ["overview", "processes"]);
@@ -9,28 +34,8 @@ test("system manager shows overview before detailed management tabs", () => {
test("gpu tab appears only after nvidia-smi or npu-smi is detected", () => {
assert.equal(shouldShowGpuTab(undefined), false);
assert.equal(
shouldShowGpuTab({
targetOs: "linux",
hasTmux: false,
hasDocker: false,
hasNvidiaSmi: false,
hasNpuSmi: false,
probedAt: 1,
}),
false,
);
assert.equal(
shouldShowGpuTab({
targetOs: "linux",
hasTmux: false,
hasDocker: false,
hasNvidiaSmi: true,
hasNpuSmi: false,
probedAt: 1,
}),
true,
);
assert.equal(shouldShowGpuTab(caps()), false);
assert.equal(shouldShowGpuTab(caps({ hasNvidiaSmi: true })), true);
const host = {
id: "host-gpu",
@@ -41,15 +46,63 @@ test("gpu tab appears only after nvidia-smi or npu-smi is detected", () => {
os: "linux" as const,
};
assert.deepEqual(
buildSystemManagerTabs(host, {
targetOs: "linux",
buildSystemManagerTabs(host, caps({
hasTmux: true,
hasDocker: true,
hasNvidiaSmi: false,
hasNpuSmi: true,
probedAt: 1,
}, null),
["overview", "processes", "tmux", "docker", "gpu"],
hasSs: true,
hasSystemctl: true,
}), null),
["overview", "processes", "ports", "services", "tmux", "docker", "gpu"],
);
});
test("ports and services tabs are detect-first like GPU", () => {
assert.equal(shouldShowPortsTab(undefined), false);
assert.equal(shouldShowPortsTab(caps({ hasSs: true })), true);
assert.equal(shouldShowPortsTab(caps({ hasNetstat: true })), true);
assert.equal(shouldShowPortsTab(caps({ hasLsof: true })), true);
assert.equal(shouldShowServicesTab(undefined), false);
assert.equal(shouldShowServicesTab(caps({ hasSystemctl: true })), true);
});
test("network appliances keep Ports/Services read-only", () => {
const host = {
id: "host-1",
label: "Router",
hostname: "router.local",
username: "admin",
tags: [],
os: "linux" as const,
deviceType: "network" as const,
};
assert.equal(allowSystemManagerMutations(host), false);
assert.equal(allowSystemManagerMutations({
id: "host-2",
label: "Linux",
hostname: "linux.local",
username: "root",
tags: [],
os: "linux" as const,
}), true);
});
test("network devices hide processes until OS probe confirms a real target", () => {
const host = {
id: "host-1",
label: "Router",
hostname: "router.local",
username: "admin",
tags: [],
os: "linux" as const,
deviceType: "network" as const,
};
assert.equal(shouldShowProcessesTab(host, undefined), false);
assert.deepEqual(buildSystemManagerTabs(host, undefined, null), ["overview"]);
assert.equal(shouldShowProcessesTab(host, caps({ targetOs: "linux" })), true);
assert.deepEqual(
buildSystemManagerTabs(host, caps({ targetOs: "linux", hasSs: true }), null),
["overview", "processes", "ports"],
);
});
+52 -3
View File
@@ -8,13 +8,18 @@ const hasKnownUnsupportedSystemDistro = (host: Host | null | undefined): boolean
return !!detectedDistro && classifyDistroId(detectedDistro) === 'other';
};
export function isNetworkDeviceTarget(host: Host | null | undefined): boolean {
if (host?.deviceType === 'network') return true;
return classifyDistroId(host?.distro) === 'network-device';
}
export function isDefiniteLinuxTarget(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
session: TerminalSession | null | undefined,
): boolean {
if (capabilities?.targetOs === 'linux') return true;
if (host?.deviceType === 'network') return false;
if (isNetworkDeviceTarget(host)) return false;
if (hasKnownUnsupportedSystemDistro(host)) return false;
if (host?.os === 'linux') return true;
if (classifyDistroId(getEffectiveHostDistro(host)) === 'linux-like') return true;
@@ -22,11 +27,25 @@ export function isDefiniteLinuxTarget(
return false;
}
export function shouldShowProcessesTab(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
): boolean {
// Network appliances often lack a usable process table; only show after OS probe confirms.
if (isNetworkDeviceTarget(host)) {
const os = capabilities?.targetOs;
return os === 'linux' || os === 'darwin' || os === 'win32';
}
return true;
}
export function shouldShowTmuxTab(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
session: TerminalSession | null | undefined,
): boolean {
// Network appliances: detect-first only — do not guess from a Linux-like probe.
if (isNetworkDeviceTarget(host)) return capabilities?.hasTmux === true;
if (isDefiniteLinuxTarget(host, capabilities, session)) return true;
if (capabilities?.targetOs === 'darwin') return true;
if (host?.os === 'macos') return true;
@@ -39,6 +58,8 @@ export function shouldShowDockerTab(
session: TerminalSession | null | undefined,
): boolean {
if (capabilities?.hasDocker === true) return true;
// Network appliances: never show Docker from an OS guess alone.
if (isNetworkDeviceTarget(host)) return false;
return isDefiniteLinuxTarget(host, capabilities, session);
}
@@ -49,13 +70,38 @@ export function shouldShowGpuTab(
return capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
}
/** Ports tab only appears after a collector binary is detected (same detect-first model as GPU). */
export function shouldShowPortsTab(
capabilities: SessionCapabilities | undefined,
): boolean {
return (
capabilities?.hasSs === true
|| capabilities?.hasNetstat === true
|| capabilities?.hasLsof === true
);
}
/** Destructive port/service actions stay off for network appliances. */
export function allowSystemManagerMutations(
host: Host | null | undefined,
): boolean {
return !isNetworkDeviceTarget(host);
}
/** Services tab only appears after systemctl is detected. */
export function shouldShowServicesTab(
capabilities: SessionCapabilities | undefined,
): boolean {
return capabilities?.hasSystemctl === true;
}
export function shouldCollectServerStats(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
session: TerminalSession | null | undefined,
): boolean {
const detectedDeviceClass = classifyDistroId(host?.distro);
if (host?.deviceType === 'network' || detectedDeviceClass === 'network-device') return false;
if (isNetworkDeviceTarget(host) || detectedDeviceClass === 'network-device') return false;
if (capabilities?.targetOs === 'linux' || capabilities?.targetOs === 'darwin') return true;
if (hasKnownUnsupportedSystemDistro(host)) return false;
if (host?.os === 'linux' || host?.os === 'macos') return true;
@@ -69,7 +115,10 @@ export function buildSystemManagerTabs(
capabilities: SessionCapabilities | undefined,
session: TerminalSession | null | undefined,
): SystemManagerSubTab[] {
const tabs: SystemManagerSubTab[] = ['overview', 'processes'];
const tabs: SystemManagerSubTab[] = ['overview'];
if (shouldShowProcessesTab(host, capabilities)) tabs.push('processes');
if (shouldShowPortsTab(capabilities)) tabs.push('ports');
if (shouldShowServicesTab(capabilities)) tabs.push('services');
if (shouldShowTmuxTab(host, capabilities, session)) tabs.push('tmux');
if (shouldShowDockerTab(host, capabilities, session)) tabs.push('docker');
if (shouldShowGpuTab(capabilities)) tabs.push('gpu');
+52 -1
View File
@@ -6,9 +6,53 @@ export interface SessionCapabilities {
hasDocker: boolean;
hasNvidiaSmi: boolean;
hasNpuSmi: boolean;
/** `ss` binary present (preferred listening-port collector). */
hasSs?: boolean;
/** `netstat` binary present (ports fallback). */
hasNetstat?: boolean;
/** `lsof` binary present (macOS / process-aware ports fallback). */
hasLsof?: boolean;
/** `systemctl` binary present. */
hasSystemctl?: boolean;
probedAt: number;
}
export type ListeningPortProtocol = 'tcp' | 'udp' | 'tcp6' | 'udp6' | 'unknown';
export interface ListeningPortInfo {
protocol: ListeningPortProtocol;
address: string;
port: number;
pid: number | null;
processName: string;
/** Stable row id for list merging: protocol|address|port|pid */
id: string;
}
export type SystemdUnitActiveState =
| 'active'
| 'inactive'
| 'failed'
| 'activating'
| 'deactivating'
| 'reloading'
| 'unknown';
export type SystemdUnitLoadState = 'loaded' | 'not-found' | 'bad-setting' | 'error' | 'masked' | 'unknown';
export type SystemdUnitSubState = string;
export interface SystemdUnitInfo {
name: string;
loadState: SystemdUnitLoadState;
activeState: SystemdUnitActiveState;
subState: SystemdUnitSubState;
description: string;
/** system or --user instance */
scope: 'system' | 'user';
}
export type SystemdUnitAction = 'start' | 'stop' | 'restart' | 'enable' | 'disable' | 'reload';
export type AcceleratorVendor = 'nvidia' | 'ascend';
export interface AcceleratorDeviceInfo {
@@ -154,7 +198,14 @@ export type DockerImageManageAction =
| { action: 'prune'; all?: boolean }
| { action: 'tag'; imageId: string; repository: string; tag?: string };
export type SystemManagerSubTab = 'overview' | 'processes' | 'tmux' | 'docker' | 'gpu';
export type SystemManagerSubTab =
| 'overview'
| 'processes'
| 'ports'
| 'services'
| 'tmux'
| 'docker'
| 'gpu';
export interface TerminalPopupIcon {
kind: 'image';
+458
View File
@@ -0,0 +1,458 @@
/* eslint-disable no-undef */
"use strict";
/**
* Listening-port collectors.
* Parsing approach inspired by Portwatch (ss -tlnp + process field), adapted for
* remote SSH exec, UDP, IPv6, macOS netstat/lsof, and BusyBox netstat.
*/
const LISTEN_PORTS_INNER = [
'printf "%s\\n" "__NC_PORTS_BEGIN__"; ',
// Run every available collector. macOS often has netstat without useful PID
// columns; lsof fills that gap. Prefer merging over elif exclusivity.
'if command -v ss >/dev/null 2>&1; then ',
'printf "%s\\n" "__NC_SS__"; ',
"ss -H -tulnp 2>/dev/null || ss -tulnp 2>/dev/null || true; ",
"fi; ",
'if command -v netstat >/dev/null 2>&1; then ',
'printf "%s\\n" "__NC_NETSTAT__"; ',
// `-lntp` is TCP-only; prefer `-lntup`, else TCP+UDP separately. macOS uses `-anv -p`.
"if netstat -lntup 2>/dev/null; then :; ",
"elif netstat -lntp 2>/dev/null; then ",
"netstat -lnup 2>/dev/null || true; ",
"elif netstat -anv -p tcp >/dev/null 2>&1; then ",
// Prefer LISTEN-only TCP to keep stdout under maxBuffer on busy hosts.
"netstat -anv -p tcp 2>/dev/null | grep -i LISTEN || true; ",
"netstat -anv -p udp 2>/dev/null || true; ",
"else ",
"netstat -anp 2>/dev/null | grep -Ei 'LISTEN|^udp' || netstat -an 2>/dev/null | grep -Ei 'LISTEN|^udp' || true; ",
"fi; ",
"fi; ",
'if command -v lsof >/dev/null 2>&1; then ',
'printf "%s\\n" "__NC_LSOF__"; ',
"lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null || true; ",
// Idle-only: Linux has no UDP state names, so this no-ops there (ss/netstat cover UDP).
// Do not fall back to bare `-iUDP` — that dumps client binds as fake listeners.
"lsof -nP -iUDP -sUDP:Idle 2>/dev/null || true; ",
"fi; ",
'printf "%s\\n" "__NC_PORTS_END__"',
].join("");
const LISTEN_PORTS_SCRIPT = `exec sh -c ${JSON.stringify(LISTEN_PORTS_INNER)}`;
const LISTEN_PORTS_WINDOWS = [
'Write-Output "__NC_PORTS_BEGIN__"; ',
'Write-Output "__NC_WIN__"; ',
"$rows = @(); ",
"$rows += @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ",
"Select-Object LocalAddress,LocalPort,OwningProcess,@{Name='Protocol';Expression={'tcp'}}); ",
// UDP has no listen state; drop high ephemeral ports on non-wildcard/non-loopback
// addresses so client binds do not flood the Ports tab.
"$rows += @(Get-NetUDPEndpoint -ErrorAction SilentlyContinue | Where-Object { ",
"$a = [string]$_.LocalAddress; ",
"$wildcard = ($a -eq '0.0.0.0' -or $a -eq '::' -or $a -eq '*'); ",
"$loopback = ($a -eq '127.0.0.1' -or $a -eq '::1'); ",
"if ($wildcard -or $loopback) { $true } else { $_.LocalPort -lt 49152 } ",
"} | Select-Object LocalAddress,LocalPort,OwningProcess,@{Name='Protocol';Expression={'udp'}}); ",
"if ($rows.Count -gt 0) { $rows | ConvertTo-Json -Compress } else { Write-Output '[]' }; ",
'Write-Output "__NC_PORTS_END__"',
].join("");
const LISTEN_PORTS_MAX_BUFFER = 16 * 1024 * 1024;
function normalizeProtocol(raw) {
const text = String(raw || "").trim().toLowerCase();
if (text === "tcp" || text === "tcp4") return "tcp";
// macOS dual-stack (tcp46/udp46) is an IPv6 socket with v6only off; map to
// tcp6/udp6 so netstat rows merge with lsof IPv6 listeners.
if (text === "tcp6" || text === "tcp46") return "tcp6";
if (text === "udp" || text === "udp4") return "udp";
if (text === "udp6" || text === "udp46") return "udp6";
return "unknown";
}
function parseListenAddress(addr) {
const text = String(addr || "").trim();
if (!text) return null;
// macOS / BSD netstat: "*.22", "127.0.0.1.53", "::1.631", "fe80::1%lo0.22"
// Prefer dotted port even when the address contains ':' (IPv6).
const dotted = text.match(/^(.*)\.(\d+)$/);
if (dotted) {
const port = Number(dotted[2]);
if (Number.isFinite(port) && port >= 0 && port <= 65535) {
let address = dotted[1] || "*";
if (address.includes("%")) address = address.split("%")[0];
if (address === "*" || address === "0.0.0.0" || address === "::") address = "*";
return { address, port };
}
}
const lastColon = text.lastIndexOf(":");
if (lastColon <= 0) return null;
const portText = text.slice(lastColon + 1);
if (!/^\d+$/.test(portText)) return null;
const port = Number(portText);
if (!Number.isFinite(port) || port < 0 || port > 65535) return null;
let address = text.slice(0, lastColon);
if (address.startsWith("[") && address.endsWith("]")) {
address = address.slice(1, -1);
}
if (address.includes("%")) address = address.split("%")[0];
if (address === "*" || address === "0.0.0.0" || address === "::") {
address = "*";
}
return { address, port };
}
function parseSsProcesses(info) {
const text = String(info || "");
// users:(("app",pid=11,fd=3),("app",pid=12,fd=4))
const entries = [];
const re = /\("([^"]+)",pid=(\d+)/g;
let match = re.exec(text);
while (match) {
entries.push({ processName: match[1] || "", pid: Number(match[2]) });
match = re.exec(text);
}
if (entries.length) return entries;
return [{ processName: "", pid: null }];
}
function portKey(protocol, address, port, pid) {
// Include pid so SO_REUSEPORT / multi-process listeners stay distinct.
return `${normalizeProtocol(protocol)}|${address || "*"}|${port}|${pid == null ? "-" : pid}`;
}
function makePortId(protocol, address, port, pid) {
return `${normalizeProtocol(protocol)}|${address}|${port}|${pid == null ? "-" : pid}`;
}
function sameSocket(a, protocol, address, port) {
return a.protocol === protocol && a.address === address && a.port === port;
}
function pushPort(entries, byKey, row) {
if (!row || !Number.isFinite(row.port)) return;
const protocol = normalizeProtocol(row.protocol);
const address = row.address || "*";
const port = Number(row.port);
const pid = Number.isFinite(row.pid) && row.pid > 0 ? Number(row.pid) : null;
const processName = String(row.processName || "");
if (pid != null) {
// Drop anonymous placeholder once a PID-bearing collector reports the socket.
const anonKey = portKey(protocol, address, port, null);
const anon = byKey.get(anonKey);
if (anon) {
byKey.delete(anonKey);
const idx = entries.indexOf(anon);
if (idx >= 0) entries.splice(idx, 1);
}
} else {
for (const existing of byKey.values()) {
if (sameSocket(existing, protocol, address, port) && existing.pid != null) {
return;
}
}
}
const key = portKey(protocol, address, port, pid);
const existing = byKey.get(key);
if (existing) {
if (!existing.processName && processName) existing.processName = processName;
return;
}
const entry = {
id: makePortId(protocol, address, port, pid),
protocol,
address,
port,
pid,
processName,
};
byKey.set(key, entry);
entries.push(entry);
}
function isWildcardPeer(peer) {
const text = String(peer || "").trim();
return (
text === "*.*"
|| text === "*:*"
|| text === "0.0.0.0:*"
|| text === ":::*"
|| text === "[::]:*"
|| text === "*."
);
}
function parseSsOutput(stdout) {
const entries = [];
const byKey = new Map();
for (const line of String(stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
if (/^Netid\b/i.test(trimmed) || /^State\b/i.test(trimmed)) continue;
const parts = trimmed.split(/\s+/);
// Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
if (parts.length < 5) continue;
let protocol;
let state = "";
let localAddr;
let peerAddr = "";
let processField = "";
if (/^(tcp|udp)/i.test(parts[0])) {
protocol = parts[0];
// With state column: parts[1]=state, parts[4]=local, parts[5]=peer
if (parts.length >= 6 && (parts[4].includes(":") || parts[4].includes("."))) {
state = parts[1] || "";
localAddr = parts[4];
peerAddr = parts[5] || "";
processField = parts.slice(6).join(" ");
} else {
localAddr = parts[3];
peerAddr = parts[4] || "";
processField = parts.slice(5).join(" ");
}
} else {
continue;
}
const isUdp = /^udp/i.test(protocol);
if (!isUdp && state && !/^LISTEN$/i.test(state)) continue;
if (isUdp && state && !/^(UNCONN|IDLE|LISTEN)$/i.test(state)) continue;
if (isUdp && peerAddr && !isWildcardPeer(peerAddr)) continue;
const parsed = parseListenAddress(localAddr);
if (!parsed) continue;
for (const proc of parseSsProcesses(processField)) {
pushPort(entries, byKey, {
protocol,
address: parsed.address,
port: parsed.port,
pid: proc.pid,
processName: proc.processName,
});
}
}
return entries;
}
function parseNetstatOutput(stdout) {
const entries = [];
const byKey = new Map();
for (const line of String(stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
if (/^Proto\b/i.test(trimmed) || /^Active\b/i.test(trimmed)) continue;
// Linux: tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1234/sshd
// BusyBox UDP often omits state: udp 0 0 127.0.0.1:53 0.0.0.0:* 456/dnsmasq
// macOS: tcp4 0 0 *.22 *.* LISTEN
const m = trimmed.match(
/^(tcp46|udp46|tcp[46]?|udp[46]?)\s+\d+\s+\d+\s+(\S+)\s+(\S+)(?:\s+(.*))?$/i,
);
if (!m) continue;
const protocol = m[1];
const local = m[2];
const peer = m[3];
const rest = String(m[4] || "").trim();
const isUdp = /^udp/i.test(protocol);
let state = "";
let pidField = "";
if (rest) {
const restParts = rest.split(/\s+/);
if (/^LISTEN$/i.test(restParts[0])) {
state = "LISTEN";
pidField = restParts.slice(1).join(" ");
} else if (isUdp && /^\d+\//.test(restParts[0])) {
// No State column — remainder is pid/program (may contain spaces).
pidField = rest;
} else if (!isUdp) {
// TCP without LISTEN (ESTABLISHED, or a bare pid token) is not a listener.
continue;
}
}
if (!isUdp) {
if (!/^LISTEN$/i.test(state)) continue;
} else if (!isWildcardPeer(peer)) {
continue;
}
const parsed = parseListenAddress(local);
if (!parsed) continue;
let pid = null;
let processName = "";
const pidMatch = String(pidField).match(/^(\d+)\/(.+)$/);
if (pidMatch) {
pid = Number(pidMatch[1]);
processName = pidMatch[2];
}
pushPort(entries, byKey, {
protocol,
address: parsed.address,
port: parsed.port,
pid,
processName,
});
}
return entries;
}
function parseLsofOutput(stdout) {
const entries = [];
const byKey = new Map();
for (const line of String(stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed || /^COMMAND\b/i.test(trimmed)) continue;
// COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
const parts = trimmed.split(/\s+/);
if (parts.length < 9) continue;
const processName = parts[0];
const pid = Number(parts[1]);
const typeField = String(parts[4] || "").toUpperCase();
const nodeField = String(parts[7] || "").toUpperCase();
const nameField = parts.slice(8).join(" ");
if (nameField.includes("->")) continue;
const isUdpNode = nodeField === "UDP" || /\bUDP\b/.test(nameField);
const isTcpNode = nodeField === "TCP" || /\bTCP\b/.test(nameField) || /\(LISTEN\)/i.test(nameField);
if (!isUdpNode && !isTcpNode) continue;
if (isTcpNode && !/\(LISTEN\)/i.test(nameField)) continue;
// NAME forms: "TCP *:80 (LISTEN)", "TCP [::1]:80 (LISTEN)", "*:22 (LISTEN)"
const cleaned = nameField
.replace(/^(?:TCP|UDP)\s+/i, "")
.replace(/\s+\((LISTEN|UDP)\)\s*$/i, "")
.trim();
const parsed = parseListenAddress(cleaned);
if (!parsed) continue;
let protocol = isUdpNode ? "udp" : "tcp";
// typeField is uppercased; match IPV6 / IPv6 before uppercasing would also work.
if (typeField.includes("IPV6") || parsed.address.includes(":")) {
protocol = isUdpNode ? "udp6" : "tcp6";
}
pushPort(entries, byKey, {
protocol,
address: parsed.address,
port: parsed.port,
pid: Number.isFinite(pid) ? pid : null,
processName,
});
}
return entries;
}
function parseWindowsPortsJson(stdout) {
const entries = [];
const byKey = new Map();
const text = String(stdout || "").trim();
if (!text) return entries;
let raw;
try {
raw = JSON.parse(text);
} catch {
return entries;
}
const list = Array.isArray(raw) ? raw : [raw];
for (const row of list) {
if (!row) continue;
const port = Number(row.LocalPort);
const pid = Number(row.OwningProcess);
let address = String(row.LocalAddress || "*");
const isV6 = address.includes(":");
if (address === "0.0.0.0" || address === "::" || address === "*") address = "*";
const protoRaw = String(row.Protocol || "tcp").toLowerCase();
const isUdp = protoRaw.startsWith("udp");
pushPort(entries, byKey, {
protocol: isUdp ? (isV6 ? "udp6" : "udp") : (isV6 ? "tcp6" : "tcp"),
address,
port,
pid: Number.isFinite(pid) && pid > 0 ? pid : null,
processName: "",
});
}
return entries;
}
function extractSection(stdout, beginMarker) {
const text = String(stdout || "");
const begin = text.indexOf(beginMarker);
if (begin < 0) return "";
const after = text.slice(begin + beginMarker.length);
const end = after.search(/\n__NC_(SS|NETSTAT|LSOF|WIN|PORTS_END)__/);
return end >= 0 ? after.slice(0, end) : after;
}
function sortPorts(entries) {
return entries.slice().sort((a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol));
}
function parseListeningPorts(stdout) {
const text = String(stdout || "");
const entries = [];
const byKey = new Map();
const merge = (rows) => {
for (const row of rows) {
pushPort(entries, byKey, row);
}
};
if (text.includes("__NC_SS__")) merge(parseSsOutput(extractSection(text, "__NC_SS__")));
if (text.includes("__NC_NETSTAT__")) merge(parseNetstatOutput(extractSection(text, "__NC_NETSTAT__")));
if (text.includes("__NC_LSOF__")) merge(parseLsofOutput(extractSection(text, "__NC_LSOF__")));
if (text.includes("__NC_WIN__")) merge(parseWindowsPortsJson(extractSection(text, "__NC_WIN__")));
if (entries.length) return sortPorts(entries);
// Bare output without markers (fallback probes)
merge(parseSsOutput(text));
if (entries.length) return sortPorts(entries);
merge(parseNetstatOutput(text));
if (entries.length) return sortPorts(entries);
merge(parseLsofOutput(text));
return sortPorts(entries);
}
function createPortOpsApi({
execOnSession,
execOnLocalMachine,
isLocalSession,
process,
}) {
async function listListeningPorts(event, sessionId) {
if (!sessionId) return { success: false, error: "Missing sessionId" };
if (isLocalSession(sessionId) && process.platform === "win32") {
const result = await execOnLocalMachine(LISTEN_PORTS_WINDOWS, 12000, {
maxBuffer: LISTEN_PORTS_MAX_BUFFER,
});
if (!result.success) return { success: false, error: result.error || "Failed to list ports" };
return { success: true, ports: parseListeningPorts(result.stdout) };
}
const result = await execOnSession(event, sessionId, LISTEN_PORTS_SCRIPT, 12000, {
maxBuffer: LISTEN_PORTS_MAX_BUFFER,
});
if (result.pending) return { success: false, pending: true };
if (!result.success) return { success: false, error: result.error || "Failed to list ports" };
return { success: true, ports: parseListeningPorts(result.stdout) };
}
return {
listListeningPorts,
parseListeningPorts,
parseSsOutput,
parseNetstatOutput,
parseLsofOutput,
};
}
module.exports = {
createPortOpsApi,
parseListeningPorts,
parseSsOutput,
parseNetstatOutput,
parseLsofOutput,
LISTEN_PORTS_SCRIPT,
};
@@ -0,0 +1,248 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const {
parseSsOutput,
parseNetstatOutput,
parseLsofOutput,
parseListeningPorts,
} = require("./portOps.cjs");
test("parseSsOutput reads tcp/udp listeners and process fields", () => {
const sample = `
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1234,fd=3))
tcp LISTEN 0 511 *:80 *:* users:(("nginx",pid=99,fd=6))
udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:* users:(("systemd-resolve",pid=500,fd=12))
tcp6 LISTEN 0 128 [::]:443 [::]:* users:(("nginx",pid=99,fd=7))
`;
const ports = parseSsOutput(sample);
assert.equal(ports.length, 4);
assert.deepEqual(
ports.find((p) => p.port === 22),
{
id: "tcp|*|22|1234",
protocol: "tcp",
address: "*",
port: 22,
pid: 1234,
processName: "sshd",
},
);
assert.equal(ports.find((p) => p.port === 80)?.processName, "nginx");
assert.equal(ports.find((p) => p.port === 53)?.protocol, "udp");
assert.equal(ports.find((p) => p.port === 443)?.protocol, "tcp6");
});
test("parseNetstatOutput maps pid/program and keeps BusyBox UDP without PID", () => {
const sample = `
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1234/sshd
tcp6 0 0 :::80 :::* LISTEN 99/nginx
udp 0 0 127.0.0.1:53 0.0.0.0:*
udp 0 0 0.0.0.0:5353 0.0.0.0:* 456/dnsmasq
`;
const ports = parseNetstatOutput(sample);
assert.equal(ports.length, 4);
assert.equal(ports.find((p) => p.port === 22)?.processName, "sshd");
assert.equal(ports.find((p) => p.port === 80)?.address, "*");
assert.equal(ports.find((p) => p.port === 53)?.protocol, "udp");
assert.equal(ports.find((p) => p.port === 53)?.pid, null);
assert.equal(ports.find((p) => p.port === 5353)?.pid, 456);
assert.equal(ports.find((p) => p.port === 5353)?.processName, "dnsmasq");
});
test("parseNetstatOutput keeps spaced UDP program names without State", () => {
const sample = `
udp 0 0 0.0.0.0:5353 0.0.0.0:* 882/avahi-daemon: r
udp 0 0 0.0.0.0:53 0.0.0.0:* 100/named -u bind
`;
const ports = parseNetstatOutput(sample);
assert.equal(ports.find((p) => p.port === 5353)?.pid, 882);
assert.equal(ports.find((p) => p.port === 5353)?.processName, "avahi-daemon: r");
assert.equal(ports.find((p) => p.port === 53)?.pid, 100);
assert.equal(ports.find((p) => p.port === 53)?.processName, "named -u bind");
});
test("parseNetstatOutput understands macOS dotted addresses", () => {
const sample = `
Active Internet connections
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 *.22 *.* LISTEN
tcp4 0 0 127.0.0.1.631 *.* LISTEN
`;
const ports = parseNetstatOutput(sample);
assert.equal(ports.length, 2);
assert.equal(ports.find((p) => p.port === 22)?.address, "*");
assert.equal(ports.find((p) => p.port === 631)?.address, "127.0.0.1");
});
test("parseListeningPorts merges ss/netstat/lsof and prefers process-aware rows", () => {
const stdout = `
__NC_PORTS_BEGIN__
__NC_NETSTAT__
tcp4 0 0 *.22 *.* LISTEN
__NC_LSOF__
sshd 1234 root 3u IPv4 0x1 0t0 TCP *:22 (LISTEN)
__NC_PORTS_END__
`;
const ports = parseListeningPorts(stdout);
assert.equal(ports.length, 1);
assert.equal(ports[0].port, 22);
assert.equal(ports[0].pid, 1234);
assert.equal(ports[0].processName, "sshd");
});
test("parseLsofOutput reads TCP LISTEN rows", () => {
const sample = `
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
nginx 99 root 6u IPv4 0xabc 0t0 TCP *:80 (LISTEN)
`;
const ports = parseLsofOutput(sample);
assert.equal(ports.length, 1);
assert.equal(ports[0].port, 80);
assert.equal(ports[0].processName, "nginx");
});
test("parseNetstatOutput rejects established TCP and connected UDP", () => {
const sample = `
tcp 0 0 10.0.0.1:22 10.0.0.2:40000 ESTABLISHED 1234/sshd
tcp 0 0 10.0.0.1:22 10.0.0.2:40000 1234/sshd
udp 0 0 10.0.0.5:68 10.0.0.1:67
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 9/sshd
`;
const ports = parseNetstatOutput(sample);
assert.equal(ports.length, 1);
assert.equal(ports[0].port, 22);
assert.equal(ports[0].processName, "sshd");
});
test("parseLsofOutput ignores connected UDP and non-listen TCP", () => {
const sample = `
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
chrome 1000 user 10u IPv4 0x1 0t0 UDP 10.0.0.1:53122->8.8.8.8:53
nginx 99 root 6u IPv4 0xabc 0t0 TCP 10.0.0.1:80->10.0.0.2:12345 (ESTABLISHED)
sshd 1234 root 3u IPv4 0x2 0t0 TCP *:22 (LISTEN)
`;
const ports = parseLsofOutput(sample);
assert.equal(ports.length, 1);
assert.equal(ports[0].port, 22);
});
test("parseLsofOutput keeps bracketed IPv6 listeners as tcp6", () => {
const sample = `
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
nginx 99 root 7u IPv6 0xabc 0t0 TCP [::1]:80 (LISTEN)
`;
const ports = parseLsofOutput(sample);
assert.equal(ports.length, 1);
assert.equal(ports[0].protocol, "tcp6");
assert.equal(ports[0].address, "::1");
assert.equal(ports[0].port, 80);
});
test("parseNetstatOutput keeps macOS IPv6 dotted ports", () => {
const sample = `
tcp6 0 0 ::1.631 *.* LISTEN
tcp6 0 0 fe80::1%lo0.22 *.* LISTEN
`;
const ports = parseNetstatOutput(sample);
assert.equal(ports.length, 2);
assert.equal(ports.find((p) => p.port === 631)?.address, "::1");
assert.equal(ports.find((p) => p.port === 22)?.address, "fe80::1");
});
test("parseSsOutput rejects UDP with non-wildcard peers", () => {
const sample = `
udp UNCONN 0 0 10.0.0.5:68 10.0.0.1:67 users:(("dhclient",pid=7,fd=1))
udp UNCONN 0 0 0.0.0.0:5353 0.0.0.0:* users:(("avahi",pid=8,fd=1))
`;
const ports = parseSsOutput(sample);
assert.equal(ports.length, 1);
assert.equal(ports[0].port, 5353);
});
test("parseListeningPorts merges ss tcp6 with lsof IPv6 without duplicating", () => {
const stdout = `
__NC_PORTS_BEGIN__
__NC_SS__
tcp6 LISTEN 0 128 [::]:80 [::]:* users:(("nginx",pid=99,fd=7))
__NC_LSOF__
nginx 99 root 7u IPv6 0xabc 0t0 TCP *:80 (LISTEN)
__NC_PORTS_END__
`;
const ports = parseListeningPorts(stdout);
assert.equal(ports.length, 1);
assert.equal(ports[0].protocol, "tcp6");
assert.equal(ports[0].pid, 99);
});
test("parseListeningPorts keeps SO_REUSEPORT listeners as separate pid rows", () => {
const sample = `
tcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("app",pid=11,fd=3))
tcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("app",pid=12,fd=3))
`;
const ports = parseSsOutput(sample);
assert.equal(ports.length, 2);
assert.deepEqual(ports.map((p) => p.pid).sort((a, b) => a - b), [11, 12]);
});
test("parseListeningPorts reads Windows UDP endpoints from Protocol field", () => {
const stdout = `
__NC_PORTS_BEGIN__
__NC_WIN__
[{"LocalAddress":"0.0.0.0","LocalPort":53,"OwningProcess":500,"Protocol":"udp"},{"LocalAddress":"::","LocalPort":22,"OwningProcess":1234,"Protocol":"tcp"}]
__NC_PORTS_END__
`;
const ports = parseListeningPorts(stdout);
assert.equal(ports.length, 2);
assert.equal(ports.find((p) => p.port === 53)?.protocol, "udp");
assert.equal(ports.find((p) => p.port === 22)?.protocol, "tcp6");
});
test("parseLsofOutput keeps Idle UDP listeners", () => {
const sample = `
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
mdnsd 200 root 12u IPv4 0x1 0t0 UDP *:5353
`;
const ports = parseLsofOutput(sample);
assert.equal(ports.length, 1);
assert.equal(ports[0].protocol, "udp");
assert.equal(ports[0].port, 5353);
});
test("parseNetstatOutput accepts macOS tcp46/udp46 dual-stack rows", () => {
const sample = `
tcp46 0 0 *.80 *.* LISTEN
udp46 0 0 *.5353 *.*
`;
const ports = parseNetstatOutput(sample);
assert.equal(ports.length, 2);
assert.equal(ports.find((p) => p.port === 80)?.protocol, "tcp6");
assert.equal(ports.find((p) => p.port === 5353)?.protocol, "udp6");
});
test("parseListeningPorts merges macOS tcp46 netstat with lsof IPv6", () => {
const stdout = `
__NC_PORTS_BEGIN__
__NC_NETSTAT__
tcp46 0 0 *.80 *.* LISTEN
__NC_LSOF__
nginx 99 root 7u IPv6 0xabc 0t0 TCP *:80 (LISTEN)
__NC_PORTS_END__
`;
const ports = parseListeningPorts(stdout);
assert.equal(ports.length, 1);
assert.equal(ports[0].protocol, "tcp6");
assert.equal(ports[0].pid, 99);
});
test("parseSsOutput expands multi-pid users tuples", () => {
const sample = `
tcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("app",pid=11,fd=3),("app",pid=12,fd=4))
`;
const ports = parseSsOutput(sample);
assert.equal(ports.length, 2);
assert.deepEqual(ports.map((p) => p.pid).sort((a, b) => a - b), [11, 12]);
});
@@ -0,0 +1,230 @@
/* eslint-disable no-undef */
"use strict";
/**
* systemd service management over SSH exec.
* List/status via systemctl; mutate with optional sudo (same session password
* pattern as dockerOps).
*/
function shQuote(str) {
return `'${String(str).replace(/'/g, `'\"'\"'`)}'`;
}
function sanitizeUnitName(name) {
const trimmed = String(name || "").trim().slice(0, 256);
// systemd unit names: letters, digits, : . _ @ - and must end with a type suffix ideally
if (!trimmed || !/^[A-Za-z0-9:._@\\-]+$/.test(trimmed)) return null;
return trimmed;
}
const ALLOWED_ACTIONS = new Set(["start", "stop", "restart", "enable", "disable", "reload"]);
function normalizeActiveState(raw) {
const text = String(raw || "").trim().toLowerCase();
if (
text === "active"
|| text === "inactive"
|| text === "failed"
|| text === "activating"
|| text === "deactivating"
|| text === "reloading"
) {
return text;
}
return "unknown";
}
function normalizeLoadState(raw) {
const text = String(raw || "").trim().toLowerCase();
if (
text === "loaded"
|| text === "not-found"
|| text === "bad-setting"
|| text === "error"
|| text === "masked"
) {
return text;
}
return "unknown";
}
function parseSystemctlListUnits(stdout, scope) {
const units = [];
for (const line of String(stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const cleaned = trimmed.replace(/^●\s*/, "");
if (!cleaned || /^UNIT\b/i.test(cleaned) || /^Legend:/i.test(cleaned)) continue;
if (/^\d+ loaded units listed/i.test(cleaned)) continue;
if (/^To show all/i.test(cleaned)) continue;
// UNIT LOAD ACTIVE SUB [DESCRIPTION] — description may be empty
const m = cleaned.match(/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$/);
if (!m) continue;
const name = m[1];
if (!name.includes(".")) continue;
units.push({
name,
loadState: normalizeLoadState(m[2]),
activeState: normalizeActiveState(m[3]),
subState: m[4],
description: (m[5] || "").trim(),
scope,
});
}
return units;
}
function getSessionSudoPassword(session) {
return typeof session?.systemManagerSudoPassword === "string" && session.systemManagerSudoPassword.length > 0
? session.systemManagerSudoPassword
: null;
}
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 isPermissionDenied(result) {
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
return text.includes("permission denied")
|| text.includes("access denied")
|| text.includes("authentication is required")
|| text.includes("interactive authentication required")
|| text.includes("not authorized");
}
const LIST_UNITS_INNER = [
'printf "%s\\n" "__NC_SERVICES_BEGIN__"; ',
'if command -v systemctl >/dev/null 2>&1; then ',
'printf "%s\\n" "__NC_SYSTEM__"; ',
// --plain needs systemd >= ~230; fall back for RHEL/CentOS 7-era hosts.
"systemctl list-units --type=service --all --no-pager --no-legend --plain 2>/dev/null ",
"|| systemctl list-units --type=service --all --no-pager --no-legend 2>/dev/null ",
"|| true; ",
'printf "%s\\n" "__NC_USER__"; ',
"systemctl --user list-units --type=service --all --no-pager --no-legend --plain 2>/dev/null ",
"|| systemctl --user list-units --type=service --all --no-pager --no-legend 2>/dev/null ",
"|| true; ",
"fi; ",
'printf "%s\\n" "__NC_SERVICES_END__"',
].join("");
const LIST_UNITS_SCRIPT = `exec sh -c ${JSON.stringify(LIST_UNITS_INNER)}`;
function extractBetween(stdout, startMarker, endMarkers) {
const text = String(stdout || "");
const begin = text.indexOf(startMarker);
if (begin < 0) return "";
const after = text.slice(begin + startMarker.length);
let end = -1;
for (const marker of endMarkers) {
const idx = after.indexOf(marker);
if (idx >= 0 && (end < 0 || idx < end)) end = idx;
}
return end >= 0 ? after.slice(0, end) : after;
}
function parseServiceList(stdout) {
const text = String(stdout || "");
const systemPart = extractBetween(text, "__NC_SYSTEM__", ["__NC_USER__", "__NC_SERVICES_END__"]);
const userPart = extractBetween(text, "__NC_USER__", ["__NC_SERVICES_END__"]);
const systemUnits = parseSystemctlListUnits(systemPart, "system");
const userUnits = parseSystemctlListUnits(userPart, "user");
// Prefer system unit when names collide
const seen = new Set(systemUnits.map((u) => u.name));
const merged = systemUnits.slice();
for (const unit of userUnits) {
if (seen.has(unit.name)) continue;
seen.add(unit.name);
merged.push(unit);
}
merged.sort((a, b) => {
if (a.activeState === "failed" && b.activeState !== "failed") return -1;
if (b.activeState === "failed" && a.activeState !== "failed") return 1;
return a.name.localeCompare(b.name);
});
return merged;
}
function createServiceOpsApi({
execOnSession,
getSession,
}) {
async function listServices(event, sessionId) {
if (!sessionId) return { success: false, error: "Missing sessionId" };
const result = await execOnSession(event, sessionId, LIST_UNITS_SCRIPT, 20000, {
maxBuffer: 16 * 1024 * 1024,
});
if (result.pending) return { success: false, pending: true };
if (!result.success) return { success: false, error: result.error || "Failed to list services" };
return { success: true, units: parseServiceList(result.stdout) };
}
async function serviceAction(event, payload) {
const sessionId = payload?.sessionId;
const unitName = sanitizeUnitName(payload?.unitName);
const action = String(payload?.action || "").toLowerCase();
const scope = payload?.scope === "user" ? "user" : "system";
if (!sessionId || !unitName) return { success: false, error: "Missing sessionId or unitName" };
if (!ALLOWED_ACTIONS.has(action)) return { success: false, error: "Invalid action" };
const userFlag = scope === "user" ? "--user " : "";
const baseCmd = `systemctl ${userFlag}${action} ${shQuote(unitName)}`;
const wrapped = `exec sh -c ${JSON.stringify(baseCmd)}`;
let result = await execOnSession(event, sessionId, wrapped, 30000);
if (result.pending) return { success: false, pending: true };
if (isSuccessfulCommandResult(result)) return { success: true };
// User-scope units should not escalate via sudo.
if (scope === "user" || !isPermissionDenied(result)) {
return { success: false, error: commandError(result, `systemctl ${action} failed`) };
}
const sudoPassword = getSessionSudoPassword(getSession?.(sessionId));
const passwordless = `exec sh -c ${JSON.stringify(`sudo systemctl ${action} ${shQuote(unitName)}`)}`;
const passwordlessResult = await execOnSession(event, sessionId, passwordless, 30000);
if (passwordlessResult.pending) return { success: false, pending: true };
if (isSuccessfulCommandResult(passwordlessResult)) return { success: true };
if (sudoPassword) {
const withPassword = `exec sh -c ${JSON.stringify(`sudo -S -p '' systemctl ${action} ${shQuote(unitName)}`)}`;
const sudoResult = await execOnSession(event, sessionId, withPassword, 30000, {
stdin: `${sudoPassword}\n`,
});
if (sudoResult.pending) return { success: false, pending: true };
if (isSuccessfulCommandResult(sudoResult)) return { success: true };
return { success: false, error: commandError(sudoResult, `sudo systemctl ${action} failed`) };
}
return {
success: false,
error: commandError(
passwordlessResult.success === false ? passwordlessResult : result,
`systemctl ${action} failed (sudo required)`,
),
};
}
return {
listServices,
serviceAction,
parseServiceList,
parseSystemctlListUnits,
sanitizeUnitName,
};
}
module.exports = {
createServiceOpsApi,
parseServiceList,
parseSystemctlListUnits,
sanitizeUnitName,
LIST_UNITS_SCRIPT,
};
@@ -0,0 +1,59 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const {
parseSystemctlListUnits,
parseServiceList,
sanitizeUnitName,
} = require("./serviceOps.cjs");
test("sanitizeUnitName rejects shell metacharacters", () => {
assert.equal(sanitizeUnitName("nginx.service"), "nginx.service");
assert.equal(sanitizeUnitName("user@1000.service"), "user@1000.service");
assert.equal(sanitizeUnitName("evil;rm -rf /"), null);
assert.equal(sanitizeUnitName(""), null);
});
test("parseSystemctlListUnits reads plain list-units rows", () => {
const sample = `
nginx.service loaded active running A high performance web server
● broken.service loaded failed failed Broken unit
cron.service loaded inactive dead Regular background program processing daemon
`;
const units = parseSystemctlListUnits(sample, "system");
assert.equal(units.length, 3);
assert.equal(units[0].name, "nginx.service");
assert.equal(units[0].activeState, "active");
assert.equal(units[1].name, "broken.service");
assert.equal(units[1].activeState, "failed");
assert.equal(units[2].activeState, "inactive");
});
test("parseSystemctlListUnits keeps units with an empty description", () => {
const sample = `
● broken.service loaded failed failed
quiet.service loaded active running
`;
const units = parseSystemctlListUnits(sample, "system");
assert.equal(units.length, 2);
assert.equal(units.find((u) => u.name === "broken.service")?.description, "");
assert.equal(units.find((u) => u.name === "quiet.service")?.activeState, "active");
});
test("parseServiceList merges system and user scopes and sorts failed first", () => {
const stdout = `
__NC_SERVICES_BEGIN__
__NC_SYSTEM__
broken.service loaded failed failed Broken
nginx.service loaded active running Nginx
__NC_USER__
podman.service loaded active running Podman
nginx.service loaded active running User nginx
__NC_SERVICES_END__
`;
const units = parseServiceList(stdout);
assert.equal(units[0].name, "broken.service");
assert.equal(units.find((u) => u.name === "nginx.service")?.scope, "system");
assert.equal(units.find((u) => u.name === "podman.service")?.scope, "user");
});
+125 -9
View File
@@ -4,6 +4,8 @@ const { createExecOnSessionApi } = require("./systemManager/execOnSession.cjs");
const { createTmuxOpsApi } = require("./systemManager/tmuxOps.cjs");
const { createDockerOpsApi } = require("./systemManager/dockerOps.cjs");
const { createGpuOpsApi } = require("./systemManager/gpuOps.cjs");
const { createPortOpsApi } = require("./systemManager/portOps.cjs");
const { createServiceOpsApi } = require("./systemManager/serviceOps.cjs");
const CAPABILITY_SCRIPT_POSIX = [
"exec sh -c ",
@@ -12,7 +14,11 @@ const CAPABILITY_SCRIPT_POSIX = [
'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 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 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; ',
'command -v netstat >/dev/null 2>&1 && printf "%s\\n" __NC_NETSTAT__=1; ',
'command -v lsof >/dev/null 2>&1 && printf "%s\\n" __NC_LSOF__=1; ',
'command -v systemctl >/dev/null 2>&1 && printf "%s\\n" __NC_SYSTEMCTL__=1',
"'",
].join("");
@@ -38,11 +44,29 @@ function parseCapabilities(stdout, isLocal, localPlatform) {
else if (uname.includes("darwin")) targetOs = "darwin";
else if (uname.includes("windows") || uname.includes("mingw")) targetOs = "win32";
}
const hasTmux = text.includes("__NC_TMUX__=1");
const hasDocker = text.includes("__NC_DOCKER__=1");
const hasNvidiaSmi = text.includes("__NC_NVIDIA_SMI__=1");
const hasNpuSmi = text.includes("__NC_NPU_SMI__=1");
return { targetOs, hasTmux, hasDocker, hasNvidiaSmi, hasNpuSmi, probedAt: Date.now() };
// Line-anchored markers only — avoid matching probe-script source echoed by
// 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 hasNvidiaSmi = hasFlag("__NC_NVIDIA_SMI__");
const hasNpuSmi = hasFlag("__NC_NPU_SMI__");
const hasSs = hasFlag("__NC_SS__");
const hasNetstat = hasFlag("__NC_NETSTAT__");
const hasLsof = hasFlag("__NC_LSOF__");
const hasSystemctl = hasFlag("__NC_SYSTEMCTL__");
return {
targetOs,
hasTmux,
hasDocker,
hasNvidiaSmi,
hasNpuSmi,
hasSs,
hasNetstat,
hasLsof,
hasSystemctl,
probedAt: Date.now(),
};
}
function parseProcessLines(stdout) {
@@ -167,6 +191,13 @@ function createSystemManagerBridge(deps) {
isLocalSession,
process,
});
const portOps = createPortOpsApi({
execOnSession,
execOnLocalMachine,
isLocalSession,
process,
});
const serviceOps = createServiceOpsApi({ execOnSession, getSession });
async function probeCapabilities(event, payload) {
const sessionId = payload?.sessionId;
@@ -177,7 +208,18 @@ function createSystemManagerBridge(deps) {
let script = CAPABILITY_SCRIPT_POSIX;
if (platform === "win32") {
const result = await execOnLocalMachine(
"$os=[System.Environment]::OSVersion.Platform; 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 nvidia-smi -ErrorAction SilentlyContinue) { Write-Output '__NC_NVIDIA_SMI__=1' }; if (Get-Command npu-smi -ErrorAction SilentlyContinue) { Write-Output '__NC_NPU_SMI__=1' }",
[
'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 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.
"Write-Output '__NC_NETSTAT__=1'; ",
"if (Get-Command ss -ErrorAction SilentlyContinue) { Write-Output '__NC_SS__=1' }; ",
"if (Get-Command lsof -ErrorAction SilentlyContinue) { Write-Output '__NC_LSOF__=1' }; ",
"if (Get-Command systemctl -ErrorAction SilentlyContinue) { Write-Output '__NC_SYSTEMCTL__=1' }",
].join(""),
8000,
);
if (!result.success) return { success: false, error: result.error || "Probe failed" };
@@ -189,7 +231,17 @@ function createSystemManagerBridge(deps) {
);
if (!result.success) {
const fallback = await execOnLocalMachine(
"uname -s; command -v tmux; command -v docker >/dev/null 2>&1 && echo docker_ok; command -v nvidia-smi >/dev/null 2>&1 && echo nvidia_ok; command -v npu-smi >/dev/null 2>&1 && echo npu_ok",
[
"uname -s; ",
"command -v tmux; ",
"command -v docker >/dev/null 2>&1 && echo docker_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; ",
"command -v netstat >/dev/null 2>&1 && echo netstat_ok; ",
"command -v lsof >/dev/null 2>&1 && echo lsof_ok; ",
"command -v systemctl >/dev/null 2>&1 && echo systemctl_ok",
].join(""),
8000,
);
if (!fallback.success) return { success: false, error: fallback.error || "Probe failed" };
@@ -202,6 +254,10 @@ function createSystemManagerBridge(deps) {
hasDocker: text.includes("docker_ok"),
hasNvidiaSmi: text.includes("nvidia_ok"),
hasNpuSmi: text.includes("npu_ok"),
hasSs: text.includes("ss_ok"),
hasNetstat: text.includes("netstat_ok"),
hasLsof: text.includes("lsof_ok"),
hasSystemctl: text.includes("systemctl_ok"),
probedAt: Date.now(),
},
};
@@ -261,10 +317,48 @@ function createSystemManagerBridge(deps) {
async function signalProcess(event, payload) {
const { sessionId, pid, signal = "TERM", nice } = payload || {};
if (!sessionId || !pid) return { success: false, error: "Missing sessionId or pid" };
const numericPid = Number(pid);
if (!Number.isFinite(numericPid) || numericPid <= 0) {
return { success: false, error: "Invalid pid" };
}
// Local Windows has no POSIX kill; map only TERM/KILL onto Stop-Process.
if (isLocalSession(sessionId) && process.platform === "win32") {
if (nice !== undefined && nice !== null) {
return { success: false, error: "renice is not supported on Windows" };
}
const sig = String(signal || "TERM").toUpperCase();
if (!(sig === "TERM" || sig === "15" || sig === "KILL" || sig === "9")) {
return { success: false, error: `signal ${sig} is not supported on Windows` };
}
const force = sig === "KILL" || sig === "9";
const ps = force
? `Stop-Process -Id ${Math.trunc(numericPid)} -Force -ErrorAction Stop`
: `Stop-Process -Id ${Math.trunc(numericPid)} -ErrorAction Stop`;
const result = await execOnLocalMachine(ps, 5000);
if (!result.success) return { success: false, error: result.error || "Stop-Process failed" };
if (typeof result.code === "number" && result.code !== 0) {
return {
success: false,
error: (result.stderr || result.error || `Stop-Process exited with code ${result.code}`).trim(),
code: result.code,
};
}
return { success: true, code: result.code };
}
const built = buildProcessSignalCommand(pid, signal, nice);
if (built.error) return { success: false, error: built.error };
const result = await execOnSession(event, sessionId, `exec sh -c ${JSON.stringify(built.command)}`, 5000);
if (result.pending) return { success: false, pending: true, error: result.error };
if (!result.success) return { success: false, error: result.error };
if (typeof result.code === "number" && result.code !== 0) {
return {
success: false,
error: (result.stderr || result.error || `kill exited with code ${result.code}`).trim(),
code: result.code,
};
}
return { success: true, code: result.code };
}
@@ -375,6 +469,22 @@ function createSystemManagerBridge(deps) {
return gpuOps.listAccelerators(event, sessionId);
}
async function listListeningPorts(event, payload) {
const sessionId = payload?.sessionId;
if (!sessionId) return { success: false, error: "Missing sessionId" };
return portOps.listListeningPorts(event, sessionId);
}
async function listSystemServices(event, payload) {
const sessionId = payload?.sessionId;
if (!sessionId) return { success: false, error: "Missing sessionId" };
return serviceOps.listServices(event, sessionId);
}
async function systemServiceAction(event, payload) {
return serviceOps.serviceAction(event, payload);
}
function registerWorkerHandle(ipcMain, terminalWorkerManager, channel) {
ipcMain.handle(channel, (event, payload) => terminalWorkerManager.request(channel, payload, {
webContentsId: event?.sender?.id,
@@ -403,6 +513,9 @@ function createSystemManagerBridge(deps) {
"netcatty:system:dockerAction",
"netcatty:system:dockerImageAction",
"netcatty:system:listAccelerators",
"netcatty:system:listListeningPorts",
"netcatty:system:listSystemServices",
"netcatty:system:systemServiceAction",
].forEach((channel) => registerWorkerHandle(ipcMain, terminalWorkerManager, channel));
return;
}
@@ -424,9 +537,12 @@ function createSystemManagerBridge(deps) {
ipcMain.handle("netcatty:system:dockerAction", dockerAction);
ipcMain.handle("netcatty:system:dockerImageAction", dockerImageAction);
ipcMain.handle("netcatty:system:listAccelerators", listAccelerators);
ipcMain.handle("netcatty:system:listListeningPorts", listListeningPorts);
ipcMain.handle("netcatty:system:listSystemServices", listSystemServices);
ipcMain.handle("netcatty:system:systemServiceAction", systemServiceAction);
}
return { registerHandlers, probeCapabilities, listProcesses, setupOsc7Tracking };
return { registerHandlers, probeCapabilities, listProcesses, signalProcess, setupOsc7Tracking };
}
module.exports = { createSystemManagerBridge };
@@ -316,9 +316,13 @@ test("probeCapabilities reports Docker when docker is installed even if plain do
assert.match(command, /command -v docker/);
assert.match(command, /command -v nvidia-smi/);
assert.match(command, /command -v npu-smi/);
assert.match(command, /command -v ss/);
assert.match(command, /command -v systemctl/);
assert.doesNotMatch(command, /docker info/);
assert.doesNotMatch(command, /docker\.sock/);
callback(null, createFakeExecStream("__NC_OS__=Linux\n__NC_DOCKER__=1\n__NC_NVIDIA_SMI__=1\n"));
callback(null, createFakeExecStream(
"__NC_OS__=Linux\n__NC_DOCKER__=1\n__NC_NVIDIA_SMI__=1\n__NC_SS__=1\n__NC_SYSTEMCTL__=1\n",
));
},
};
const sessions = new Map([["s1", { conn, type: "ssh" }]]);
@@ -333,6 +337,8 @@ test("probeCapabilities reports Docker when docker is installed even if plain do
assert.equal(result.capabilities.hasDocker, true);
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("setupOsc7Tracking runs the setup command through the active session executor", async () => {
@@ -380,3 +386,60 @@ test("setupOsc7Tracking reports non-zero setup exits as failures", async () => {
assert.equal(result.code, 2);
assert.match(result.error, /unsupported shell/);
});
test("signalProcess reports non-zero kill exits as failures", async () => {
const conn = {
exec(_command, callback) {
callback(null, createFakeExecStream("", { code: 1, stderr: "kill: (99999): No such process\n" }));
},
};
const sessions = new Map([["s1", { conn, type: "ssh" }]]);
const bridge = createSystemManagerBridge({
getSessions: () => sessions,
process,
});
const result = await bridge.signalProcess(null, {
sessionId: "s1",
pid: 99999,
signal: "TERM",
});
assert.equal(result.success, false);
assert.equal(result.code, 1);
assert.match(result.error, /No such process/);
});
test("signalProcess propagates pending when the SSH channel is unavailable", async () => {
const sessions = new Map([["mosh1", { type: "mosh" }]]);
const bridge = createSystemManagerBridge({
getSessions: () => sessions,
process,
});
const result = await bridge.signalProcess(null, {
sessionId: "mosh1",
pid: 42,
signal: "TERM",
});
assert.equal(result.success, false);
assert.equal(result.pending, true);
});
test("signalProcess rejects Windows-only STOP/CONT on local win32 sessions", async () => {
const sessions = new Map([["local", { type: "local" }]]);
const bridge = createSystemManagerBridge({
getSessions: () => sessions,
process: { ...process, platform: "win32" },
});
const result = await bridge.signalProcess(null, {
sessionId: "local",
pid: 42,
signal: "STOP",
});
assert.equal(result.success, false);
assert.match(result.error, /not supported on Windows/i);
});
+9
View File
@@ -423,6 +423,15 @@ function createPreloadApi(ctx) {
listAccelerators: async (sessionId) => {
return ipcRenderer.invoke("netcatty:system:listAccelerators", { sessionId });
},
listListeningPorts: async (sessionId) => {
return ipcRenderer.invoke("netcatty:system:listListeningPorts", { sessionId });
},
listSystemServices: async (sessionId) => {
return ipcRenderer.invoke("netcatty:system:listSystemServices", { sessionId });
},
systemServiceAction: async (options) => {
return ipcRenderer.invoke("netcatty:system:systemServiceAction", options);
},
dockerInspect: async (options) => {
return ipcRenderer.invoke("netcatty:system:dockerInspect", options);
},
+19 -1
View File
@@ -18,7 +18,7 @@ declare global {
pid: number;
signal?: string;
nice?: number;
}): Promise<{ success: boolean; error?: string; code?: number }>;
}): Promise<{ success: boolean; pending?: boolean; error?: string; code?: number }>;
setupOsc7Tracking?(sessionId: string, command: string): Promise<{
success: boolean;
pending?: boolean;
@@ -94,6 +94,24 @@ declare global {
nvidiaDriverVersion?: string | null;
probedAt?: number;
}>;
listListeningPorts?(sessionId: string): Promise<{
success: boolean;
pending?: boolean;
error?: string;
ports?: import("../../domain/systemManager/types").ListeningPortInfo[];
}>;
listSystemServices?(sessionId: string): Promise<{
success: boolean;
pending?: boolean;
error?: string;
units?: import("../../domain/systemManager/types").SystemdUnitInfo[];
}>;
systemServiceAction?(options: {
sessionId: string;
unitName: string;
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<{
success: boolean;
error?: string;