feat(header): opt-in GPU and CPU clock readings after CPU and MEM

This commit is contained in:
Devvyn committed 2026-10-23 21:36:10 +08:00
1 parent 002de32a08
commit e1d6471808
16 files changed
+947 -12

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
/**
* @fileoverview The opt-in header readings (GPU, VRAM, GPU MHz, CPU MHz): all off by default, requested from
* the server only while their setting is on, and shown only when the host reported them.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
const read = (name: string) => readFileSync(join(process.cwd(), 'src/web/public', name), 'utf-8');
type El = {
hidden: boolean;
textContent: string;
style: Record<string, string>;
classList: ReturnType<typeof classList>;
};
const classList = () => {
const set = new Set<string>();
return {
set,
add: (c: string) => set.add(c),
remove: (c: string) => set.delete(c),
toggle: (c: string, force?: boolean) => {
const on = force ?? !set.has(c);
if (on) set.add(c);
else set.delete(c);
return on;
},
};
};
function harness(settings: Record<string, unknown>) {
const els = new Map<string, El>();
const fetched: string[] = [];
class CodemanApp {}
const context = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: () => ({ style: {} }) },
fetch: async (url: string) => {
fetched.push(url);
return { json: async () => ({ data: { cpu: 1, memory: { usedMB: 1, totalMB: 2, percent: 50 } } }) };
},
localStorage: { getItem: () => null },
setInterval: () => 0,
clearInterval: () => {},
console,
});
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
const app = new (context.CodemanApp as new () => Record<string, (...a: unknown[]) => unknown>)();
Object.assign(app, {
loadAppSettingsFromStorage: () => settings,
$: (id: string) => {
if (!els.has(id))
els.set(id, {
hidden: true,
textContent: '',
style: { setProperty: () => {} } as unknown as Record<string, string>,
classList: classList(),
});
return els.get(id);
},
});
return { app, els, fetched };
}
describe('header host sensors', () => {
it('requests nothing extra from the server when every setting is off (the default)', async () => {
const { app, fetched } = harness({});
await app.fetchSystemStats.call(app);
expect(fetched).toEqual(['/api/system/stats']);
});
it('asks for exactly the readings whose setting is on', async () => {
const { app, fetched } = harness({ showGpuStats: true, showCpuMhz: true });
await app.fetchSystemStats.call(app);
expect(fetched).toEqual(['/api/system/stats?gpu=1&cpumhz=1']);
});
it('shows an item only when its setting is on and the host reported it', () => {
const stats = {
gpu: { percent: 42, memPercent: 50, memUsedMB: 4096, memTotalMB: 8192, mhz: 1800 },
cpuMhz: 3400,
};
const on = harness({ showGpuStats: true, showCpuMhz: true });
on.app._updateHostSensors(stats);
for (const id of ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem']) {
expect(on.els.get(id)?.hidden, id).toBe(false);
}
expect(on.els.get('statGpu')?.textContent).toBe('42%');
expect(on.els.get('statVram')?.textContent).toBe('4.0G');
expect(on.els.get('statCpuMhz')?.textContent).toBe('3400 MHz');
expect(on.els.get('statGpuMhz')?.textContent).toBe('1800 MHz');
const off = harness({});
off.app._updateHostSensors(stats);
for (const id of ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem']) {
expect(off.els.get(id)?.hidden, id).toBe(true);
}
// the GPU clock belongs to GPU Stats; with only CPU MHz on it stays hidden
const cpuOnly = harness({ showCpuMhz: true });
cpuOnly.app._updateHostSensors(stats);
expect(cpuOnly.els.get('statCpuMhzItem')?.hidden).toBe(false);
expect(cpuOnly.els.get('statGpuMhzItem')?.hidden).toBe(true);
const gpuOnly = harness({ showGpuStats: true });
gpuOnly.app._updateHostSensors(stats);
expect(gpuOnly.els.get('statGpuMhzItem')?.hidden).toBe(false);
expect(gpuOnly.els.get('statCpuMhzItem')?.hidden).toBe(true);
const noHardware = harness({ showGpuStats: true, showCpuMhz: true });
noHardware.app._updateHostSensors({});
expect(noHardware.els.get('statGpuItem')?.hidden).toBe(true);
});
it('shows graphics memory in use even when its total is unknown, with no ring', () => {
const { app, els } = harness({ showGpuStats: true });
app._updateHostSensors({ gpu: { percent: 5, memUsedMB: 1536 } });
expect(els.get('statVramItem')?.hidden).toBe(false);
expect(els.get('statVram')?.textContent).toBe('1.5G');
expect(els.get('statVramRing')?.style.display).toBe('none');
app._updateHostSensors({ gpu: { percent: 5, memUsedMB: 1536, memTotalMB: 8192, memPercent: 19 } });
expect(els.get('statVramRing')?.style.display).toBe('');
});
it('keeps polling the header stats with no session open, so CPU, MEM and the readings are never stuck at --', () => {
const appJs = read('app.js');
expect(appJs).toContain('if (!this.systemStatsInterval) this.startSystemStatsPolling();');
expect(appJs).not.toMatch(/sessions\.size === 0\) this\.stopSystemStatsPolling\(\)/);
expect(appJs).not.toMatch(/\} else \{\s*this\.stopSystemStatsPolling\(\);/);
});
it('is opt-in in the settings page: unchecked chips, off in every default, accepted by the schema', () => {
const html = read('index.html');
for (const id of ['appSettingsShowGpuStats', 'appSettingsShowCpuMhz']) {
expect(html).toMatch(new RegExp(`<input type="checkbox" id="${id}">`));
}
const settingsUi = read('settings-ui.js');
expect(settingsUi).toContain('showGpuStats ?? defaults.showGpuStats ?? false');
expect(settingsUi).toContain('showCpuMhz ?? defaults.showCpuMhz ?? false');
expect(readFileSync(join(process.cwd(), 'src/web/schemas.ts'), 'utf-8')).toMatch(/showCpuMhz: z\.boolean\(\)/);
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* Real Chromium, real header markup and styles.css: the opt-in host readings stay out of the header until they
* are switched on and reported, under every header-stats style, and a reported one is drawn with its value.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const dir = resolve(import.meta.dirname, '../src/web/public');
const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8');
const header = /<header class="header">[\s\S]*?<\/header>/.exec(readFileSync(resolve(dir, 'index.html'), 'utf8'))![0];
const ITEMS = ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem'];
describe('opt-in host readings in the header (real Chromium)', () => {
let browser: Browser;
let p: Page;
beforeAll(async () => {
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
});
for (const style of ['compact', 'tiles', 'classic']) {
it(`draws none of them until reported, and the reported one with its value, in the ${style} style`, async () => {
p = await browser.newPage({ viewport: { width: 1500, height: 400 } });
await p.setContent(
`<!doctype html><html data-header-stats="${style}" data-tab-orientation="horizontal" data-session-list="header"><head><style>${styles}</style></head><body>${header}</body></html>`
);
const shown = () =>
p.evaluate(
(ids) => ids.filter((id) => getComputedStyle(document.getElementById(id)!).display !== 'none'),
ITEMS
);
expect(await shown()).toEqual([]);
await p.evaluate(() => {
document.getElementById('statGpuItem')!.hidden = false;
document.getElementById('statGpu')!.textContent = '42%';
});
expect(await shown()).toEqual(['statGpuItem']);
const box = await p.locator('#statGpuItem').boundingBox();
expect(box!.width).toBeGreaterThan(20);
expect(await p.locator('#statGpuItem').innerText()).toContain('42%');
await p.close();
});
}
});
+176
View File
@@ -0,0 +1,176 @@
/**
* @fileoverview The pure parsers behind the opt-in header readings (GPU, CPU clock), the route's
* opt-in query handling, and the header display's defaults. No hardware needed.
*/
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';
import {
createCounterStream,
meanKhzAsMhz,
parseCpuInfoMhz,
parseDpmClock,
parseNvidiaSmi,
parseTypeperf,
readHostSensors,
} from '../src/host-sensors.js';
describe('parseNvidiaSmi', () => {
it('reads load, memory and clock from the first GPU', () => {
expect(parseNvidiaSmi('37, 2048, 8192, 1815\n12, 1, 8192, 210\n')).toEqual({
percent: 37,
memUsedMB: 2048,
memTotalMB: 8192,
memPercent: 25,
mhz: 1815,
});
});
it('keeps the load when the driver reports no memory or clock, and refuses non-numbers', () => {
expect(parseNvidiaSmi('5, [N/A], [N/A], [N/A]')).toEqual({ percent: 5 });
expect(parseNvidiaSmi('[N/A], 1, 2, 3')).toBeUndefined();
expect(parseNvidiaSmi('')).toBeUndefined();
});
it('clamps a load above 100', () => {
expect(parseNvidiaSmi('140, 1, 2, 3')?.percent).toBe(100);
});
});
describe('clock parsers', () => {
it('takes the active line of an amdgpu pp_dpm_sclk table', () => {
expect(parseDpmClock('0: 500Mhz\n1: 1200Mhz *\n2: 2100Mhz\n')).toBe(1200);
expect(parseDpmClock('0: 500Mhz\n')).toBeUndefined();
});
it('averages per-core kHz readings into MHz and ignores gaps', () => {
expect(meanKhzAsMhz([3000000, 4000000, Number.NaN, 0])).toBe(3500);
expect(meanKhzAsMhz([])).toBeUndefined();
});
it('averages the cpu MHz lines of /proc/cpuinfo', () => {
expect(parseCpuInfoMhz('processor : 0\ncpu MHz\t\t: 3000.500\nprocessor : 1\ncpu MHz\t\t: 4000.500\n')).toBe(3501);
expect(parseCpuInfoMhz('model name : x\n')).toBeUndefined();
});
});
describe('parseTypeperf', () => {
const header = [
'(PDH-CSV 4.0)',
'\\\\DAEDALUS\\GPU Engine(pid_10728_luid_0x00000000_0x000139EC_phys_0_eng_0_engtype_3D)\\Utilization Percentage',
'\\\\DAEDALUS\\GPU Engine(pid_2016_luid_0x00000000_0x000139EC_phys_0_eng_0_engtype_3D)\\Utilization Percentage',
'\\\\DAEDALUS\\GPU Engine(pid_2016_luid_0x00000000_0x000139EC_phys_0_eng_1_engtype_VideoDecode)\\Utilization Percentage',
'\\\\DAEDALUS\\GPU Engine(pid_9_luid_0x00000000_0x00013DF2_phys_0_eng_0_engtype_3D)\\Utilization Percentage',
'\\\\DAEDALUS\\GPU Adapter Memory(luid_0x00000000_0x000139EC_phys_0)\\Shared Usage',
'\\\\DAEDALUS\\GPU Adapter Memory(luid_0x00000000_0x000139EC_phys_0)\\Dedicated Usage',
'\\\\DAEDALUS\\GPU Adapter Memory(luid_0x00000000_0x00013DF2_phys_0)\\Shared Usage',
'\\\\DAEDALUS\\Processor Information(_Total)\\Processor Frequency',
];
const sample = (values: string[]) =>
`\r\n"${header.join('","')}"\r\n"${values.join('","')}"\r\nExiting, please wait... The command completed successfully.\r\n`;
it('takes the busiest engine type of the adapter holding the most memory, and the clock', () => {
const out = parseTypeperf(
sample(['10/23/2026 21:00:00.123', '12.5', '30', '3.5', '99', '1015808000', '0', '8192', '1640'])
);
// 3D engines sum to 42.5 (the video decode engine is lower), and the software adapter's 99 is ignored
expect(out.gpu).toEqual({ percent: 43, memUsedMB: 969, dedicated: false });
expect(out.cpuMhz).toBe(1640);
});
it('caps the load at 100 and marks an adapter with dedicated memory', () => {
const out = parseTypeperf(sample(['t', '80', '70', '0', '0', '0', '2147483648', '0', '1000']));
expect(out.gpu).toEqual({ percent: 100, memUsedMB: 2048, dedicated: true });
});
it('skips counters with no value and answers nothing for unusable output', () => {
const out = parseTypeperf(sample(['t', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']));
expect(out.cpuMhz).toBeUndefined();
expect(parseTypeperf('')).toEqual({});
expect(parseTypeperf('The specified counter could not be found.')).toEqual({});
});
});
describe('readHostSensors', () => {
it('reads nothing at all unless a reading is asked for', async () => {
expect(await readHostSensors({})).toEqual({});
expect(await readHostSensors({ gpu: false, cpuMhz: false })).toEqual({});
});
});
describe('createCounterStream', () => {
const ROW = (a: string) => `"t","${a}"\r\n`;
const HEADER = '"(PDH-CSV 4.0)","\\\\H\\Processor Information(_Total)\\Processor Frequency"\r\n';
function fake() {
const children: Array<{ stdout: PassThrough; killed: boolean; emitter: EventEmitter }> = [];
let clock = 1000;
const stream = createCounterStream({
start: () => {
const stdout = new PassThrough();
const emitter = new EventEmitter();
const child = { stdout, killed: false, emitter };
children.push(child);
return {
stdout,
kill: () => {
child.killed = true;
return true;
},
once: (event: string, cb: () => void) => emitter.once(event, cb),
} as never;
},
now: () => clock,
firstSampleMs: 50,
recycleMs: 30000,
idleMs: 30000,
backoffMs: 60000,
});
return { children, stream, advance: (ms: number) => (clock += ms) };
}
const counters = ['\\Processor Information(_Total)\\Processor Frequency'];
it('waits for the first sample, then hands out the newest without starting another sampler', async () => {
const { children, stream } = fake();
const first = stream.read(counters);
children[0].stdout.write('\r\n' + HEADER + ROW('1700'));
expect(await first).toEqual({ cpuMhz: 1700 });
children[0].stdout.write(ROW('1900'));
await new Promise((r) => setImmediate(r));
expect(await stream.read(counters)).toEqual({ cpuMhz: 1900 });
expect(children).toHaveLength(1);
});
it('replaces an old sampler so new GPU processes are counted, and stops an idle one', async () => {
const { children, stream, advance } = fake();
const first = stream.read(counters);
children[0].stdout.write(HEADER + ROW('1700'));
await first;
advance(31000);
const again = stream.read(counters);
expect(children).toHaveLength(2);
expect(children[0].killed).toBe(true);
children[1].stdout.write(HEADER + ROW('1800'));
expect(await again).toEqual({ cpuMhz: 1800 });
advance(31000);
stream.sweep();
expect(children[1].killed).toBe(true);
});
it('gives up quietly, and waits before trying again, when the sampler ends without a sample', async () => {
const { children, stream, advance } = fake();
const first = stream.read(counters);
children[0].emitter.emit('exit');
expect(await first).toBeUndefined();
expect(await stream.read(counters)).toBeUndefined();
expect(children).toHaveLength(1);
advance(61000);
void stream.read(counters);
expect(children).toHaveLength(2);
});
it('answers nothing, within the wait, when no sample ever arrives', async () => {
const { stream } = fake();
expect(await stream.read(counters)).toBeUndefined();
});
});
+17
View File
@@ -12,6 +12,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
const hostSensors = vi.hoisted(() => ({ readHostSensors: vi.fn(async (_request: unknown) => ({})) }));
vi.mock('../../src/host-sensors.js', () => hostSensors);
// ── Mocks ──────────────────────────────────────────────────────────
vi.mock('node:fs/promises', () => ({
@@ -326,6 +329,20 @@ describe('system-routes', () => {
expect(body).toHaveProperty('cpu');
expect(body).toHaveProperty('memory');
});
it('adds no GPU or clock readings unless they are asked for', async () => {
const body = JSON.parse((await harness.app.inject({ method: 'GET', url: '/api/system/stats' })).body);
expect(body).not.toHaveProperty('gpu');
expect(body).not.toHaveProperty('cpuMhz');
});
it('passes only the requested readings on to the sensor reader', async () => {
hostSensors.readHostSensors.mockClear();
hostSensors.readHostSensors.mockResolvedValueOnce({ cpuMhz: 3200 });
const res = await harness.app.inject({ method: 'GET', url: '/api/system/stats?cpumhz=1&gpu=0' });
expect(hostSensors.readHostSensors).toHaveBeenCalledWith({ gpu: false, cpuMhz: true });
expect(JSON.parse(res.body).cpuMhz).toBe(3200);
});
});
// ========== POST /api/cleanup-state ==========