Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 140906b4f5 fix(system): show unknown GPU util/memory as -- not 0%
ResourceBar now treats null/undefined as unavailable, and the GPU
device cards pass through null metrics instead of coalescing to 0.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:35:54 +00:00
Cursor Agentand陈大猫 24af7ce456 fix(system): address Codex GPU collector review findings
- Wrap POSIX accelerator script with JSON.stringify so nested sed
  quotes cannot break outer sh -c
- Parse Ascend proc-mem whitespace-delimited table rows

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:30:00 +00:00
Cursor Agentand陈大猫 de5828fc35 fix(system): address GPU monitor Bugbot findings
- Use PowerShell nvidia-smi collector for local Windows sessions
- Keep GPU tab in loading state while listAccelerators is pending
- Render compute processes even when device list is empty
- Stop treating Ascend HBM usage rate (%) as megabytes

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:19:30 +00:00
Cursor Agentand陈大猫 843543e77d feat(system): add GPU/NPU monitor tab when accelerators are detected
Probe nvidia-smi and npu-smi during System capabilities checks and show a
GPU sub-tab with utilization, memory/HBM, temperature, power, and compute
processes. Reuses the existing SSH exec + parse pattern (same approach as
open-source remote GPU monitors) without new dependencies.

Related to #2561

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:13:50 +00:00
19 changed files with 1212 additions and 12 deletions

No files matched your search

