mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-09 15:52:14 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
140906b4f5 | ||
|
|
24af7ce456 | ||
|
|
de5828fc35 | ||
|
|
843543e77d |
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\}/);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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",
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user