Compare commits

..
12 changed files with 201 additions and 52 deletions

No files matched your search

+1
View File
@@ -93,6 +93,7 @@ export const enTerminalMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': 'Reconnect this session',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.gpu': 'GPU Usage',
'terminal.serverStats.memory': 'Memory Usage',
'terminal.serverStats.memoryDetails': 'Memory Details',
'terminal.serverStats.memUsed': 'Used',
+1
View File
@@ -93,6 +93,7 @@ export const esTerminalMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': 'Reconectar esta sesión',
'terminal.serverStats.cpu': 'Uso de CPU',
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
'terminal.serverStats.gpu': 'Uso de GPU',
'terminal.serverStats.memory': 'Uso de memoria',
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
'terminal.serverStats.memUsed': 'Usada',
+1
View File
@@ -114,6 +114,7 @@ export const ruTerminalMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': 'Переподключить эту сессию',
'terminal.serverStats.cpu': 'Использование CPU',
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
'terminal.serverStats.gpu': 'Использование GPU',
'terminal.serverStats.memory': 'Использование памяти',
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
'terminal.serverStats.memUsed': 'Использовано',
+1
View File
@@ -503,6 +503,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.gpu': 'GPU 使用率',
'terminal.serverStats.memory': '内存使用',
'terminal.serverStats.memoryDetails': '内存详情',
'terminal.serverStats.memUsed': '已用',
+1
View File
@@ -503,6 +503,7 @@ export const zhTWVaultMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.gpu': 'GPU 使用率',
'terminal.serverStats.memory': '記憶體使用',
'terminal.serverStats.memoryDetails': '記憶體詳情',
'terminal.serverStats.memUsed': '已用',
+12
View File
@@ -33,6 +33,10 @@ export interface ServerStats {
cpu: number | null; // CPU usage percentage (0-100)
cpuCores: number | null; // Number of CPU cores
cpuPerCore: number[]; // Per-core CPU usage array
gpu: number | null; // NVIDIA GPU utilization percentage (0-100), null when no GPU data
gpuName: string | null; // First GPU name (tooltip)
gpuMemUsed: number | null; // Summed VRAM used in MB
gpuMemTotal: number | null; // Summed VRAM total in MB
memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB
@@ -107,6 +111,10 @@ function createEmptyServerStats(): ServerStats {
cpu: null,
cpuCores: null,
cpuPerCore: [],
gpu: null,
gpuName: null,
gpuMemUsed: null,
gpuMemTotal: null,
memTotal: null,
memUsed: null,
memFree: null,
@@ -200,6 +208,10 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
cpu: stats.cpu ?? null,
cpuCores: stats.cpuCores ?? null,
cpuPerCore: stats.cpuPerCore || [],
gpu: stats.gpu ?? null,
gpuName: stats.gpuName ?? null,
gpuMemUsed: stats.gpuMemUsed ?? null,
gpuMemTotal: stats.gpuMemTotal ?? null,
memTotal: stats.memTotal ?? null,
memUsed: stats.memUsed ?? null,
memFree: stats.memFree ?? null,
@@ -17,3 +17,15 @@ test("server stats stay subscribed while the terminal is in the background", ()
assert.doesNotMatch(source, /usePaneVisible/);
assert.doesNotMatch(source, /isVisible,/);
});
test("GPU chip only renders when the remote reports GPU utilization", () => {
const source = readFileSync(new URL("./TerminalServerStats.tsx", import.meta.url), "utf8");
// Conditional render — hosts without nvidia-smi must not show an empty GPU chip.
assert.match(source, /\{serverStats\.gpu !== null && \(/);
// Sits directly after the CPU chip, before the memory chip.
assert.match(
source,
/terminal\.serverStats\.gpu[\s\S]{0,2000}terminal\.serverStats\.memory/,
);
});
+24 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Activity, ArrowDownToLine, ArrowUpFromLine, Cpu, HardDrive, MemoryStick } from 'lucide-react';
import { Activity, ArrowDownToLine, ArrowUpFromLine, Cpu, Gpu, HardDrive, MemoryStick } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
@@ -122,6 +122,29 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
</div>
</HoverCardContent>
</HoverCard>
{/* GPU — only rendered when the remote reports NVIDIA GPU data (Linux nvidia-smi) */}
{serverStats.gpu !== null && (
<div
className="flex items-center gap-0.5 min-w-0 shrink"
aria-label={t("terminal.serverStats.gpu")}
title={
[
serverStats.gpuName,
serverStats.gpuMemUsed !== null && serverStats.gpuMemTotal !== null && serverStats.gpuMemTotal > 0
? `VRAM ${(serverStats.gpuMemUsed / 1024).toFixed(1)}/${(serverStats.gpuMemTotal / 1024).toFixed(1)}G`
: null,
].filter(Boolean).join(' · ') || undefined
}
>
<Gpu size={10} className="flex-shrink-0" />
<span className="truncate">
{serverStats.gpu}%
{serverStats.gpuMemUsed !== null && serverStats.gpuMemTotal !== null && serverStats.gpuMemTotal > 0
? ` (${(serverStats.gpuMemUsed / 1024).toFixed(1)}/${(serverStats.gpuMemTotal / 1024).toFixed(1)}G)`
: ''}
</span>
</div>
)}
{/* Memory with HoverCard for htop-style bar and top processes */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
+41 -9
View File
@@ -635,13 +635,7 @@ function createSessionOpsApi(ctx) {
_rc_cwd=$(readlink "/proc/$1/cwd" 2>/dev/null)
if [ -n "$_rc_cwd" ]; then printf '%s\\n' "$_rc_cwd"; return 0; fi
if command -v lsof >/dev/null 2>&1; then
# An unknown-type record can put a readlink error in its name field.
# Only a confirmed directory name is usable as an upload destination.
_rc_cwd=$(LC_ALL=C lsof -a -p "$1" -d cwd -Fnt 2>/dev/null | awk '
/^f/ { is_dir=0 }
/^t/ { is_dir=($0 == "tDIR" || $0 == "tVDIR") }
/^n/ && is_dir { print substr($0, 2); exit }
')
_rc_cwd=$(LC_ALL=C lsof -a -p "$1" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n1)
if [ -n "$_rc_cwd" ]; then printf 'NETCATTY_LSOF_CWD=%s\\n' "$_rc_cwd"; return 0; fi
fi
return 1
@@ -1220,8 +1214,17 @@ function createSessionOpsApi(ctx) {
`kernel=$(uname -r 2>/dev/null || echo "")`,
`uptime=$(awk '{printf "%.0f",$1}' /proc/uptime 2>/dev/null || echo "")`,
`loadavg=$(awk '{print $1" "$2" "$3}' /proc/loadavg 2>/dev/null || echo "")`,
// GPU: NVIDIA GPUs expose utilization + VRAM through the driver-bundled
// nvidia-smi. Best-effort — empty on hosts without the tool, so the UI
// simply hides the GPU chip. Utilization is averaged across GPUs and
// VRAM is summed independently; incomplete memory totals remain unknown.
// The first GPU's name is kept for the tooltip.
// Bound GPU queries independently so a stuck driver cannot stop CPU/memory
// polling. Hosts without timeout also omit this optional metric.
// macOS is skipped: powermetrics needs root, which stats must not.
`gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`,
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
].join('; ');
// Client-side run bound shared by the stats and disk probes; the remote
@@ -1339,7 +1342,10 @@ function createSessionOpsApi(ctx) {
let kernelRelease = "";
let uptimeSeconds = null;
let loadAverage = [];
let gpu = null;
let gpuName = null;
let gpuMemUsed = null;
let gpuMemTotal = null;
for (const part of parts) {
if (part.startsWith('CPU:')) {
// macOS: command reports normalized CPU% directly (no delta needed)
@@ -1466,6 +1472,28 @@ function createSessionOpsApi(ctx) {
}
}
}
} else if (part.startsWith('GPU:')) {
// Linux: "<avgUtil> <sumMemUsed> <sumMemTotal> <first GPU name>"
const gpuStr = part.substring(4).trim();
if (gpuStr && gpuStr !== '') {
const gpuFields = gpuStr.split(/\s+/);
const util = parseInt(gpuFields[0], 10);
if (!isNaN(util)) {
gpu = Math.min(100, Math.max(0, util));
if (gpuFields.length >= 2) {
const memUsed = parseInt(gpuFields[1], 10);
if (!isNaN(memUsed)) gpuMemUsed = memUsed;
}
if (gpuFields.length >= 3) {
const memTotal = parseInt(gpuFields[2], 10);
if (!isNaN(memTotal)) gpuMemTotal = memTotal;
}
if (gpuFields.length >= 4) {
const name = gpuFields.slice(3).join(' ').trim();
if (name) gpuName = name;
}
}
}
} else if (part.startsWith('HOST:')) {
hostname = part.substring(5).trim();
} else if (part.startsWith('OS:')) {
@@ -1614,6 +1642,10 @@ function createSessionOpsApi(ctx) {
cpu, // CPU usage percentage (0-100)
cpuCores, // Number of CPU cores
cpuPerCore, // Per-core CPU usage array
gpu, // NVIDIA GPU utilization percentage (0-100), null when unavailable
gpuName, // First GPU name (tooltip), null when unavailable
gpuMemUsed, // Summed VRAM used in MB, null when unavailable
gpuMemTotal, // Summed VRAM total in MB, null when unavailable
memTotal, // Total memory in MB
memUsed, // Used memory in MB (excluding buffers/cache)
memFree, // Free memory in MB
@@ -29,7 +29,7 @@ function makeApi(session, siblingSessions = [], overrides = {}) {
sessions,
setTimeout: overrides.setTimeout || setTimeout,
clearTimeout: overrides.clearTimeout || clearTimeout,
quoteShellArg: overrides.quoteShellArg || quoteShellArg,
quoteShellArg,
log: () => {},
});
}
@@ -123,47 +123,6 @@ test("session cwd probe decodes the marked lsof pathname", async () => {
assert.deepEqual(result, { success: true, cwd: "/srv/中文" });
});
test("lsof cwd fallback accepts only directory records, not diagnostic names (#3237)", async () => {
let script;
const api = makeApi({
shellPid: "4242",
connRef: { count: 1 },
stream: {},
conn: {
exec(_command, callback) {
callback(null, makePwdStream("/srv/app", "4242"));
},
},
}, [], {
quoteShellArg(value) {
if (!script && value.includes("read_shell_cwd()")) script = value;
return quoteShellArg(value);
},
});
await api.getSessionPwd(null, { sessionId: "session-1" });
const helper = script.match(/ read_shell_cwd\(\) \{[\s\S]*?\n \}/)?.[0];
assert.ok(helper, "exercise the actual remote cwd helper");
const { spawnSync } = require("node:child_process");
for (const [output, expected] of [
["p4242\nfcwd\ntunknown\nn/proc/4242/cwd (readlink: Permission denied)\n", null],
["p4242\nfcwd\ntunknown\nn/proc/4242/cwd (readlink: No such file or directory)\n", null],
["p4242\nfcwd\nn/proc/4242/cwd\n", null],
["p4242\nfcwd\ntDIR\nn/srv/app\n", "/srv/app"],
["p4242\nfcwd\ntVDIR\nn/usr/home/alice\n", "/usr/home/alice"],
["p4242\nfcwd\ntDIR\nn/tmp/literal (readlink: Permission denied)\n", "/tmp/literal (readlink: Permission denied)"],
["p4242\nfcwd\ntDIR\nn/tmp/\\xe4\\xb8\\xad\\xe6\\x96\\x87\n", "/tmp/\\xe4\\xb8\\xad\\xe6\\x96\\x87"],
]) {
const result = spawnSync("sh", ["-c", `
readlink() { return 1; }
lsof() { printf '%s' "$LSOF_TEST_OUTPUT"; }
${helper}
read_shell_cwd 4242
`], { encoding: "utf8", env: { ...process.env, LSOF_TEST_OUTPUT: output } });
assert.equal(result.status, expected === null ? 1 : 0, output);
assert.equal(result.stdout, expected === null ? "" : `NETCATTY_LSOF_CWD=${expected}\n`, output);
}
});
test("session cwd probe closes a remote command that exceeds its timeout", async () => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
@@ -2,6 +2,9 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const { EventEmitter } = require("node:events");
const { mkdtempSync, writeFileSync, rmSync } = require("node:fs");
const { join } = require("node:path");
const { getTempFilePath } = require("../tempDirBridge.cjs");
const { createSessionOpsApi } = require("./sessionOps.cjs");
@@ -619,6 +622,105 @@ test("getServerStats does not touch the companion path for a normal SSH session"
assert.equal(result.success, true);
});
test("getServerStats reports null GPU fields when the host has no nvidia-smi data", async () => {
const sessions = new Map();
sessions.set("sid", { type: "ssh", conn: fakeConn(LINUX_STATS) });
const api = makeSessionOps(sessions);
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
assert.equal(result.success, true);
assert.equal(result.stats.gpu, null);
assert.equal(result.stats.gpuName, null);
assert.equal(result.stats.gpuMemUsed, null);
assert.equal(result.stats.gpuMemTotal, null);
});
test("getServerStats parses NVIDIA GPU utilization and VRAM from the stats line", async () => {
// Mirrors the remote line: nvidia-smi CSV is space-squeezed to
// "<avgUtil> <sumMemUsed> <sumMemTotal> <first GPU name>".
const sessions = new Map();
sessions.set("sid", {
type: "ssh",
conn: fakeConn(`${LINUX_STATS}|GPU:73 2048 24576 NVIDIA GeForce RTX 4090`),
});
const api = makeSessionOps(sessions);
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
assert.equal(result.success, true);
assert.equal(result.stats.gpu, 73);
assert.equal(result.stats.gpuName, "NVIDIA GeForce RTX 4090");
assert.equal(result.stats.gpuMemUsed, 2048);
assert.equal(result.stats.gpuMemTotal, 24576);
});
test("getServerStats tolerates a malformed GPU section", async () => {
const sessions = new Map();
sessions.set("sid", {
type: "ssh",
conn: fakeConn(`${LINUX_STATS}|GPU:[Not Supported]`),
});
const api = makeSessionOps(sessions);
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
assert.equal(result.success, true);
assert.equal(result.stats.gpu, null);
assert.equal(result.stats.gpuName, null);
});
for (const scenario of [
{ name: "multiple GPUs", script: "printf '%s\\n' '73, 2048, 24576, NVIDIA RTX 4090' '71, 1024, 24576, NVIDIA RTX 4090'", gpu: 72, used: 3072, total: 49152 },
{ name: "idle GPU", script: "printf '%s\\n' '0, 0, 24576, NVIDIA RTX 4090'", gpu: 0, used: 0, total: 24576 },
{ name: "mixed utilization availability", script: "printf '%s\\n' '73, 2048, 24576, NVIDIA RTX 4090' '[N/A], 1024, 8192, NVIDIA A100 MIG'", gpu: 73, used: 3072, total: 32768 },
{ name: "unavailable memory usage", script: "printf '%s\\n' '73, [N/A], 24576, NVIDIA RTX 4090'", gpu: 73, total: 24576 },
{ name: "partial memory totals", script: "printf '%s\\n' '73, 2048, 24576, NVIDIA RTX 4090' '71, 1024, [N/A], NVIDIA RTX 4090'", gpu: 72, used: 3072 },
{ name: "unsupported utilization", script: "printf '%s\\n' '[N/A], 2048, 24576, NVIDIA RTX 4090'", gpu: null },
{ name: "failed query with partial output", script: "printf '%s\\n' '73, 2048, 24576, NVIDIA RTX 4090'; exit 1", gpu: null },
{ name: "missing query tool", script: "exit 127", gpu: null },
{ name: "missing timeout tool", script: "exec sleep 20", gpu: null, noTimeout: true },
{ name: "blocked GPU query", script: "trap '' TERM; exec sleep 20", gpu: null },
]) {
test(`real stats shell preserves other metrics with ${scenario.name}`, async (t) => {
if (spawnSync("timeout", ["-s", "KILL", "2", "true"]).status !== 0) {
t.skip("GNU/BusyBox-compatible timeout is unavailable on this test host");
return;
}
const dir = mkdtempSync(getTempFilePath("gpu-stats-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
writeFileSync(join(dir, "nvidia-smi"), `#!/bin/sh\n${scenario.script}\n`, { mode: 0o755 });
const sessions = new Map([["sid", {
type: "ssh",
conn: {
exec(command, cb) {
const execution = spawnSync("sh", ["-c", [
"uname() { printf '%s\\n' Linux; }",
"nproc() { printf '%s\\n' 4; }",
"top() { return 1; }",
"df() { return 1; }",
scenario.noTimeout
? unwrapExecShC(command).replace("timeout -s KILL 2 nvidia-smi", "nc_missing_timeout -s KILL 2 nvidia-smi")
: unwrapExecShC(command),
].join("\n")], {
encoding: "utf8",
env: { ...process.env, PATH: `${dir}:${process.env.PATH}` },
timeout: 7000,
});
assert.equal(execution.status, 0, execution.stderr);
cb(null, fakeStream(execution.stdout));
},
},
}]]);
const result = await makeSessionOps(sessions).getServerStats({ sender: {} }, { sessionId: "sid" });
assert.equal(result.success, true);
assert.equal(result.stats.cpuCores, 4);
assert.equal(result.stats.gpu, scenario.gpu);
assert.equal(result.stats.gpuMemUsed, scenario.used ?? null);
assert.equal(result.stats.gpuMemTotal, scenario.total ?? null);
});
}
test("getServerStats measures latency by pinging the stats connection", async () => {
const sessions = new Map();
const session = {
+4
View File
@@ -281,6 +281,10 @@ declare global {
cpu: number | null; // CPU usage percentage (0-100)
cpuCores: number | null; // Number of CPU cores
cpuPerCore: number[]; // Per-core CPU usage array
gpu?: number | null; // NVIDIA utilization; absent on unsupported transports
gpuName?: string | null; // First GPU name
gpuMemUsed?: number | null; // Summed VRAM used in MB
gpuMemTotal?: number | null; // Summed VRAM total in MB
memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB