mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(header): opt-in GPU and CPU clock readings after CPU and MEM
This commit is contained in:
1 parent
002de32a08
commit
e1d6471808
16 files changed
+947
-12
No files matched your search
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* @fileoverview Optional host readings for the header stats: GPU load and memory, and the live CPU and GPU clock
|
||||
* speeds.
|
||||
*
|
||||
* Every reader is opt-in per request (the route only calls the ones the browser asks for, and the browser only
|
||||
* asks when the matching setting is on), answers `undefined` when the host has no such sensor, and never
|
||||
* throws. Reads are cached for a couple of seconds so several open tabs cost one read.
|
||||
*
|
||||
* Sources, with no extra dependency:
|
||||
* - GPU: `nvidia-smi` (NVIDIA), else the amdgpu sysfs files (`gpu_busy_percent`, `mem_info_vram_*`,
|
||||
* `pp_dpm_sclk`), else, inside WSL, the Windows performance counters through `typeperf.exe` (any vendor,
|
||||
* Intel included: load and memory in use, but Windows publishes no GPU clock for them).
|
||||
* - CPU clock: inside WSL the Windows `Processor Frequency` counter (WSL's own cpuinfo is a fixed number),
|
||||
* else the mean of `scaling_cur_freq` across cores, else the `cpu MHz` lines of /proc/cpuinfo.
|
||||
*
|
||||
* The parsers are pure and exported so the tests need no hardware.
|
||||
*
|
||||
* @module host-sensors
|
||||
*/
|
||||
|
||||
import { execFile, spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface GpuReading {
|
||||
/** Core utilisation, 0 to 100. */
|
||||
percent: number;
|
||||
/** Graphics memory in use as a share of what is available, 0 to 100, when that is known. */
|
||||
memPercent?: number;
|
||||
memUsedMB?: number;
|
||||
memTotalMB?: number;
|
||||
/** Current graphics clock in MHz, when known. */
|
||||
mhz?: number;
|
||||
}
|
||||
|
||||
export interface HostSensors {
|
||||
gpu?: GpuReading;
|
||||
cpuMhz?: number;
|
||||
}
|
||||
|
||||
export interface SensorRequest {
|
||||
/** GPU load, graphics memory and clock. */
|
||||
gpu?: boolean;
|
||||
cpuMhz?: boolean;
|
||||
}
|
||||
|
||||
const CACHE_MS = 2000;
|
||||
/** A counter sample older than this (a few sampling intervals) is no longer a live reading. */
|
||||
const FRESH_MS = 10000;
|
||||
const EXEC_TIMEOUT_MS = 1500;
|
||||
const SYS_DRM = '/sys/class/drm';
|
||||
const SYS_CPU = '/sys/devices/system/cpu';
|
||||
const TYPEPERF = '/mnt/c/Windows/System32/typeperf.exe';
|
||||
const POWERSHELL = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe';
|
||||
const MB = 1048576;
|
||||
|
||||
const clampPercent = (n: number) => Math.min(100, Math.max(0, Math.round(n)));
|
||||
const finite = (n: number) => (Number.isFinite(n) ? n : undefined);
|
||||
|
||||
/** Parse `nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr --format=csv,noheader,nounits`. */
|
||||
export function parseNvidiaSmi(output: string): GpuReading | undefined {
|
||||
const line = output.split('\n').find((l) => l.trim());
|
||||
if (!line) return undefined;
|
||||
const [util, used, total, clock] = line.split(',').map((s) => finite(Number(s.trim())));
|
||||
if (util === undefined) return undefined;
|
||||
const reading: GpuReading = { percent: clampPercent(util) };
|
||||
if (used !== undefined && total !== undefined && total > 0) {
|
||||
reading.memUsedMB = Math.round(used);
|
||||
reading.memTotalMB = Math.round(total);
|
||||
reading.memPercent = clampPercent((used / total) * 100);
|
||||
}
|
||||
if (clock !== undefined && clock > 0) reading.mhz = Math.round(clock);
|
||||
return reading;
|
||||
}
|
||||
|
||||
/** The active line of an amdgpu `pp_dpm_sclk` table (`1: 1800Mhz *`). */
|
||||
export function parseDpmClock(text: string): number | undefined {
|
||||
const active = text.split('\n').find((l) => l.trim().endsWith('*'));
|
||||
const mhz = active ? /(\d+)\s*mhz/i.exec(active)?.[1] : undefined;
|
||||
return mhz ? Number(mhz) : undefined;
|
||||
}
|
||||
|
||||
/** Mean of per-core `scaling_cur_freq` values (kHz) as MHz. */
|
||||
export function meanKhzAsMhz(values: number[]): number | undefined {
|
||||
const good = values.filter((v) => Number.isFinite(v) && v > 0);
|
||||
if (!good.length) return undefined;
|
||||
return Math.round(good.reduce((a, b) => a + b, 0) / good.length / 1000);
|
||||
}
|
||||
|
||||
/** Mean of the `cpu MHz : 3412.5` lines of /proc/cpuinfo. */
|
||||
export function parseCpuInfoMhz(text: string): number | undefined {
|
||||
const values = [...text.matchAll(/^cpu MHz\s*:\s*([\d.]+)/gim)].map((m) => Number(m[1]));
|
||||
return meanKhzAsMhz(values.map((v) => v * 1000));
|
||||
}
|
||||
|
||||
export interface WindowsCounters {
|
||||
gpu?: { percent: number; memUsedMB: number; dedicated: boolean };
|
||||
cpuMhz?: number;
|
||||
}
|
||||
|
||||
const ENGINE =
|
||||
/GPU Engine\(.*?_luid_(0x[0-9a-f]+_0x[0-9a-f]+)_phys_\d+_eng_\d+_engtype_([^)]*)\)\\Utilization Percentage/i;
|
||||
const ADAPTER_MEMORY = /GPU Adapter Memory\(luid_(0x[0-9a-f]+_0x[0-9a-f]+)_phys_\d+\)\\(Shared|Dedicated) Usage/i;
|
||||
const CPU_FREQUENCY = /Processor Information\(_Total\)\\Processor Frequency/i;
|
||||
|
||||
/**
|
||||
* Parse one `typeperf.exe -sc 1 ...` sample: a quoted CSV header of counter paths and one row of values.
|
||||
* Task Manager's GPU figure is its busiest engine, so per adapter the load is the largest per-engine-type sum
|
||||
* (3D, copy, video decode...), capped at 100. The adapter that holds the most graphics memory is the real one
|
||||
* (Windows also lists software adapters that hold a few kilobytes). A `Shared Usage` only means an integrated
|
||||
* GPU, whose memory is carved out of system RAM.
|
||||
*/
|
||||
export function parseTypeperf(output: string): WindowsCounters {
|
||||
const rows = output
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l.startsWith('"'))
|
||||
.map((l) => l.slice(1, l.lastIndexOf('"')).split('","'));
|
||||
if (rows.length < 2) return {};
|
||||
const [names, values] = rows;
|
||||
const engines = new Map<string, Map<string, number>>();
|
||||
const memory = new Map<string, { shared: number; dedicated: number }>();
|
||||
let cpuMhz: number | undefined;
|
||||
names.forEach((name, i) => {
|
||||
const value = finite(Number(values[i]));
|
||||
if (value === undefined || values[i].trim() === '') return;
|
||||
const engine = ENGINE.exec(name);
|
||||
if (engine) {
|
||||
const byType = engines.get(engine[1].toLowerCase()) ?? new Map<string, number>();
|
||||
byType.set(engine[2], (byType.get(engine[2]) ?? 0) + value);
|
||||
engines.set(engine[1].toLowerCase(), byType);
|
||||
return;
|
||||
}
|
||||
const mem = ADAPTER_MEMORY.exec(name);
|
||||
if (mem) {
|
||||
const luid = mem[1].toLowerCase();
|
||||
const entry = memory.get(luid) ?? { shared: 0, dedicated: 0 };
|
||||
if (mem[2].toLowerCase() === 'shared') entry.shared = value;
|
||||
else entry.dedicated = value;
|
||||
memory.set(luid, entry);
|
||||
return;
|
||||
}
|
||||
if (CPU_FREQUENCY.test(name) && value > 0) cpuMhz = Math.round(value);
|
||||
});
|
||||
const result: WindowsCounters = {};
|
||||
if (cpuMhz) result.cpuMhz = cpuMhz;
|
||||
const luids = new Set([...engines.keys(), ...memory.keys()]);
|
||||
let best: { luid: string; bytes: number; load: number } | undefined;
|
||||
for (const luid of luids) {
|
||||
const m = memory.get(luid);
|
||||
const bytes = (m?.shared ?? 0) + (m?.dedicated ?? 0);
|
||||
const load = Math.max(0, ...(engines.get(luid)?.values() ?? []));
|
||||
if (!best || bytes > best.bytes || (bytes === best.bytes && load > best.load)) best = { luid, bytes, load };
|
||||
}
|
||||
if (best && (engines.size > 0 || memory.size > 0)) {
|
||||
result.gpu = {
|
||||
percent: clampPercent(best.load),
|
||||
memUsedMB: Math.round(best.bytes / MB),
|
||||
dedicated: (memory.get(best.luid)?.dedicated ?? 0) > 0,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const run = (cmd: string, args: string[], timeout = EXEC_TIMEOUT_MS) =>
|
||||
new Promise<string | undefined>((resolve) => {
|
||||
execFile(cmd, args, { timeout, windowsHide: true, maxBuffer: 4 * MB }, (err, stdout) =>
|
||||
resolve(err ? undefined : stdout)
|
||||
);
|
||||
});
|
||||
|
||||
const readText = (path: string) =>
|
||||
readFile(path, 'utf8').then(
|
||||
(t) => t.trim(),
|
||||
() => undefined
|
||||
);
|
||||
const readNumber = async (path: string) => {
|
||||
const text = await readText(path);
|
||||
return text === undefined ? undefined : finite(Number(text));
|
||||
};
|
||||
|
||||
/** Linux running under WSL: Windows programs can be started, and the GPU belongs to Windows. */
|
||||
function inWsl(): boolean {
|
||||
return (
|
||||
process.platform === 'linux' &&
|
||||
(Boolean(process.env.WSL_INTEROP) ||
|
||||
existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') ||
|
||||
existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late'))
|
||||
);
|
||||
}
|
||||
|
||||
const typeperfPath = () => (existsSync(TYPEPERF) ? TYPEPERF : 'typeperf.exe');
|
||||
|
||||
let windowsRamMB: Promise<number | undefined> | undefined;
|
||||
|
||||
/** Windows' installed RAM, asked once (PowerShell takes about a second) and remembered when it answers. */
|
||||
function readWindowsRamMB(): Promise<number | undefined> {
|
||||
windowsRamMB ??= run(
|
||||
existsSync(POWERSHELL) ? POWERSHELL : 'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory'],
|
||||
8000
|
||||
).then((out) => {
|
||||
const bytes = out ? finite(Number(out.trim())) : undefined;
|
||||
if (!bytes || bytes <= 0) windowsRamMB = undefined;
|
||||
return bytes && bytes > 0 ? Math.round(bytes / MB) : undefined;
|
||||
});
|
||||
return windowsRamMB;
|
||||
}
|
||||
|
||||
/** What the sampler needs from a child process; `ChildProcess` fits, and tests pass a fake. */
|
||||
export type SamplerChild = Pick<ChildProcess, 'stdout' | 'kill' | 'once'>;
|
||||
|
||||
export interface CounterStreamOptions {
|
||||
start: (counters: string[]) => SamplerChild;
|
||||
now?: () => number;
|
||||
/** How long the first read waits for the first sample. */
|
||||
firstSampleMs?: number;
|
||||
/** A sampler older than this is replaced, so counters of processes that started since are picked up. */
|
||||
recycleMs?: number;
|
||||
/** A sampler nobody has read for this long is stopped. */
|
||||
idleMs?: number;
|
||||
/** After the sampler fails to start or exits at once, do not try again for this long. */
|
||||
backoffMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One long-running `typeperf.exe -si 2` instead of a process per read: every `typeperf` sample takes over a
|
||||
* second (rate counters need two collections), which would keep one running back to back. The newest row is kept
|
||||
* and handed out; the process is replaced every `recycleMs` (its wildcard counters are expanded once, at
|
||||
* start) and stopped after `idleMs` without a read, so nothing runs while the settings are off.
|
||||
*/
|
||||
export function createCounterStream(options: CounterStreamOptions) {
|
||||
const now = options.now ?? Date.now;
|
||||
const firstSampleMs = options.firstSampleMs ?? 8000;
|
||||
const recycleMs = options.recycleMs ?? 30000;
|
||||
const idleMs = options.idleMs ?? 30000;
|
||||
const backoffMs = options.backoffMs ?? 60000;
|
||||
let child: SamplerChild | undefined;
|
||||
let key = '';
|
||||
let startedAt = 0;
|
||||
let lastRead = 0;
|
||||
let blockedUntil = 0;
|
||||
let header: string | undefined;
|
||||
let latest: { at: number; value: WindowsCounters } | undefined;
|
||||
let waiting: Array<() => void> = [];
|
||||
|
||||
const stop = () => {
|
||||
const old = child;
|
||||
child = undefined;
|
||||
header = undefined;
|
||||
if (old) {
|
||||
try {
|
||||
old.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const begin = (counters: string[]) => {
|
||||
stop();
|
||||
const nextKey = counters.join('\n');
|
||||
// A sample of other counters is no answer; one from an older sampler of the same counters still is.
|
||||
if (nextKey !== key) latest = undefined;
|
||||
key = nextKey;
|
||||
startedAt = now();
|
||||
let buffer = '';
|
||||
let sampled = false;
|
||||
let started: SamplerChild;
|
||||
try {
|
||||
started = options.start(counters);
|
||||
} catch {
|
||||
blockedUntil = now() + backoffMs;
|
||||
return;
|
||||
}
|
||||
child = started;
|
||||
started.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
buffer += chunk.toString();
|
||||
let at: number;
|
||||
while ((at = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, at).replace(/\r$/, '');
|
||||
buffer = buffer.slice(at + 1);
|
||||
if (!line.startsWith('"')) continue;
|
||||
if (!header) {
|
||||
header = line;
|
||||
continue;
|
||||
}
|
||||
sampled = true;
|
||||
latest = { at: now(), value: parseTypeperf(`${header}\n${line}`) };
|
||||
const release = waiting;
|
||||
waiting = [];
|
||||
release.forEach((fn) => fn());
|
||||
}
|
||||
});
|
||||
const ended = () => {
|
||||
if (child !== started) return;
|
||||
child = undefined;
|
||||
// It ended without a sample (typeperf missing, counters unknown): leave it alone for a while.
|
||||
if (!sampled) blockedUntil = now() + backoffMs;
|
||||
const release = waiting;
|
||||
waiting = [];
|
||||
release.forEach((fn) => fn());
|
||||
};
|
||||
started.once('exit', ended);
|
||||
started.once('error', ended);
|
||||
};
|
||||
|
||||
return {
|
||||
/** The newest sample for these counters; the first read waits briefly for one. */
|
||||
async read(counters: string[]): Promise<WindowsCounters | undefined> {
|
||||
lastRead = now();
|
||||
if (now() < blockedUntil) return undefined;
|
||||
const wanted = counters.join('\n');
|
||||
if (!child || key !== wanted || now() - startedAt > recycleMs) begin(counters);
|
||||
if (!latest || key !== wanted || now() - latest.at >= FRESH_MS) {
|
||||
if (!child) return undefined;
|
||||
await new Promise<void>((resolve) => {
|
||||
waiting.push(resolve);
|
||||
setTimeout(resolve, firstSampleMs).unref();
|
||||
});
|
||||
}
|
||||
return latest && now() - latest.at < FRESH_MS ? latest.value : undefined;
|
||||
},
|
||||
/** Stop the sampler when nobody has read it lately. */
|
||||
sweep() {
|
||||
if (child && now() - lastRead > idleMs) {
|
||||
stop();
|
||||
latest = undefined;
|
||||
}
|
||||
},
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
const windowsCounters = createCounterStream({
|
||||
start: (counters) =>
|
||||
spawn(typeperfPath(), ['-si', '2', ...counters], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }),
|
||||
});
|
||||
setInterval(() => windowsCounters.sweep(), 10000).unref();
|
||||
|
||||
async function readWindowsCounters(request: SensorRequest): Promise<WindowsCounters> {
|
||||
const counters: string[] = [];
|
||||
if (request.gpu) {
|
||||
counters.push(
|
||||
'\\GPU Engine(*)\\Utilization Percentage',
|
||||
'\\GPU Adapter Memory(*)\\Shared Usage',
|
||||
'\\GPU Adapter Memory(*)\\Dedicated Usage'
|
||||
);
|
||||
}
|
||||
if (request.cpuMhz) counters.push('\\Processor Information(_Total)\\Processor Frequency');
|
||||
if (!counters.length) return {};
|
||||
return (await windowsCounters.read(counters)) ?? {};
|
||||
}
|
||||
|
||||
async function readAmdGpu(): Promise<GpuReading | undefined> {
|
||||
const cards = (await readdir(SYS_DRM).catch(() => [] as string[])).filter((n) => /^card\d+$/.test(n)).sort();
|
||||
for (const card of cards) {
|
||||
const dev = join(SYS_DRM, card, 'device');
|
||||
const busy = await readNumber(join(dev, 'gpu_busy_percent'));
|
||||
if (busy === undefined) continue;
|
||||
const reading: GpuReading = { percent: clampPercent(busy) };
|
||||
const used = await readNumber(join(dev, 'mem_info_vram_used'));
|
||||
const total = await readNumber(join(dev, 'mem_info_vram_total'));
|
||||
if (used !== undefined && total !== undefined && total > 0) {
|
||||
reading.memUsedMB = Math.round(used / MB);
|
||||
reading.memTotalMB = Math.round(total / MB);
|
||||
reading.memPercent = clampPercent((used / total) * 100);
|
||||
}
|
||||
const sclk = await readText(join(dev, 'pp_dpm_sclk'));
|
||||
const mhz = sclk ? parseDpmClock(sclk) : undefined;
|
||||
if (mhz) reading.mhz = mhz;
|
||||
return reading;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** NVIDIA, then AMD; the Windows counters (WSL only) are the slower last resort and only for load and memory. */
|
||||
async function readGpu(windows: WindowsCounters | undefined): Promise<GpuReading | undefined> {
|
||||
const out = await run('nvidia-smi', [
|
||||
'--query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr',
|
||||
'--format=csv,noheader,nounits',
|
||||
]);
|
||||
const native = (out && parseNvidiaSmi(out)) || (await readAmdGpu());
|
||||
if (native || !windows?.gpu) return native;
|
||||
const { percent, memUsedMB, dedicated } = windows.gpu;
|
||||
const reading: GpuReading = { percent, memUsedMB };
|
||||
// An integrated GPU draws its memory from system RAM, and Windows lets it use up to half of it.
|
||||
const ram = dedicated ? undefined : await readWindowsRamMB();
|
||||
if (ram) {
|
||||
reading.memTotalMB = Math.round(ram / 2);
|
||||
reading.memPercent = clampPercent((memUsedMB / reading.memTotalMB) * 100);
|
||||
}
|
||||
return reading;
|
||||
}
|
||||
|
||||
async function readCpuMhz(windows: WindowsCounters | undefined): Promise<number | undefined> {
|
||||
// Under WSL the Linux side only has a fixed figure, so until Windows answers there is no reading.
|
||||
if (windows) return windows.cpuMhz;
|
||||
const cores = (await readdir(SYS_CPU).catch(() => [] as string[])).filter((n) => /^cpu\d+$/.test(n));
|
||||
const khz = await Promise.all(cores.map((c) => readNumber(join(SYS_CPU, c, 'cpufreq', 'scaling_cur_freq'))));
|
||||
const fromSysfs = meanKhzAsMhz(khz.filter((v): v is number => v !== undefined));
|
||||
if (fromSysfs) return fromSysfs;
|
||||
const info = await readText('/proc/cpuinfo');
|
||||
return info ? parseCpuInfoMhz(info) : undefined;
|
||||
}
|
||||
|
||||
let cache: { at: number; key: string; value: HostSensors } | undefined;
|
||||
let inFlight: { key: string; promise: Promise<HostSensors> } | undefined;
|
||||
|
||||
/** Read only what was asked for. Missing sensors are simply absent from the result. */
|
||||
export async function readHostSensors(request: SensorRequest): Promise<HostSensors> {
|
||||
const key = `${request.gpu ? 'g' : ''}${request.cpuMhz ? 'c' : ''}`;
|
||||
if (!key) return {};
|
||||
if (cache && cache.key === key && Date.now() - cache.at < CACHE_MS) return cache.value;
|
||||
if (inFlight?.key === key) return inFlight.promise;
|
||||
const promise = (async () => {
|
||||
const windows = inWsl() ? await readWindowsCounters(request) : undefined;
|
||||
const [gpu, cpuMhz] = await Promise.all([
|
||||
request.gpu ? readGpu(windows) : undefined,
|
||||
request.cpuMhz ? readCpuMhz(windows) : undefined,
|
||||
]);
|
||||
const value: HostSensors = {};
|
||||
if (gpu) value.gpu = gpu;
|
||||
if (cpuMhz) value.cpuMhz = cpuMhz;
|
||||
cache = { at: Date.now(), key, value };
|
||||
return value;
|
||||
})().finally(() => {
|
||||
inFlight = undefined;
|
||||
});
|
||||
inFlight = { key, promise };
|
||||
return promise;
|
||||
}
|
||||
@@ -2459,8 +2459,8 @@ class CodemanApp {
|
||||
this.renderSessionTabs();
|
||||
this.renderRalphStatePanel(); // Update ralph panel after session deleted
|
||||
this.renderProjectInsightsPanel(); // Update project insights panel after session deleted
|
||||
// Stop stats polling when no sessions remain
|
||||
if (this.sessions.size === 0) this.stopSystemStatsPolling();
|
||||
// Stats polling keeps going with no session left: the header shows CPU, MEM and the opt-in readings
|
||||
// regardless, and would sit at "--" otherwise.
|
||||
}
|
||||
|
||||
// SSE wrappers — skip terminal events while WebSocket owns active terminal I/O.
|
||||
@@ -5025,12 +5025,9 @@ class CodemanApp {
|
||||
// survive reloads and SSE reconnects (methods in approvals-ui.js).
|
||||
this.seedApprovals?.();
|
||||
|
||||
// Start/stop system stats polling based on session count
|
||||
if (this.sessions.size > 0) {
|
||||
this.startSystemStatsPolling();
|
||||
} else {
|
||||
this.stopSystemStatsPolling();
|
||||
}
|
||||
// Poll the header stats whether or not a session is open (fetchSystemStats skips the call while the
|
||||
// stats pill is hidden); starting restarts the interval, so only start it once.
|
||||
if (!this.systemStatsInterval) this.startSystemStatsPolling();
|
||||
|
||||
// CRITICAL: Clean up all floating windows before loading new subagents
|
||||
// This prevents memory leaks from ResizeObservers, EventSources, and DOM elements
|
||||
|
||||
@@ -449,6 +449,8 @@
|
||||
'Header Displays': '顶部栏显示',
|
||||
'Font Controls': '字体控制',
|
||||
'System Stats': '系统状态',
|
||||
'GPU Stats': 'GPU 状态',
|
||||
'CPU MHz': 'CPU 频率',
|
||||
'Lifecycle Log': '生命周期日志',
|
||||
'Response Viewer': '回复查看器',
|
||||
'Attachments Button': '附件按钮',
|
||||
|
||||
@@ -178,6 +178,30 @@
|
||||
</div>
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
<div class="stat-item" id="statGpuItem" hidden>
|
||||
<span class="stat-ring" id="statGpuRing" aria-hidden="true"></span>
|
||||
<span class="stat-label">GPU</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-cpu" id="statGpuBar"></div>
|
||||
</div>
|
||||
<span class="stat-value" id="statGpu">--%</span>
|
||||
</div>
|
||||
<div class="stat-item" id="statVramItem" hidden>
|
||||
<span class="stat-ring" id="statVramRing" aria-hidden="true"></span>
|
||||
<span class="stat-label">VRAM</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-cpu" id="statVramBar"></div>
|
||||
</div>
|
||||
<span class="stat-value" id="statVram">--%</span>
|
||||
</div>
|
||||
<div class="stat-item stat-mhz" id="statGpuMhzItem" hidden>
|
||||
<span class="stat-label">GPU</span>
|
||||
<span class="stat-value" id="statGpuMhz">-- MHz</span>
|
||||
</div>
|
||||
<div class="stat-item stat-mhz" id="statCpuMhzItem" hidden>
|
||||
<span class="stat-label">CPU</span>
|
||||
<span class="stat-value" id="statCpuMhz">-- MHz</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
@@ -2097,6 +2121,8 @@
|
||||
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw split tiles">
|
||||
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="CPU 12%"><input type="checkbox" id="appSettingsShowSystemStats" checked><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-7 4 14L16 12h5"/></svg><span>System Stats</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="GPU 8%"><input type="checkbox" id="appSettingsShowGpuStats"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="7" width="18" height="10" rx="2"/><path d="M7 21v-4M12 21v-4M17 21v-4M7 7V3M17 7V3"/></svg><span>GPU Stats</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="3.4 GHz"><input type="checkbox" id="appSettingsShowCpuMhz"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>CPU MHz</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="3"><input type="checkbox" id="appSettingsShowRedrawButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg><span>Redraw Terminal</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="4"><input type="checkbox" id="appSettingsShowResponseViewer"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span>Response Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="5"><input type="checkbox" id="appSettingsShowAwayDigestButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg><span>Away Digest</span></label>
|
||||
|
||||
@@ -6718,7 +6718,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!statsEl || statsEl.style.display === 'none') return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/system/stats');
|
||||
// Opt-in readings are only requested while their header setting is on (default off).
|
||||
const settings = this.loadAppSettingsFromStorage?.() ?? {};
|
||||
const wanted = ['gpu', settings.showGpuStats, 'cpumhz', settings.showCpuMhz];
|
||||
const query = [];
|
||||
for (let i = 0; i < wanted.length; i += 2) if (wanted[i + 1] === true) query.push(`${wanted[i]}=1`);
|
||||
const res = await fetch('/api/system/stats' + (query.length ? '?' + query.join('&') : ''));
|
||||
const stats = await res.json();
|
||||
this.updateSystemStatsDisplay(stats.data);
|
||||
} catch (err) {
|
||||
@@ -6752,6 +6757,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._setStatRing('statCpuRing', stats.cpu);
|
||||
this._setStatRing('statMemRing', stats.memory?.percent);
|
||||
|
||||
this._updateHostSensors(stats);
|
||||
|
||||
if (memEl && memBar) {
|
||||
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
|
||||
memEl.textContent = `${memGB}G`;
|
||||
@@ -6769,6 +6776,52 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The opt-in readings after CPU and MEM: GPU load, GPU memory and GPU clock (the GPU Stats setting) and the CPU clock. An item
|
||||
* shows only while its setting is on AND the host reported it, so a machine without a readable GPU just
|
||||
* has nothing extra in the header.
|
||||
*/
|
||||
_updateHostSensors(stats) {
|
||||
const settings = this.loadAppSettingsFromStorage?.() ?? {};
|
||||
const show = (id, visible) => {
|
||||
const el = this.$(id);
|
||||
if (el) el.hidden = !visible;
|
||||
};
|
||||
const fill = (valueId, ringId, barId, percent, text) => {
|
||||
const value = this.$(valueId);
|
||||
if (value) {
|
||||
value.textContent = text;
|
||||
value.classList.toggle('high', percent > 80);
|
||||
}
|
||||
this._setStatRing(ringId, percent);
|
||||
const bar = this.$(barId);
|
||||
if (bar) bar.style.width = `${Math.min(100, percent)}%`;
|
||||
};
|
||||
const gpu = settings.showGpuStats === true ? stats.gpu : undefined;
|
||||
show('statGpuItem', Boolean(gpu));
|
||||
if (gpu) fill('statGpu', 'statGpuRing', 'statGpuBar', gpu.percent, `${gpu.percent}%`);
|
||||
// Graphics memory: the amount in use whenever it is known, with a ring only when the total is too (an
|
||||
// integrated GPU under WSL, where Windows reports usage but the share of RAM is a guess, may have none).
|
||||
show('statVramItem', Boolean(gpu && gpu.memUsedMB !== undefined));
|
||||
if (gpu && gpu.memUsedMB !== undefined) {
|
||||
const used = `${(gpu.memUsedMB / 1024).toFixed(1)}G`;
|
||||
const ring = this.$('statVramRing');
|
||||
if (gpu.memPercent !== undefined) {
|
||||
fill('statVram', 'statVramRing', 'statVramBar', gpu.memPercent, used);
|
||||
if (ring) ring.style.display = '';
|
||||
} else {
|
||||
this.$('statVram').textContent = used;
|
||||
if (ring) ring.style.display = 'none';
|
||||
}
|
||||
}
|
||||
// The GPU clock is a third GPU Stats item (the host may not report one: Windows has none for Intel).
|
||||
show('statGpuMhzItem', Boolean(gpu && gpu.mhz !== undefined));
|
||||
if (gpu && gpu.mhz !== undefined) this.$('statGpuMhz').textContent = `${gpu.mhz} MHz`;
|
||||
const cpuMhz = settings.showCpuMhz === true && stats.cpuMhz !== undefined ? stats.cpuMhz : undefined;
|
||||
show('statCpuMhzItem', cpuMhz !== undefined);
|
||||
if (cpuMhz !== undefined) this.$('statCpuMhz').textContent = `${cpuMhz} MHz`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fill one stat ring (Compact header style) to `percent`, clamped to 0-100,
|
||||
* and flag it `high` above 80%, the same threshold at which the value next
|
||||
|
||||
@@ -514,6 +514,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
// Opt-in host readings after CPU and MEM: all default OFF.
|
||||
document.getElementById('appSettingsShowGpuStats').checked = settings.showGpuStats ?? defaults.showGpuStats ?? false;
|
||||
document.getElementById('appSettingsShowCpuMhz').checked = settings.showCpuMhz ?? defaults.showCpuMhz ?? false;
|
||||
document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings);
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
@@ -2904,6 +2907,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
showGpuStats: document.getElementById('appSettingsShowGpuStats').checked,
|
||||
showCpuMhz: document.getElementById('appSettingsShowCpuMhz').checked,
|
||||
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
@@ -4113,6 +4118,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility - hide everything on mobile
|
||||
showFontControls: false,
|
||||
showSystemStats: false,
|
||||
showGpuStats: false,
|
||||
showCpuMhz: false,
|
||||
showTokenCount: false,
|
||||
showCost: false,
|
||||
// Panel visibility - hide panels on mobile (not enough space)
|
||||
@@ -4880,7 +4887,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
*/
|
||||
_displaySettingKeySet() {
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showFontControls', 'showSystemStats', 'showGpuStats', 'showCpuMhz', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
|
||||
@@ -2251,6 +2251,13 @@ html[data-tab-orientation='vertical'].tab-rail-compact
|
||||
border-left: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
/* The opt-in host readings (GPU, VRAM, MHz) start hidden and are shown by panels-ui.js once the host
|
||||
reports them; a plain `display` on .stat-item would override the attribute, and the Tiles style sets its own
|
||||
(a grid) at a higher specificity, so the selector carries the style attribute to outrank it. */
|
||||
html[data-header-stats] .header-system-stats .stat-item[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Connection Indicator */
|
||||
.connection-indicator {
|
||||
display: flex;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { dataPath } from '../../config/instance.js';
|
||||
import { readHostSensors } from '../../host-sensors.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
@@ -1007,8 +1008,15 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== System Stats ==========
|
||||
|
||||
app.get('/api/system/stats', async () => {
|
||||
return getSystemStats();
|
||||
// GPU (load, memory, clock) and CPU clock readings are opt-in: the browser adds ?gpu=1 and ?cpumhz=1 only while
|
||||
// the matching header setting is on, so a default install never runs nvidia-smi or reads sysfs.
|
||||
app.get('/api/system/stats', async (req) => {
|
||||
const query = req.query as { gpu?: string; cpumhz?: string };
|
||||
const sensors = await readHostSensors({
|
||||
gpu: query.gpu === '1',
|
||||
cpuMhz: query.cpumhz === '1',
|
||||
});
|
||||
return { ...getSystemStats(), ...sensors };
|
||||
});
|
||||
|
||||
// ========== Settings ==========
|
||||
|
||||
@@ -1619,6 +1619,9 @@ export const SettingsUpdateSchema = z
|
||||
// UI visibility
|
||||
showFontControls: z.boolean().optional(),
|
||||
showSystemStats: z.boolean().optional(),
|
||||
/** Opt-in host readings after CPU and MEM in the header (default off): GPU load/memory/clock (showGpuStats) and the CPU clock (showCpuMhz). */
|
||||
showGpuStats: z.boolean().optional(),
|
||||
showCpuMhz: z.boolean().optional(),
|
||||
/**
|
||||
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
|
||||
* (per-device), desktop only (the cluster is hidden below 768px).
|
||||
|
||||
Reference in new issue
Block a user