@@ -10,6 +10,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.popup.loading': 'Opening terminal…',
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
@@ -23,6 +24,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'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.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.',
@@ -187,6 +189,23 @@ export const enSystemManagerMessages: Messages = {
'systemManager.docker.start': 'Start',
'systemManager.docker.stop': 'Stop',
'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.',
'systemManager.gpu.meta': '{{devices}} device(s) · {{processes}} process(es)',
'systemManager.gpu.devices': 'Devices',
'systemManager.gpu.processes': 'Compute processes',
'systemManager.gpu.noProcesses': 'No compute processes reported.',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': 'Ascend',
'systemManager.gpu.util': 'Util',
'systemManager.gpu.memory': 'VRAM',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': 'Temperature',
'systemManager.gpu.power': 'Power',
'systemManager.gpu.fan': 'Fan {{value}}%',
'systemManager.gpu.driver': 'Driver {{version}}',
'systemManager.inspect.status': 'Status',
'systemManager.inspect.image': 'Image',
'systemManager.inspect.created': 'Created',
@@ -10,6 +10,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.popup.loading': 'Открытие терминала…',
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
@@ -23,6 +24,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.overview.empty': 'Данных обзора пока нет.',
@@ -187,6 +189,23 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.docker.start': 'Запустить',
'systemManager.docker.stop': 'Остановить',
'systemManager.gpu.unavailable': 'На этом хосте не обнаружены инструменты NVIDIA GPU или Ascend NPU.',
'systemManager.gpu.loading': 'Загрузка статистики ускорителей…',
'systemManager.gpu.empty': 'Инструменты ускорителей найдены, но устройства не сообщены.',
'systemManager.gpu.meta': '{{devices}} устройств · {{processes}} процессов',
'systemManager.gpu.devices': 'Устройства',
'systemManager.gpu.processes': 'Вычислительные процессы',
'systemManager.gpu.noProcesses': 'Вычислительные процессы не сообщены.',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': 'Ascend',
'systemManager.gpu.util': 'Загрузка',
'systemManager.gpu.memory': 'VRAM',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': 'Температура',
'systemManager.gpu.power': 'Питание',
'systemManager.gpu.fan': 'Вентилятор {{value}}%',
'systemManager.gpu.driver': 'Драйвер {{version}}',
'systemManager.inspect.status': 'Статус',
'systemManager.inspect.image': 'Образ',
'systemManager.inspect.created': 'Создан',
@@ -10,6 +10,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.tabs.processes': '进程',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.popup.loading': '正在打开终端…',
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
@@ -23,6 +24,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.loadOverview': '加载系统概览失败',
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.overview.empty': '暂无系统概览数据。',
@@ -187,6 +189,23 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.docker.start': '启动',
'systemManager.docker.stop': '停止',
'systemManager.gpu.unavailable': '此主机未检测到 NVIDIA GPU 或昇腾 NPU 工具。',
'systemManager.gpu.loading': '正在加载加速卡状态…',
'systemManager.gpu.empty': '已检测到加速卡工具,但未返回设备信息。',
'systemManager.gpu.meta': '{{devices}} 张卡 · {{processes}} 个进程',
'systemManager.gpu.devices': '设备',
'systemManager.gpu.processes': '计算进程',
'systemManager.gpu.noProcesses': '当前没有计算进程。',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': '昇腾',
'systemManager.gpu.util': '利用率',
'systemManager.gpu.memory': '显存',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': '温度',
'systemManager.gpu.power': '功耗',
'systemManager.gpu.fan': '风扇 {{value}}%',
'systemManager.gpu.driver': '驱动 {{version}}',
'systemManager.inspect.status': '状态',
'systemManager.inspect.image': '镜像',
'systemManager.inspect.created': '创建时间',
@@ -10,6 +10,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.tabs.processes': '程序',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.popup.loading': '正在開啟終端…',
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
@@ -23,6 +24,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
'systemManager.errors.loadOverview': '載入系統概覽失敗',
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
'systemManager.overview.empty': '暫無系統概覽資料。',
@@ -187,6 +189,23 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.docker.start': '啟動',
'systemManager.docker.stop': '停止',
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
'systemManager.gpu.loading': '正在載入加速卡狀態…',
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
'systemManager.gpu.meta': '{{devices}} 張卡 · {{processes}} 個程序',
'systemManager.gpu.devices': '裝置',
'systemManager.gpu.processes': '計算程序',
'systemManager.gpu.noProcesses': '目前沒有計算程序。',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': '昇騰',
'systemManager.gpu.util': '使用率',
'systemManager.gpu.memory': '顯示記憶體',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': '溫度',
'systemManager.gpu.power': '功耗',
'systemManager.gpu.fan': '風扇 {{value}}%',
'systemManager.gpu.driver': '驅動 {{version}}',
'systemManager.inspect.status': '狀態',
'systemManager.inspect.image': '映像',
'systemManager.inspect.created': '建立時間',
@@ -112,6 +112,14 @@ export function useSystemManagerBackend() {
return bridge.getDockerStats(options);
}, []);
const listAccelerators = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listAccelerators) {
return { success: false as const, error: 'listAccelerators unavailable' };
}
return bridge.listAccelerators(sessionId);
}, []);
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerInspect) {
@@ -172,6 +180,7 @@ export function useSystemManagerBackend() {
listDockerContainers,
listDockerImages,
getDockerStats,
listAccelerators,
dockerInspect,
dockerImageInspect,
dockerAction,
@@ -190,6 +199,7 @@ export function useSystemManagerBackend() {
listDockerContainers,
listDockerImages,
getDockerStats,
listAccelerators,
dockerInspect,
dockerImageInspect,
dockerAction,
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("gpu tab keeps loading while accelerator query is pending", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
assert.match(source, /setGpuListPending\(true\)/);
assert.match(source, /if \(result\.pending\)/);
assert.match(source, /isRefreshActive = loading \|\| gpuListPending/);
assert.match(source, /if \(!data\)/);
assert.doesNotMatch(
source,
/if \(result\.pending\) return null;\s*\n\s*if \(!result\.success\)/,
);
});
test("gpu tab still renders compute processes when device list is empty", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
assert.match(source, /if \(!devices\.length && !processes\.length\)/);
assert.match(source, /processes\.map\(\(process\) =>/);
});
test("gpu tab passes null utilization to ResourceBar instead of zero", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
assert.match(source, /value=\{util\}/);
assert.match(source, /value=\{memPct\}/);
assert.doesNotMatch(source, /value=\{util \?\? 0\}/);
assert.doesNotMatch(source, /value=\{memPct \?\? 0\}/);
});
+294
View File
@@ -0,0 +1,294 @@
import { CircuitBoard, Cpu, Thermometer, Zap } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import type {
AcceleratorDeviceInfo,
AcceleratorProcessInfo,
AcceleratorSnapshot,
} from '../../domain/systemManager/types';
import { ResourceBar } from './ResourceBar';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelList,
SystemPanelLoading,
SystemPanelRefreshButton,
SystemPanelRow,
SystemPanelShell,
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
type Backend = ReturnType<typeof useSystemManagerBackend>;
interface GpuManagerTabProps {
sessionId: string;
isVisible: boolean;
backend: Backend;
refreshIntervalSec: number;
}
function formatMb(mb: number | null | undefined): string {
if (!Number.isFinite(mb)) return '--';
const value = Number(mb);
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`;
return `${Math.round(value)} MB`;
}
function memoryPercent(device: AcceleratorDeviceInfo): number | null {
if (!Number.isFinite(device.memoryUsedMb) || !Number.isFinite(device.memoryTotalMb)) return null;
if (!device.memoryTotalMb || device.memoryTotalMb <= 0) return null;
return Math.max(0, Math.min(100, (Number(device.memoryUsedMb) / Number(device.memoryTotalMb)) * 100));
}
function vendorLabel(
vendor: AcceleratorDeviceInfo['vendor'],
t: ReturnType<typeof useI18n>['t'],
): string {
return vendor === 'nvidia'
? t('systemManager.gpu.vendor.nvidia')
: t('systemManager.gpu.vendor.ascend');
}
const DeviceCard = memo(function DeviceCard({
device,
}: {
device: AcceleratorDeviceInfo;
}) {
const { t } = useI18n();
const memPct = memoryPercent(device);
const util = Number.isFinite(device.utilizationPercent) ? Number(device.utilizationPercent) : null;
return (
<div className="border-b border-border/30 px-3 py-2.5 space-y-2">
<div className="flex items-start justify-between gap-2 min-w-0">
<div className="min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs font-medium truncate">
[{device.index}] {device.name}
</span>
<SystemPanelStatusBadge tone="muted">
{vendorLabel(device.vendor, t)}
</SystemPanelStatusBadge>
{device.health ? (
<SystemPanelStatusBadge tone={/ok|healthy|good/i.test(device.health) ? 'success' : 'warning'}>
{device.health}
</SystemPanelStatusBadge>
) : null}
</div>
{device.driverVersion ? (
<div className="text-[10px] text-muted-foreground mt-0.5">
{t('systemManager.gpu.driver', { version: device.driverVersion })}
</div>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0 text-[10px] text-muted-foreground tabular-nums">
{Number.isFinite(device.temperatureC) ? (
<span className="inline-flex items-center gap-0.5" title={t('systemManager.gpu.temperature')}>
<Thermometer size={10} />
{Math.round(Number(device.temperatureC))}°C
</span>
) : null}
{Number.isFinite(device.powerDrawW) ? (
<span className="inline-flex items-center gap-0.5" title={t('systemManager.gpu.power')}>
<Zap size={10} />
{Number(device.powerDrawW).toFixed(0)}
{Number.isFinite(device.powerLimitW) ? `/${Math.round(Number(device.powerLimitW))}` : ''}
W
</span>
) : null}
</div>
</div>
<ResourceBar
label={t('systemManager.gpu.util')}
value={util}
/>
<div className="flex items-center gap-2 min-w-0">
<ResourceBar
label={device.vendor === 'ascend' ? t('systemManager.gpu.hbm') : t('systemManager.gpu.memory')}
value={memPct}
className="flex-1"
/>
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
{formatMb(device.memoryUsedMb)} / {formatMb(device.memoryTotalMb)}
</span>
</div>
{Number.isFinite(device.fanPercent) ? (
<div className="text-[10px] text-muted-foreground">
{t('systemManager.gpu.fan', { value: Math.round(Number(device.fanPercent)) })}
</div>
) : null}
</div>
);
});
const ProcessRow = memo(function ProcessRow({
process,
}: {
process: AcceleratorProcessInfo;
}) {
const { t } = useI18n();
return (
<SystemPanelRow
title={process.processName || '—'}
subtitle={`${vendorLabel(process.vendor, t)} #${process.gpuIndex}`}
trailing={(
<div className="flex items-center gap-2 text-[10px] text-muted-foreground tabular-nums">
<span>PID {process.pid}</span>
<span>{formatMb(process.memoryUsedMb)}</span>
</div>
)}
/>
);
});
export const GpuManagerTab = memo(function GpuManagerTab({
sessionId,
isVisible,
backend,
refreshIntervalSec,
}: GpuManagerTabProps) {
const { t } = useI18n();
const stableT = useStableTranslate();
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
const [gpuListPending, setGpuListPending] = useState(false);
useEffect(() => {
setGpuListPending(false);
}, [sessionId]);
const fetcher = useCallback(async (): Promise<AcceleratorSnapshot | null> => {
const result = await backend.listAccelerators(sessionId);
if (result.pending) {
setGpuListPending(true);
return null;
}
setGpuListPending(false);
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.loadGpu'));
}
return {
devices: result.devices || [],
processes: result.processes || [],
nvidiaDriverVersion: result.nvidiaDriverVersion ?? null,
probedAt: result.probedAt || Date.now(),
};
}, [backend, sessionId, stableT]);
const { data, error, loading, refresh } = usePolling(
fetcher,
intervalMs,
isVisible,
undefined,
{ resetKey: sessionId },
);
const devices = useMemo(() => data?.devices ?? [], [data?.devices]);
const processes = useMemo(() => data?.processes ?? [], [data?.processes]);
const isRefreshActive = loading || gpuListPending;
const meta = useMemo(() => {
const nvidiaCount = devices.filter((d) => d.vendor === 'nvidia').length;
const ascendCount = devices.filter((d) => d.vendor === 'ascend').length;
return t('systemManager.gpu.meta', {
devices: devices.length,
processes: processes.length,
nvidia: nvidiaCount,
ascend: ascendCount,
});
}, [devices, processes, t]);
if (!isVisible && !data) {
return null;
}
if (!data) {
if (error && !isRefreshActive) {
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelError
message={error}
retryLabel={t('history.action.refresh')}
onRetry={() => void refresh()}
/>
</SystemPanelShell>
);
}
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelLoading message={t('systemManager.gpu.loading')} />
</SystemPanelShell>
);
}
if (!devices.length && !processes.length) {
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelToolbar trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={isRefreshActive}
onClick={() => void refresh()}
/>
)}>
<span className="text-[11px] text-muted-foreground truncate">{meta}</span>
</SystemPanelToolbar>
<SystemPanelEmpty icon={CircuitBoard} message={t('systemManager.gpu.empty')} />
</SystemPanelShell>
);
}
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelToolbar trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={isRefreshActive}
onClick={() => void refresh()}
/>
)}>
<span className="text-[11px] text-muted-foreground truncate">{meta}</span>
</SystemPanelToolbar>
{error && !isRefreshActive ? (
<SystemPanelInlineError
message={error}
retryLabel={t('history.action.refresh')}
onRetry={() => void refresh()}
/>
) : null}
<SystemPanelList>
<div className="px-3 py-1.5 text-[10px] uppercase tracking-wide text-muted-foreground flex items-center gap-1">
<Cpu size={10} />
{t('systemManager.gpu.devices')}
</div>
{devices.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground">
{t('systemManager.gpu.empty')}
</div>
) : (
devices.map((device) => (
<DeviceCard key={`${device.vendor}-${device.index}-${device.uuid || device.name}`} device={device} />
))
)}
<div className="px-3 pt-3 pb-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{t('systemManager.gpu.processes')}
</div>
{processes.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground">
{t('systemManager.gpu.noProcesses')}
</div>
) : (
processes.map((process) => (
<ProcessRow
key={`${process.vendor}-${process.gpuIndex}-${process.pid}-${process.processName}`}
process={process}
/>
))
)}
</SystemPanelList>
</SystemPanelShell>
);
});
+6 -4
View File
@@ -3,12 +3,13 @@ import { cn } from '../../lib/utils';
interface ResourceBarProps {
label: string;
value: number;
value: number | null | undefined;
className?: string;
}
export const ResourceBar = memo(function ResourceBar({ label, value, className }: ResourceBarProps) {
const clamped = Math.max(0, Math.min(100, value));
const finite = typeof value === 'number' && Number.isFinite(value);
const clamped = finite ? Math.max(0, Math.min(100, value)) : 0;
return (
<div className={cn('flex items-center gap-2 min-w-0', className)}>
<span className="text-[10px] text-muted-foreground w-7 shrink-0">{label}</span>
@@ -16,13 +17,14 @@ export const ResourceBar = memo(function ResourceBar({ label, value, className }
<div
className={cn(
'h-full rounded-full',
clamped > 85 ? 'bg-destructive/70' : 'bg-primary/70',
finite && clamped > 85 ? 'bg-destructive/70' : 'bg-primary/70',
!finite && 'opacity-0',
)}
style={{ width: `${clamped}%` }}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground w-10 text-right shrink-0">
{Number.isFinite(value) ? `${value.toFixed(1)}%` : '--'}
{finite ? `${value.toFixed(1)}%` : '--'}
</span>
</div>
);
@@ -1,4 +1,4 @@
import { Activity, Box, Gauge, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
import { Activity, Box, CircuitBoard, Gauge, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import React, { memo, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -11,6 +11,7 @@ import { buildSystemManagerTabs, shouldCollectServerStats } from '../../domain/s
import type { Snippet, TerminalSession } from '../../types';
import { cn } from '../../lib/utils';
import { DockerManagerTab } from './DockerManagerTab';
import { GpuManagerTab } from './GpuManagerTab';
import { ProcessManagerTab } from './ProcessManagerTab';
import { SystemOverviewTab } from './SystemOverviewTab';
import { TmuxManagerTab } from './TmuxManagerTab';
@@ -165,10 +166,12 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
{ id: 'processes', icon: LayoutList, label: t('systemManager.tabs.processes') },
{ 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') },
];
const tmuxReady = capabilities?.hasTmux === true;
const dockerReady = capabilities?.hasDocker === true;
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
const tmuxPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'tmux',
ready: tmuxReady,
@@ -179,6 +182,11 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
ready: dockerReady,
capabilitiesKnown: capabilities !== undefined,
});
const gpuPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'gpu',
ready: gpuReady,
capabilitiesKnown: capabilities !== undefined,
});
return (
<SystemPanelShell section="system-manager-panel">
@@ -267,6 +275,24 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
/>
</div>
) : null}
{gpuPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={CircuitBoard} message={t('systemManager.gpu.unavailable')} />
</div>
) : gpuPanelState === 'checking' ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : gpuPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'gpu' && 'hidden')}>
<GpuManagerTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'gpu'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
/>
</div>
) : null}
</div>
</SystemPanelShell>
);
+47 -1
View File
@@ -1,12 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildSystemManagerTabs, shouldCollectServerStats } from "./systemTarget.ts";
import { buildSystemManagerTabs, shouldCollectServerStats, shouldShowGpuTab } from "./systemTarget.ts";
test("system manager shows overview before detailed management tabs", () => {
assert.deepEqual(buildSystemManagerTabs(null, undefined, null), ["overview", "processes"]);
});
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,
);
const host = {
id: "host-gpu",
label: "GPU",
hostname: "gpu.local",
username: "root",
tags: [],
os: "linux" as const,
};
assert.deepEqual(
buildSystemManagerTabs(host, {
targetOs: "linux",
hasTmux: true,
hasDocker: true,
hasNvidiaSmi: false,
hasNpuSmi: true,
probedAt: 1,
}, null),
["overview", "processes", "tmux", "docker", "gpu"],
);
});
test("system overview stats skip network devices even when a Linux icon was selected", () => {
assert.equal(
shouldCollectServerStats(
+8
View File
@@ -42,6 +42,13 @@ export function shouldShowDockerTab(
return isDefiniteLinuxTarget(host, capabilities, session);
}
/** GPU tab only appears after nvidia-smi / npu-smi is actually detected. */
export function shouldShowGpuTab(
capabilities: SessionCapabilities | undefined,
): boolean {
return capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
}
export function shouldCollectServerStats(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
@@ -65,5 +72,6 @@ export function buildSystemManagerTabs(
const tabs: SystemManagerSubTab[] = ['overview', 'processes'];
if (shouldShowTmuxTab(host, capabilities, session)) tabs.push('tmux');
if (shouldShowDockerTab(host, capabilities, session)) tabs.push('docker');
if (shouldShowGpuTab(capabilities)) tabs.push('gpu');
return tabs;
}
+36 -1
View File
@@ -4,6 +4,41 @@ export interface SessionCapabilities {
targetOs: TargetOs;
hasTmux: boolean;
hasDocker: boolean;
hasNvidiaSmi: boolean;
hasNpuSmi: boolean;
probedAt: number;
}
export type AcceleratorVendor = 'nvidia' | 'ascend';
export interface AcceleratorDeviceInfo {
vendor: AcceleratorVendor;
index: number;
uuid: string;
name: string;
utilizationPercent: number | null;
memoryUsedMb: number | null;
memoryTotalMb: number | null;
temperatureC: number | null;
powerDrawW: number | null;
powerLimitW: number | null;
fanPercent: number | null;
driverVersion: string | null;
health: string | null;
}
export interface AcceleratorProcessInfo {
vendor: AcceleratorVendor;
gpuIndex: number;
pid: number;
processName: string;
memoryUsedMb: number | null;
}
export interface AcceleratorSnapshot {
devices: AcceleratorDeviceInfo[];
processes: AcceleratorProcessInfo[];
nvidiaDriverVersion: string | null;
probedAt: number;
}
@@ -119,7 +154,7 @@ export type DockerImageManageAction =
| { action: 'prune'; all?: boolean }
| { action: 'tag'; imageId: string; repository: string; tag?: string };
export type SystemManagerSubTab = 'overview' | 'processes' | 'tmux' | 'docker';
export type SystemManagerSubTab = 'overview' | 'processes' | 'tmux' | 'docker' | 'gpu';
export interface TerminalPopupIcon {
kind: 'image';
+453
View File
@@ -0,0 +1,453 @@
/* eslint-disable no-undef */
"use strict";
const NVIDIA_GPU_QUERY = [
"nvidia-smi",
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw,power.limit,fan.speed,driver_version",
"--format=csv,noheader,nounits",
].join(" ");
const NVIDIA_PROCESS_QUERY = [
"nvidia-smi",
"--query-compute-apps=gpu_uuid,pid,process_name,used_gpu_memory",
"--format=csv,noheader,nounits",
].join(" ");
/**
* Local Windows collector — PowerShell (execOnLocalMachine uses powershell.exe).
* Ascend on Windows is uncommon; still attempt npu-smi when present.
*/
const ACCELERATOR_COLLECT_SCRIPT_WINDOWS = [
'Write-Output "__NC_ACCEL_BEGIN__"; ',
"if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) { ",
'Write-Output "__NC_NVIDIA_DEVICES__"; ',
`${NVIDIA_GPU_QUERY} 2>$null; `,
'Write-Output "__NC_NVIDIA_PROCESSES__"; ',
`${NVIDIA_PROCESS_QUERY} 2>$null; `,
"}; ",
"if (Get-Command npu-smi -ErrorAction SilentlyContinue) { ",
'Write-Output "__NC_NPU_BEGIN__"; ',
'Write-Output "__NC_NPU_INFO__"; ',
"npu-smi info 2>$null; ",
'Write-Output "__NC_NPU_PROCS__"; ',
"npu-smi info -t proc-mem 2>$null; ",
'Write-Output "__NC_NPU_END__"; ',
"}; ",
'Write-Output "__NC_ACCEL_END__"',
].join("");
/**
* Remote collector body (no outer quoting). Wrapped with JSON.stringify so
* nested sed single-quotes cannot break `sh -c`.
*/
const ACCELERATOR_COLLECT_INNER = [
'printf "%s\\n" "__NC_ACCEL_BEGIN__"; ',
'if command -v nvidia-smi >/dev/null 2>&1; then ',
'printf "%s\\n" "__NC_NVIDIA_DEVICES__"; ',
`${NVIDIA_GPU_QUERY} 2>/dev/null || true; `,
'printf "%s\\n" "__NC_NVIDIA_PROCESSES__"; ',
`${NVIDIA_PROCESS_QUERY} 2>/dev/null || true; `,
"fi; ",
'if command -v npu-smi >/dev/null 2>&1; then ',
'printf "%s\\n" "__NC_NPU_BEGIN__"; ',
"ids=$(npu-smi info -l 2>/dev/null | sed -n 's/^[[:space:]]*NPU ID[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p'); ",
'if [ -n "$ids" ]; then ',
'for id in $ids; do ',
'printf "%s\\n" "__NC_NPU_DEVICE__=$id"; ',
'npu-smi info -t board -i "$id" 2>/dev/null || true; ',
'npu-smi info -t common -i "$id" 2>/dev/null || true; ',
'npu-smi info -t usages -i "$id" 2>/dev/null || true; ',
'npu-smi info -t memory -i "$id" 2>/dev/null || true; ',
"done; ",
"else ",
'printf "%s\\n" "__NC_NPU_INFO__"; ',
"npu-smi info 2>/dev/null || true; ",
"fi; ",
'printf "%s\\n" "__NC_NPU_PROCS__"; ',
"npu-smi info -t proc-mem 2>/dev/null || true; ",
'printf "%s\\n" "__NC_NPU_END__"; ',
"fi; ",
'printf "%s\\n" "__NC_ACCEL_END__"',
].join("");
const ACCELERATOR_COLLECT_SCRIPT = `exec sh -c ${JSON.stringify(ACCELERATOR_COLLECT_INNER)}`;
function parseCsvNumber(raw) {
const text = String(raw ?? "").trim();
if (!text || /^\[?n\/?a\]?$/i.test(text) || /^not\s+supported$/i.test(text)) return null;
const n = Number.parseFloat(text.replace(/[,%]/g, ""));
return Number.isFinite(n) ? n : null;
}
function parseCsvFields(line) {
// nvidia-smi csv is simple (no embedded commas in queried fields with nounits)
return String(line || "")
.split(",")
.map((part) => part.trim());
}
function parseNvidiaDevices(sectionText) {
const devices = [];
for (const line of String(sectionText || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("__NC_")) continue;
const fields = parseCsvFields(trimmed);
if (fields.length < 3) continue;
const index = Number.parseInt(fields[0], 10);
if (!Number.isFinite(index)) continue;
devices.push({
vendor: "nvidia",
index,
uuid: fields[1] || "",
name: fields[2] || `GPU ${index}`,
utilizationPercent: parseCsvNumber(fields[3]),
memoryUsedMb: parseCsvNumber(fields[4]),
memoryTotalMb: parseCsvNumber(fields[5]),
temperatureC: parseCsvNumber(fields[6]),
powerDrawW: parseCsvNumber(fields[7]),
powerLimitW: parseCsvNumber(fields[8]),
fanPercent: parseCsvNumber(fields[9]),
driverVersion: fields[10] || null,
health: null,
});
}
return devices;
}
function parseNvidiaProcesses(sectionText, devices) {
const uuidToIndex = new Map();
for (const device of devices) {
if (device.uuid) uuidToIndex.set(device.uuid, device.index);
}
const processes = [];
for (const line of String(sectionText || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("__NC_")) continue;
const fields = parseCsvFields(trimmed);
if (fields.length < 3) continue;
const uuid = fields[0] || "";
const pid = Number.parseInt(fields[1], 10);
if (!Number.isFinite(pid) || pid <= 0) continue;
processes.push({
vendor: "nvidia",
gpuIndex: uuidToIndex.has(uuid) ? uuidToIndex.get(uuid) : 0,
pid,
processName: fields[2] || "",
memoryUsedMb: parseCsvNumber(fields[3]),
});
}
return processes;
}
function extractKvNumber(block, labels) {
for (const label of labels) {
const re = new RegExp(`${label}\\s*[:=]\\s*([0-9]+(?:\\.[0-9]+)?)`, "i");
const match = String(block || "").match(re);
if (match) {
const n = Number.parseFloat(match[1]);
if (Number.isFinite(n)) return n;
}
}
return null;
}
function extractKvText(block, labels) {
for (const label of labels) {
const re = new RegExp(`${label}\\s*[:=]\\s*(.+)`, "i");
const match = String(block || "").match(re);
if (match) {
const value = match[1].trim().replace(/\s{2,}.*/, "").trim();
if (value) return value;
}
}
return null;
}
function parseAscendDeviceBlock(index, block) {
const name =
extractKvText(block, [
"Product Name",
"NPU Name",
"Chip Name",
"Model",
"Board Name",
]) || `Ascend NPU ${index}`;
const utilizationPercent = extractKvNumber(block, [
"Aicore Usage Rate\\(%\\)",
"AICore Usage Rate\\(%\\)",
"Aicore Usage Rate",
"AI Core Usage",
]);
// Absolute MB fields only — never treat "HBM Usage Rate(%)" as megabytes.
const hbmUsed = extractKvNumber(block, [
"HBM Used Memory\\(MB\\)",
"HBM Memory Usage\\(MB\\)",
"Used HBM Memory\\(MB\\)",
"Used HBM Memory",
]);
const hbmTotal = extractKvNumber(block, [
"HBM Total Memory\\(MB\\)",
"HBM Capacity\\(MB\\)",
"Total HBM Memory\\(MB\\)",
"Total HBM Memory",
]);
const hbmUsageRate = extractKvNumber(block, [
"HBM Usage Rate\\(%\\)",
"HBM Usage Rate",
]);
// memory command sometimes reports "Used / Total"
const hbmPair = String(block || "").match(
/HBM[^\n]*?(?:Memory|Usage)\([^\n]*?(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/i,
) || String(block || "").match(
/HBM Used Memory[^\n]*?(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/i,
);
let memoryUsedMb = hbmPair ? Number.parseFloat(hbmPair[1]) : hbmUsed;
let memoryTotalMb = hbmPair ? Number.parseFloat(hbmPair[2]) : hbmTotal;
// Derive used MB from rate only when total is known and used is missing.
if (
!Number.isFinite(memoryUsedMb)
&& Number.isFinite(hbmUsageRate)
&& Number.isFinite(memoryTotalMb)
&& memoryTotalMb > 0
) {
memoryUsedMb = (hbmUsageRate / 100) * memoryTotalMb;
}
const temperatureC = extractKvNumber(block, [
"Temperature\\(C\\)",
"Temp\\(C\\)",
"Temperature",
]);
const powerDrawW = extractKvNumber(block, [
"NPU Real-time Power\\(W\\)",
"Power Dissipation\\(W\\)",
"Power\\(W\\)",
"Power",
]);
const health = extractKvText(block, ["Health", "Health Status"]);
return {
vendor: "ascend",
index,
uuid: "",
name,
utilizationPercent: Number.isFinite(utilizationPercent) ? utilizationPercent : null,
memoryUsedMb: Number.isFinite(memoryUsedMb) ? memoryUsedMb : null,
memoryTotalMb: Number.isFinite(memoryTotalMb) ? memoryTotalMb : null,
temperatureC: Number.isFinite(temperatureC) ? temperatureC : null,
powerDrawW: Number.isFinite(powerDrawW) ? powerDrawW : null,
powerLimitW: null,
fanPercent: null,
driverVersion: null,
health,
};
}
function parseAscendInfoTable(sectionText) {
const devices = [];
const lines = String(sectionText || "").split("\n");
for (const line of lines) {
// | 0 910B3 | OK | 71.8 42 |
const match = line.match(
/^\|\s*(\d+)\s+(\S+(?:\s+\S+)*?)\s+\|\s*(\S+)\s+\|\s*([0-9.]+)\s+([0-9.]+)\s*\|/,
);
if (!match) continue;
const index = Number.parseInt(match[1], 10);
if (!Number.isFinite(index)) continue;
if (devices.some((d) => d.index === index)) continue;
devices.push({
vendor: "ascend",
index,
uuid: "",
name: match[2].trim(),
utilizationPercent: null,
memoryUsedMb: null,
memoryTotalMb: null,
temperatureC: parseCsvNumber(match[5]),
powerDrawW: parseCsvNumber(match[4]),
powerLimitW: null,
fanPercent: null,
driverVersion: null,
health: match[3].trim(),
});
}
// Chip rows: | 0 0 0 12 1234 / 32768 0 / 32768 |
for (const line of lines) {
const chip = line.match(
/^\|\s*(\d+)\s+\d+\s+\d+\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/,
);
if (!chip) continue;
const index = Number.parseInt(chip[1], 10);
const device = devices.find((d) => d.index === index);
if (!device) continue;
device.utilizationPercent = parseCsvNumber(chip[2]);
// Prefer HBM pair (last) when present
device.memoryUsedMb = parseCsvNumber(chip[5]);
device.memoryTotalMb = parseCsvNumber(chip[6]);
}
return devices;
}
function parseAscendTypedSections(sectionText) {
const devices = [];
const chunks = String(sectionText || "").split(/__NC_NPU_DEVICE__=/);
for (const chunk of chunks) {
const trimmed = chunk.trim();
if (!trimmed || trimmed.startsWith("__NC_")) continue;
const nl = trimmed.indexOf("\n");
const idPart = nl === -1 ? trimmed : trimmed.slice(0, nl);
const body = nl === -1 ? "" : trimmed.slice(nl + 1);
const index = Number.parseInt(idPart, 10);
if (!Number.isFinite(index)) continue;
devices.push(parseAscendDeviceBlock(index, body));
}
return devices;
}
function parseAscendProcesses(sectionText) {
const processes = [];
for (const line of String(sectionText || "").split("\n")) {
// Common proc-mem lines include NPU/Chip/Pid/Name/Memory
const match = line.match(
/(?:NPU|Device)?\s*I?D?\s*[:=]?\s*(\d+).*?\b(?:PID|Pid)\s*[:=]?\s*(\d+).*?\b(?:Name|Process)\s*[:=]?\s*(\S+).*?(?:Memory|Mem)\s*[:=]?\s*(\d+(?:\.\d+)?)/i,
);
if (match) {
processes.push({
vendor: "ascend",
gpuIndex: Number.parseInt(match[1], 10) || 0,
pid: Number.parseInt(match[2], 10),
processName: match[3],
memoryUsedMb: parseCsvNumber(match[4]),
});
continue;
}
// Pipe-separated: | 0 | 0 | 12345 | python | 1024 |
const pipeTable = line.match(
/^\|\s*(\d+)\s*\|\s*\d+\s*\|\s*(\d+)\s*\|\s*([^|]+?)\s*\|\s*(\d+(?:\.\d+)?)/,
);
if (pipeTable) {
processes.push({
vendor: "ascend",
gpuIndex: Number.parseInt(pipeTable[1], 10) || 0,
pid: Number.parseInt(pipeTable[2], 10),
processName: pipeTable[3].trim(),
memoryUsedMb: parseCsvNumber(pipeTable[4]),
});
continue;
}
// Whitespace-delimited inside one outer |: | 0 0 12345 python 1024 |
const wsTable = line.match(
/^\|\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\d+(?:\.\d+)?)\s*\|?\s*$/,
);
if (!wsTable) continue;
processes.push({
vendor: "ascend",
gpuIndex: Number.parseInt(wsTable[1], 10) || 0,
pid: Number.parseInt(wsTable[3], 10),
processName: wsTable[4],
memoryUsedMb: parseCsvNumber(wsTable[5]),
});
}
return processes.filter((p) => Number.isFinite(p.pid) && p.pid > 0);
}
function sliceMarkedSection(text, beginMarker, endMarkers) {
const start = text.indexOf(beginMarker);
if (start === -1) return "";
const from = start + beginMarker.length;
let end = text.length;
for (const marker of endMarkers) {
const idx = text.indexOf(marker, from);
if (idx !== -1 && idx < end) end = idx;
}
return text.slice(from, end);
}
function parseAcceleratorSnapshot(stdout) {
const text = String(stdout || "");
const nvidiaDevicesText = sliceMarkedSection(text, "__NC_NVIDIA_DEVICES__", [
"__NC_NVIDIA_PROCESSES__",
"__NC_NPU_BEGIN__",
"__NC_ACCEL_END__",
]);
const nvidiaProcessesText = sliceMarkedSection(text, "__NC_NVIDIA_PROCESSES__", [
"__NC_NPU_BEGIN__",
"__NC_ACCEL_END__",
]);
const npuSection = sliceMarkedSection(text, "__NC_NPU_BEGIN__", ["__NC_NPU_END__", "__NC_ACCEL_END__"]);
const nvidiaDevices = parseNvidiaDevices(nvidiaDevicesText);
const nvidiaProcesses = parseNvidiaProcesses(nvidiaProcessesText, nvidiaDevices);
let ascendDevices = parseAscendTypedSections(npuSection);
if (ascendDevices.length === 0) {
const infoDump = sliceMarkedSection(npuSection, "__NC_NPU_INFO__", [
"__NC_NPU_PROCS__",
"__NC_NPU_END__",
]);
ascendDevices = parseAscendInfoTable(infoDump || npuSection);
}
const ascendProcText = sliceMarkedSection(npuSection, "__NC_NPU_PROCS__", [
"__NC_NPU_END__",
]);
const ascendProcesses = parseAscendProcesses(ascendProcText);
const devices = [...nvidiaDevices, ...ascendDevices].sort((a, b) => {
if (a.vendor !== b.vendor) return a.vendor.localeCompare(b.vendor);
return a.index - b.index;
});
const processes = [...nvidiaProcesses, ...ascendProcesses];
const nvidiaDriverVersion = nvidiaDevices.find((d) => d.driverVersion)?.driverVersion || null;
return {
devices,
processes,
nvidiaDriverVersion,
probedAt: Date.now(),
};
}
function createGpuOpsApi({
execOnSession,
execOnLocalMachine,
isLocalSession,
process: nodeProcess = process,
}) {
async function listAccelerators(event, sessionId) {
if (!sessionId) return { success: false, error: "Missing sessionId" };
let result;
if (
typeof isLocalSession === "function"
&& isLocalSession(sessionId)
&& nodeProcess.platform === "win32"
&& typeof execOnLocalMachine === "function"
) {
result = await execOnLocalMachine(ACCELERATOR_COLLECT_SCRIPT_WINDOWS, 15000);
} else {
result = await execOnSession(event, sessionId, ACCELERATOR_COLLECT_SCRIPT, 15000);
}
if (result.pending) return { success: false, pending: true };
if (!result.success) return { success: false, error: result.error || "Failed to query accelerators" };
const snapshot = parseAcceleratorSnapshot(result.stdout);
return { success: true, ...snapshot };
}
return { listAccelerators, parseAcceleratorSnapshot };
}
module.exports = {
createGpuOpsApi,
parseAcceleratorSnapshot,
parseNvidiaDevices,
parseNvidiaProcesses,
parseAscendDeviceBlock,
parseAscendInfoTable,
parseAscendProcesses,
ACCELERATOR_COLLECT_SCRIPT,
ACCELERATOR_COLLECT_SCRIPT_WINDOWS,
};
@@ -0,0 +1,179 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
parseAcceleratorSnapshot,
parseAscendDeviceBlock,
parseAscendInfoTable,
parseNvidiaDevices,
parseNvidiaProcesses,
} = require("./gpuOps.cjs");
test("parseNvidiaDevices reads csv nounits rows", () => {
const devices = parseNvidiaDevices(
"0, GPU-aaa, NVIDIA GeForce RTX 4090, 42, 1024, 24576, 61, 120.5, 450.0, 35, 550.54.15\n" +
"1, GPU-bbb, NVIDIA A100-SXM4-80GB, [N/A], 0, 81920, 38, 70.0, 400.0, [N/A], 550.54.15\n",
);
assert.equal(devices.length, 2);
assert.equal(devices[0].vendor, "nvidia");
assert.equal(devices[0].name, "NVIDIA GeForce RTX 4090");
assert.equal(devices[0].utilizationPercent, 42);
assert.equal(devices[0].memoryUsedMb, 1024);
assert.equal(devices[0].memoryTotalMb, 24576);
assert.equal(devices[1].utilizationPercent, null);
assert.equal(devices[1].fanPercent, null);
});
test("parseNvidiaProcesses maps uuid to gpu index", () => {
const devices = parseNvidiaDevices("0, GPU-aaa, RTX, 10, 1, 2, 30, 40, 50, 20, 1.0\n");
const processes = parseNvidiaProcesses("GPU-aaa, 1234, python, 2048\n", devices);
assert.equal(processes.length, 1);
assert.equal(processes[0].gpuIndex, 0);
assert.equal(processes[0].pid, 1234);
assert.equal(processes[0].processName, "python");
assert.equal(processes[0].memoryUsedMb, 2048);
});
test("parseAscendDeviceBlock extracts usages and memory pair", () => {
const device = parseAscendDeviceBlock(
0,
`
NPU ID : 0
Chip ID : 0
Product Name : Ascend 910B
Aicore Usage Rate(%) : 17
HBM Usage Rate(%) : 6
HBM Capacity(MB) : 32768
HBM Used Memory(MB) : 2048 / 32768
Temperature(C) : 41
NPU Real-time Power(W) : 71.7
Health : OK
`,
);
assert.equal(device.vendor, "ascend");
assert.equal(device.name, "Ascend 910B");
assert.equal(device.utilizationPercent, 17);
assert.equal(device.memoryUsedMb, 2048);
assert.equal(device.memoryTotalMb, 32768);
assert.equal(device.temperatureC, 41);
assert.equal(device.powerDrawW, 71.7);
assert.equal(device.health, "OK");
});
test("parseAscendDeviceBlock does not treat HBM usage rate percent as megabytes", () => {
const device = parseAscendDeviceBlock(
2,
`
Product Name : Ascend 910B
Aicore Usage Rate(%) : 3
HBM Usage Rate(%) : 25
HBM Capacity(MB) : 32768
Temperature(C) : 40
`,
);
assert.equal(device.memoryTotalMb, 32768);
assert.equal(device.memoryUsedMb, 8192);
});
test("parseAscendProcesses accepts whitespace-delimited table rows", () => {
const { parseAscendProcesses } = require("./gpuOps.cjs");
const processes = parseAscendProcesses(`
| NPU Chip PID Name Memory |
| 0 0 12345 python 1024 |
| 1 0 99 train.py 2048 |
`);
assert.equal(processes.length, 2);
assert.equal(processes[0].gpuIndex, 0);
assert.equal(processes[0].pid, 12345);
assert.equal(processes[0].processName, "python");
assert.equal(processes[0].memoryUsedMb, 1024);
assert.equal(processes[1].pid, 99);
});
test("POSIX accelerator collector keeps sed quotes inside JSON-wrapped sh -c", () => {
const { ACCELERATOR_COLLECT_SCRIPT } = require("./gpuOps.cjs");
assert.match(ACCELERATOR_COLLECT_SCRIPT, /^exec sh -c "/);
assert.match(ACCELERATOR_COLLECT_SCRIPT, /sed -n '/);
// Outer wrapper must not use raw single quotes around the whole script body.
assert.doesNotMatch(ACCELERATOR_COLLECT_SCRIPT, /^exec sh -c '/);
});
test("POSIX accelerator collector is syntactically valid for sh -c", () => {
const { ACCELERATOR_COLLECT_SCRIPT } = require("./gpuOps.cjs");
const { spawnSync } = require("node:child_process");
// Replace remote tools with no-ops so we only validate shell syntax/runtime of wrappers.
const dryRun = ACCELERATOR_COLLECT_SCRIPT
.replaceAll("nvidia-smi", "false")
.replaceAll("npu-smi", "false");
const result = spawnSync("sh", ["-c", dryRun], { encoding: "utf8" });
assert.equal(result.status, 0, `stderr=${result.stderr}\nstdout=${result.stdout}`);
assert.match(result.stdout, /__NC_ACCEL_BEGIN__/);
assert.match(result.stdout, /__NC_ACCEL_END__/);
});
test("listAccelerators uses PowerShell collector for local Windows sessions", async () => {
const { createGpuOpsApi, ACCELERATOR_COLLECT_SCRIPT_WINDOWS } = require("./gpuOps.cjs");
let seenCommand = "";
const gpuOps = createGpuOpsApi({
execOnSession: async () => {
throw new Error("POSIX collector should not run on local Windows");
},
execOnLocalMachine: async (command) => {
seenCommand = command;
return {
success: true,
stdout: "__NC_ACCEL_BEGIN__\n__NC_NVIDIA_DEVICES__\n0, GPU-w, RTX, 1, 2, 3, 4, 5, 6, 7, 8.0\n__NC_ACCEL_END__\n",
};
},
isLocalSession: () => true,
process: { platform: "win32" },
});
const result = await gpuOps.listAccelerators(null, "local-1");
assert.equal(result.success, true);
assert.equal(seenCommand, ACCELERATOR_COLLECT_SCRIPT_WINDOWS);
assert.equal(result.devices.length, 1);
assert.equal(result.devices[0].name, "RTX");
});
test("parseAscendInfoTable reads summary and chip rows", () => {
const devices = parseAscendInfoTable(`
| NPU Name | Health | Power(W) Temp(C) |
| 0 910B3 | OK | 71.8 42 |
| Chip Phy-ID Chip-Logic-ID AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
| 0 0 0 12 100 / 32768 2048 / 32768 |
`);
assert.equal(devices.length, 1);
assert.equal(devices[0].name, "910B3");
assert.equal(devices[0].utilizationPercent, 12);
assert.equal(devices[0].memoryUsedMb, 2048);
assert.equal(devices[0].memoryTotalMb, 32768);
assert.equal(devices[0].temperatureC, 42);
});
test("parseAcceleratorSnapshot merges nvidia and ascend marked sections", () => {
const snapshot = parseAcceleratorSnapshot(`
__NC_ACCEL_BEGIN__
__NC_NVIDIA_DEVICES__
0, GPU-aaa, RTX 4090, 55, 8192, 24576, 60, 200.0, 450.0, 40, 550.54
__NC_NVIDIA_PROCESSES__
GPU-aaa, 99, train.py, 4096
__NC_NPU_BEGIN__
__NC_NPU_DEVICE__=1
Product Name : Ascend 910B
Aicore Usage Rate(%) : 8
HBM Used Memory(MB) : 512 / 32768
Temperature(C) : 39
NPU Real-time Power(W) : 66.0
Health : OK
__NC_NPU_PROCS__
__NC_NPU_END__
__NC_ACCEL_END__
`);
assert.equal(snapshot.devices.length, 2);
assert.equal(snapshot.devices[0].vendor, "ascend");
assert.equal(snapshot.devices[1].vendor, "nvidia");
assert.equal(snapshot.processes.length, 1);
assert.equal(snapshot.nvidiaDriverVersion, "550.54");
});
+28 -4
View File
@@ -3,13 +3,16 @@
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 CAPABILITY_SCRIPT_POSIX = [
"exec sh -c ",
"'",
'printf "%s\\n" "__NC_OS__=$(uname -s)"; ',
'command -v tmux >/dev/null 2>&1 && printf "%s\\n" __NC_TMUX__=1; ',
'command -v docker >/dev/null 2>&1 && printf "%s\\n" __NC_DOCKER__=1',
'command -v 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',
"'",
].join("");
@@ -37,7 +40,9 @@ function parseCapabilities(stdout, isLocal, localPlatform) {
}
const hasTmux = text.includes("__NC_TMUX__=1");
const hasDocker = text.includes("__NC_DOCKER__=1");
return { targetOs, hasTmux, hasDocker, probedAt: Date.now() };
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() };
}
function parseProcessLines(stdout) {
@@ -156,6 +161,12 @@ function createSystemManagerBridge(deps) {
const tmuxOps = createTmuxOpsApi({ execOnSession });
const dockerOps = createDockerOpsApi({ execOnSession, getSession });
const gpuOps = createGpuOpsApi({
execOnSession,
execOnLocalMachine,
isLocalSession,
process,
});
async function probeCapabilities(event, payload) {
const sessionId = payload?.sessionId;
@@ -166,7 +177,7 @@ 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' }",
"$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' }",
8000,
);
if (!result.success) return { success: false, error: result.error || "Probe failed" };
@@ -177,7 +188,10 @@ function createSystemManagerBridge(deps) {
8000,
);
if (!result.success) {
const fallback = await execOnLocalMachine("uname -s; command -v tmux; command -v docker >/dev/null 2>&1 && echo docker_ok", 8000);
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",
8000,
);
if (!fallback.success) return { success: false, error: fallback.error || "Probe failed" };
const text = fallback.stdout || "";
return {
@@ -186,6 +200,8 @@ function createSystemManagerBridge(deps) {
targetOs: platform === "linux" ? "linux" : platform === "darwin" ? "darwin" : "unknown",
hasTmux: text.includes("tmux") && !text.includes("not found"),
hasDocker: text.includes("docker_ok"),
hasNvidiaSmi: text.includes("nvidia_ok"),
hasNpuSmi: text.includes("npu_ok"),
probedAt: Date.now(),
},
};
@@ -353,6 +369,12 @@ function createSystemManagerBridge(deps) {
return { success: true, output: result.stdout };
}
async function listAccelerators(event, payload) {
const sessionId = payload?.sessionId;
if (!sessionId) return { success: false, error: "Missing sessionId" };
return gpuOps.listAccelerators(event, sessionId);
}
function registerWorkerHandle(ipcMain, terminalWorkerManager, channel) {
ipcMain.handle(channel, (event, payload) => terminalWorkerManager.request(channel, payload, {
webContentsId: event?.sender?.id,
@@ -380,6 +402,7 @@ function createSystemManagerBridge(deps) {
"netcatty:system:dockerImageInspect",
"netcatty:system:dockerAction",
"netcatty:system:dockerImageAction",
"netcatty:system:listAccelerators",
].forEach((channel) => registerWorkerHandle(ipcMain, terminalWorkerManager, channel));
return;
}
@@ -400,6 +423,7 @@ function createSystemManagerBridge(deps) {
ipcMain.handle("netcatty:system:dockerImageInspect", dockerImageInspect);
ipcMain.handle("netcatty:system:dockerAction", dockerAction);
ipcMain.handle("netcatty:system:dockerImageAction", dockerImageAction);
ipcMain.handle("netcatty:system:listAccelerators", listAccelerators);
}
return { registerHandlers, probeCapabilities, listProcesses, setupOsc7Tracking };
@@ -314,9 +314,11 @@ test("probeCapabilities reports Docker when docker is installed even if plain do
const conn = {
exec(command, callback) {
assert.match(command, /command -v docker/);
assert.match(command, /command -v nvidia-smi/);
assert.match(command, /command -v npu-smi/);
assert.doesNotMatch(command, /docker info/);
assert.doesNotMatch(command, /docker\.sock/);
callback(null, createFakeExecStream("__NC_OS__=Linux\n__NC_DOCKER__=1\n"));
callback(null, createFakeExecStream("__NC_OS__=Linux\n__NC_DOCKER__=1\n__NC_NVIDIA_SMI__=1\n"));
},
};
const sessions = new Map([["s1", { conn, type: "ssh" }]]);
@@ -329,6 +331,8 @@ test("probeCapabilities reports Docker when docker is installed even if plain do
assert.equal(result.success, true);
assert.equal(result.capabilities.hasDocker, true);
assert.equal(result.capabilities.hasNvidiaSmi, true);
assert.equal(result.capabilities.hasNpuSmi, false);
});
test("setupOsc7Tracking runs the setup command through the active session executor", async () => {
@@ -337,6 +337,7 @@ test("terminal worker mode proxies system management requests", async () => {
"netcatty:system:setupOsc7Tracking",
"netcatty:system:listTmuxSessions",
"netcatty:system:listDockerContainers",
"netcatty:system:listAccelerators",
]) {
assert.equal(ipcMain.handlers.has(channel), true, `${channel} should be proxied`);
await ipcMain.handlers.get(channel)(fakeEvent, { sessionId: "ssh-1" });
@@ -350,6 +351,7 @@ test("terminal worker mode proxies system management requests", async () => {
"netcatty:system:setupOsc7Tracking",
"netcatty:system:listTmuxSessions",
"netcatty:system:listDockerContainers",
"netcatty:system:listAccelerators",
],
);
});
+3
View File
@@ -420,6 +420,9 @@ function createPreloadApi(ctx) {
getDockerStats: async (options) => {
return ipcRenderer.invoke("netcatty:system:dockerStats", options);
},
listAccelerators: async (sessionId) => {
return ipcRenderer.invoke("netcatty:system:listAccelerators", { sessionId });
},
dockerInspect: async (options) => {
return ipcRenderer.invoke("netcatty:system:dockerInspect", options);
},
+9
View File
@@ -85,6 +85,15 @@ declare global {
error?: string;
stats?: import("../../domain/systemManager/types").DockerStatInfo[];
}>;
listAccelerators?(sessionId: string): Promise<{
success: boolean;
pending?: boolean;
error?: string;
devices?: import("../../domain/systemManager/types").AcceleratorDeviceInfo[];
processes?: import("../../domain/systemManager/types").AcceleratorProcessInfo[];
nvidiaDriverVersion?: string | null;
probedAt?: number;
}>;
dockerInspect?(options: { sessionId: string; containerId: string }): Promise<{
success: boolean;
error?: string;