mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa1d50d82 | ||
|
|
159e82d677 | ||
|
|
ac220dbacc | ||
|
|
5f76503d46 | ||
|
|
cba9e02c63 | ||
|
|
b631ee0ab6 | ||
|
|
6d5d7f6b27 | ||
|
|
1400d1abc3 | ||
|
|
1f8659d057 | ||
|
|
34813639c9 | ||
|
|
9973309a15 |
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': '已偵測到加速卡工具,但未回傳裝置資訊。',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user