@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/client.js +754 -0
- package/cordis.patch.yml +3 -0
- package/host/actions-darwin.js +1052 -0
- package/host/actions-win32.js +1299 -0
- package/host/actions.js +45 -0
- package/host/benchmark.js +413 -0
- package/host/cleanup-darwin.js +715 -0
- package/host/cleanup-win32.js +760 -0
- package/host/cleanup.js +62 -0
- package/host/collector-darwin.js +2995 -0
- package/host/collector-win32.js +2057 -0
- package/host/collector.js +71 -0
- package/host/proc.js +232 -0
- package/host/tool.js +254 -0
- package/host/transport.js +294 -0
- package/host/win/hardware.ps1 +501 -0
- package/host/win/metrics.ps1 +535 -0
- package/host/win/network.ps1 +322 -0
- package/host/win/processes.ps1 +169 -0
- package/host/win/security.ps1 +197 -0
- package/host/win/software.ps1 +119 -0
- package/host/win/system.ps1 +279 -0
- package/host/win.js +187 -0
- package/icon.svg +18 -0
- package/index.js +65 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
|
@@ -0,0 +1,2057 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X host collectors — Windows implementation.
|
|
3
|
+
*
|
|
4
|
+
* The public data contract is `docs/build-contract.md`: `Metrics`, the six
|
|
5
|
+
* section shapes and `Inventory` are identical to the macOS collector, with
|
|
6
|
+
* `null` where Windows genuinely does not expose a value. The Windows-specific
|
|
7
|
+
* sources are PowerShell/CIM (see `plugin/host/win/*.ps1`). Every call goes
|
|
8
|
+
* through the frozen `./win.js` bridge (encoded command, argv only, bounded
|
|
9
|
+
* buffer, timeout); nothing here invokes a shell or interpolates data into
|
|
10
|
+
* script text.
|
|
11
|
+
*
|
|
12
|
+
* Rules honoured here (same as `collector-darwin.js`):
|
|
13
|
+
* - every data source failure yields null/empty values plus a non-fatal entry
|
|
14
|
+
* in the section's `errors[]`; collectors never throw (unknown section
|
|
15
|
+
* names are the one deliberate exception);
|
|
16
|
+
* - everything returned is JSON-safe: no `undefined`, no NaN/Infinity, no
|
|
17
|
+
* circular references;
|
|
18
|
+
* - slow sections are cached with TTLs, de-duplicated while in flight and can
|
|
19
|
+
* be warmed in the background;
|
|
20
|
+
* - `collectMetrics()` is a fast poll path built from a single PowerShell
|
|
21
|
+
* invocation plus `os.cpus()` deltas for CPU percentages;
|
|
22
|
+
* - CPU%, network and disk byte rates are deltas against the previous sample,
|
|
23
|
+
* exactly like the macOS implementation.
|
|
24
|
+
*
|
|
25
|
+
* @module @cfour-hi/dsh-plugin-devicex/host/collector-win32
|
|
26
|
+
*/
|
|
27
|
+
import * as os from 'node:os';
|
|
28
|
+
|
|
29
|
+
import { actionCatalog, actionHistory } from './actions-win32.js';
|
|
30
|
+
import { sleep } from './proc.js';
|
|
31
|
+
import { readScript, runPSJson } from './win.js';
|
|
32
|
+
|
|
33
|
+
/** Host platform for this implementation. */
|
|
34
|
+
export const PLATFORM = 'win32';
|
|
35
|
+
|
|
36
|
+
/** Frozen section identifiers, in UI order. */
|
|
37
|
+
export const SECTIONS = ['hardware', 'system', 'software', 'network', 'security', 'processes'];
|
|
38
|
+
|
|
39
|
+
/** Per-section cache TTLs (ms) — recommended values from docs/windows-contract.md. */
|
|
40
|
+
const SECTION_TTL = {
|
|
41
|
+
hardware: 300_000,
|
|
42
|
+
system: 120_000,
|
|
43
|
+
software: 600_000,
|
|
44
|
+
network: 15_000,
|
|
45
|
+
security: 60_000,
|
|
46
|
+
processes: 30_000,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Wall-clock timeout per bundled script (PowerShell startup + CIM query). */
|
|
50
|
+
const SCRIPT_TIMEOUT = {
|
|
51
|
+
'metrics.ps1': 15_000,
|
|
52
|
+
'hardware.ps1': 30_000,
|
|
53
|
+
'system.ps1': 25_000,
|
|
54
|
+
'software.ps1': 45_000,
|
|
55
|
+
'network.ps1': 30_000,
|
|
56
|
+
'security.ps1': 30_000,
|
|
57
|
+
'processes.ps1': 25_000,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fast-metrics cache TTL. The macOS collector stays inside the contract's
|
|
62
|
+
* <250 ms poll budget, but one Windows sample needs a PowerShell spawn plus
|
|
63
|
+
* ~20 CIM queries, netsh and per-process owner lookups — routinely far above
|
|
64
|
+
* 250 ms. The UI still polls every 2500 ms, so a 3000 ms TTL serves roughly
|
|
65
|
+
* every other poll from cache and halves the PowerShell load. This is a
|
|
66
|
+
* deliberate, documented deviation from the darwin budget; a <=3 s stale
|
|
67
|
+
* metrics snapshot is acceptable for the panel, and network/disk rates stay
|
|
68
|
+
* meaningful because they are deltas against the previous *sampled* timestamp.
|
|
69
|
+
*/
|
|
70
|
+
const METRICS_TTL = 3_000;
|
|
71
|
+
|
|
72
|
+
/* -------------------------------------------------------------------------- */
|
|
73
|
+
/* Small utilities */
|
|
74
|
+
/* -------------------------------------------------------------------------- */
|
|
75
|
+
|
|
76
|
+
/** @param {unknown} value @returns {value is Record<string, any>} */
|
|
77
|
+
function isObject(value) {
|
|
78
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Coerce to a finite number, else null.
|
|
83
|
+
* @param {unknown} value
|
|
84
|
+
* @returns {number|null}
|
|
85
|
+
*/
|
|
86
|
+
function num(value) {
|
|
87
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
88
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
89
|
+
const parsed = Number(value);
|
|
90
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Coerce to a trimmed, non-empty string, else null.
|
|
97
|
+
* @param {unknown} value
|
|
98
|
+
* @returns {string|null}
|
|
99
|
+
*/
|
|
100
|
+
function str(value) {
|
|
101
|
+
if (typeof value === 'string') {
|
|
102
|
+
const trimmed = value.trim();
|
|
103
|
+
return trimmed === '' ? null : trimmed;
|
|
104
|
+
}
|
|
105
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Tolerant array normalizer. Windows PowerShell 5.1 `ConvertTo-Json` can
|
|
111
|
+
* collapse a single-element array into a scalar, so arrays emitted by the
|
|
112
|
+
* bundled scripts are normalized through this helper.
|
|
113
|
+
* @param {unknown} value
|
|
114
|
+
* @returns {any[]}
|
|
115
|
+
*/
|
|
116
|
+
function asArray(value) {
|
|
117
|
+
if (Array.isArray(value)) return value;
|
|
118
|
+
if (value === null || value === undefined || value === '') return [];
|
|
119
|
+
return [value];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Coerce a number to a fixed number of decimals (null-safe).
|
|
124
|
+
* @param {unknown} value
|
|
125
|
+
* @param {number} [digits]
|
|
126
|
+
* @returns {number|null}
|
|
127
|
+
*/
|
|
128
|
+
function round(value, digits = 1) {
|
|
129
|
+
const parsed = num(value);
|
|
130
|
+
if (parsed === null) return null;
|
|
131
|
+
const factor = 10 ** digits;
|
|
132
|
+
return Math.round(parsed * factor) / factor;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Clamp a number into [min, max]; non-finite becomes min.
|
|
137
|
+
* @param {unknown} value
|
|
138
|
+
* @param {number} min
|
|
139
|
+
* @param {number} max
|
|
140
|
+
* @returns {number}
|
|
141
|
+
*/
|
|
142
|
+
function clamp(value, min, max) {
|
|
143
|
+
const parsed = num(value);
|
|
144
|
+
if (parsed === null) return min;
|
|
145
|
+
return Math.min(max, Math.max(min, parsed));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* First numeric value among the given keys of an object.
|
|
150
|
+
* @param {Record<string, unknown>} object
|
|
151
|
+
* @param {string[]} keys
|
|
152
|
+
* @returns {number|null}
|
|
153
|
+
*/
|
|
154
|
+
function firstNum(object, keys) {
|
|
155
|
+
if (!isObject(object)) return null;
|
|
156
|
+
for (const key of keys) {
|
|
157
|
+
const parsed = num(object[key]);
|
|
158
|
+
if (parsed !== null) return parsed;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* First string value among the given keys of an object.
|
|
165
|
+
* @param {Record<string, unknown>} object
|
|
166
|
+
* @param {string[]} keys
|
|
167
|
+
* @returns {string|null}
|
|
168
|
+
*/
|
|
169
|
+
function firstStr(object, keys) {
|
|
170
|
+
if (!isObject(object)) return null;
|
|
171
|
+
for (const key of keys) {
|
|
172
|
+
const parsed = str(object[key]);
|
|
173
|
+
if (parsed !== null) return parsed;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Tolerant boolean: true/false booleans, "true"/"false" strings, 1/0 numbers.
|
|
180
|
+
* Returns null when the value is not a recognizable boolean.
|
|
181
|
+
* @param {unknown} value
|
|
182
|
+
* @returns {boolean|null}
|
|
183
|
+
*/
|
|
184
|
+
function boolOrNull(value) {
|
|
185
|
+
if (typeof value === 'boolean') return value;
|
|
186
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
187
|
+
if (value === 1) return true;
|
|
188
|
+
if (value === 0) return false;
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
if (typeof value === 'string') {
|
|
192
|
+
const text = value.trim().toLowerCase();
|
|
193
|
+
if (text === 'true' || text === '1' || text === 'yes' || text === 'on') return true;
|
|
194
|
+
if (text === 'false' || text === '0' || text === 'no' || text === 'off') return false;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Shorten an error message for an `errors[]` entry.
|
|
201
|
+
* @param {unknown} value
|
|
202
|
+
* @param {number} [max]
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
function shortErr(value, max = 220) {
|
|
206
|
+
const text = String(value ?? 'unknown error').replace(/\s+/g, ' ').trim();
|
|
207
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Deep JSON-safe conversion: `undefined`, non-finite numbers and functions
|
|
212
|
+
* become `null`; values are cloned so callers cannot mutate a cache entry.
|
|
213
|
+
* Cycles (which should not exist here) collapse to `null`.
|
|
214
|
+
* @param {unknown} value
|
|
215
|
+
* @param {Set<object>} [seen]
|
|
216
|
+
* @returns {any}
|
|
217
|
+
*/
|
|
218
|
+
function jsonSafe(value, seen = new Set()) {
|
|
219
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
220
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
221
|
+
if (typeof value === 'bigint') return Number(value);
|
|
222
|
+
if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') return null;
|
|
223
|
+
if (typeof value !== 'object') return null;
|
|
224
|
+
if (seen.has(value)) return null;
|
|
225
|
+
seen.add(value);
|
|
226
|
+
if (Array.isArray(value)) {
|
|
227
|
+
const array = value.map((item) => jsonSafe(item, seen));
|
|
228
|
+
seen.delete(value);
|
|
229
|
+
return array;
|
|
230
|
+
}
|
|
231
|
+
if (value instanceof Date) {
|
|
232
|
+
const time = value.getTime();
|
|
233
|
+
seen.delete(value);
|
|
234
|
+
return Number.isFinite(time) ? time : null;
|
|
235
|
+
}
|
|
236
|
+
/** @type {Record<string, unknown>} */
|
|
237
|
+
const object = {};
|
|
238
|
+
for (const [key, item] of Object.entries(value)) {
|
|
239
|
+
object[key] = jsonSafe(item, seen);
|
|
240
|
+
}
|
|
241
|
+
seen.delete(value);
|
|
242
|
+
return object;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** os.hostname() that cannot throw. @returns {string|null} */
|
|
246
|
+
function safeHostname() {
|
|
247
|
+
try {
|
|
248
|
+
return os.hostname();
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** os.userInfo().username that cannot throw. @returns {string|null} */
|
|
255
|
+
function safeUsername() {
|
|
256
|
+
try {
|
|
257
|
+
return os.userInfo().username;
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Number of logical CPUs that cannot throw. @returns {number} */
|
|
264
|
+
function safeCpuCount() {
|
|
265
|
+
try {
|
|
266
|
+
return os.cpus().length;
|
|
267
|
+
} catch {
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Sum a numeric field across objects, ignoring nulls. */
|
|
273
|
+
function sumField(items, field) {
|
|
274
|
+
let total = null;
|
|
275
|
+
for (const item of items) {
|
|
276
|
+
const value = num(item?.[field]);
|
|
277
|
+
if (value === null) continue;
|
|
278
|
+
total = (total ?? 0) + value;
|
|
279
|
+
}
|
|
280
|
+
return total;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Normalize the `errors` array of a raw PowerShell document plus an optional
|
|
285
|
+
* transport error. Also produces a populated `errors[]` for empty/failed input.
|
|
286
|
+
* @param {unknown} raw
|
|
287
|
+
* @param {string|null|undefined} transportError
|
|
288
|
+
* @param {string} label
|
|
289
|
+
* @returns {string[]}
|
|
290
|
+
*/
|
|
291
|
+
function collectErrors(raw, transportError, label) {
|
|
292
|
+
const errors = [];
|
|
293
|
+
if (transportError) errors.push(`${label}: ${shortErr(transportError)}`);
|
|
294
|
+
if (!isObject(raw)) {
|
|
295
|
+
errors.push(`${label}: PowerShell returned no data`);
|
|
296
|
+
return errors;
|
|
297
|
+
}
|
|
298
|
+
if (Object.keys(raw).length === 0) errors.push(`${label}: PowerShell returned an empty document`);
|
|
299
|
+
for (const item of asArray(raw.errors)) {
|
|
300
|
+
const text = str(item);
|
|
301
|
+
if (text) errors.push(shortErr(text));
|
|
302
|
+
}
|
|
303
|
+
return errors;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* -------------------------------------------------------------------------- */
|
|
307
|
+
/* PowerShell execution seam (injectable for tests) */
|
|
308
|
+
/* -------------------------------------------------------------------------- */
|
|
309
|
+
|
|
310
|
+
/** Default runner: read a bundled script and run it through the frozen bridge. */
|
|
311
|
+
async function defaultRunner(scriptName, options = {}) {
|
|
312
|
+
return runPSJson(readScript(scriptName), {
|
|
313
|
+
timeout: options.timeout ?? SCRIPT_TIMEOUT[scriptName] ?? 20_000,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** @type {(scriptName: string, options?: {timeout?: number}) => Promise<any>} */
|
|
318
|
+
let scriptRunner = defaultRunner;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Run one bundled collection script. Never throws: a bridge failure becomes
|
|
322
|
+
* `{ok:false, error}`.
|
|
323
|
+
* @param {string} scriptName
|
|
324
|
+
* @returns {Promise<{ok: true, data: any} | {ok: false, error: string}>}
|
|
325
|
+
*/
|
|
326
|
+
async function runScript(scriptName) {
|
|
327
|
+
try {
|
|
328
|
+
const result = await scriptRunner(scriptName, { timeout: SCRIPT_TIMEOUT[scriptName] ?? 20_000 });
|
|
329
|
+
if (result && result.ok === true) return { ok: true, data: result.data };
|
|
330
|
+
const error = result && (result.error || result.stderr) ? result.error || result.stderr : 'PowerShell returned no data';
|
|
331
|
+
return { ok: false, error: shortErr(error) };
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return { ok: false, error: shortErr(error instanceof Error ? error.message : error) };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* -------------------------------------------------------------------------- */
|
|
338
|
+
/* Caching */
|
|
339
|
+
/* -------------------------------------------------------------------------- */
|
|
340
|
+
|
|
341
|
+
/** @type {Map<string, {value: any, at: number}>} */
|
|
342
|
+
const sectionCache = new Map();
|
|
343
|
+
/** @type {Map<string, Promise<any>>} */
|
|
344
|
+
const sectionInflight = new Map();
|
|
345
|
+
|
|
346
|
+
/* -------------------------------------------------------------------------- */
|
|
347
|
+
/* Rate tracking (metrics deltas) */
|
|
348
|
+
/* -------------------------------------------------------------------------- */
|
|
349
|
+
|
|
350
|
+
/** @type {Map<string, {at: number, read: number, write: number}>} */
|
|
351
|
+
const rateState = new Map();
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Turn two cumulative byte counters into per-second rates.
|
|
355
|
+
* @param {string} key
|
|
356
|
+
* @param {{read: number, write: number}|null} current
|
|
357
|
+
* @param {number} at
|
|
358
|
+
* @returns {{readBytesPerSec: number, writeBytesPerSec: number}}
|
|
359
|
+
*/
|
|
360
|
+
function rateOf(key, current, at) {
|
|
361
|
+
if (!current) return { readBytesPerSec: 0, writeBytesPerSec: 0 };
|
|
362
|
+
const previous = rateState.get(key);
|
|
363
|
+
rateState.set(key, { at, read: current.read, write: current.write });
|
|
364
|
+
if (!previous || at <= previous.at) return { readBytesPerSec: 0, writeBytesPerSec: 0 };
|
|
365
|
+
const dt = (at - previous.at) / 1000;
|
|
366
|
+
if (!(dt > 0)) return { readBytesPerSec: 0, writeBytesPerSec: 0 };
|
|
367
|
+
return {
|
|
368
|
+
readBytesPerSec: Math.max(0, Math.round((current.read - previous.read) / dt)),
|
|
369
|
+
writeBytesPerSec: Math.max(0, Math.round((current.write - previous.write) / dt)),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Extract a cumulative counter pair from a raw script document.
|
|
375
|
+
* @param {unknown} raw
|
|
376
|
+
* @returns {{read: number, write: number}|null}
|
|
377
|
+
*/
|
|
378
|
+
function countersOf(raw) {
|
|
379
|
+
if (!isObject(raw)) return null;
|
|
380
|
+
const read = num(raw.read);
|
|
381
|
+
const write = num(raw.write);
|
|
382
|
+
if (read === null && write === null) return null;
|
|
383
|
+
return { read: read ?? 0, write: write ?? 0 };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* -------------------------------------------------------------------------- */
|
|
387
|
+
/* CPU usage (os.cpus deltas, same algorithm as collector-darwin.js) */
|
|
388
|
+
/* -------------------------------------------------------------------------- */
|
|
389
|
+
|
|
390
|
+
/** Previous `os.cpus()` snapshot for delta-based CPU usage. */
|
|
391
|
+
let cpuSnapshot = null;
|
|
392
|
+
/** Last computed CPU usage, used when two polls land within the same tick. */
|
|
393
|
+
/** @type {{usagePercent: number, perCore: number[]}} */
|
|
394
|
+
let lastCpuUsage = { usagePercent: 0, perCore: [] };
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Whole-machine and per-core CPU usage from successive `os.cpus()` snapshots.
|
|
398
|
+
* @returns {Promise<{usagePercent: number, perCore: number[]}>}
|
|
399
|
+
*/
|
|
400
|
+
async function cpuUsage() {
|
|
401
|
+
const takeSnapshot = () =>
|
|
402
|
+
os.cpus().map((cpu) => ({
|
|
403
|
+
idle: cpu.times.idle,
|
|
404
|
+
total: cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq,
|
|
405
|
+
}));
|
|
406
|
+
const compute = (previous, current) => {
|
|
407
|
+
let idleDelta = 0;
|
|
408
|
+
let totalDelta = 0;
|
|
409
|
+
const perCore = current.map((core, index) => {
|
|
410
|
+
const before = previous[index] ?? core;
|
|
411
|
+
const idle = Math.max(0, core.idle - before.idle);
|
|
412
|
+
const total = Math.max(0, core.total - before.total);
|
|
413
|
+
idleDelta += idle;
|
|
414
|
+
totalDelta += total;
|
|
415
|
+
if (total <= 0) return lastCpuUsage.perCore[index] ?? 0;
|
|
416
|
+
return round(clamp(100 * (1 - idle / total), 0, 100), 1);
|
|
417
|
+
});
|
|
418
|
+
const usagePercent =
|
|
419
|
+
totalDelta > 0 ? round(clamp(100 * (1 - idleDelta / totalDelta), 0, 100), 1) ?? 0 : lastCpuUsage.usagePercent;
|
|
420
|
+
lastCpuUsage = { usagePercent, perCore };
|
|
421
|
+
return lastCpuUsage;
|
|
422
|
+
};
|
|
423
|
+
const current = takeSnapshot();
|
|
424
|
+
if (!cpuSnapshot) {
|
|
425
|
+
await sleep(80);
|
|
426
|
+
const sampled = takeSnapshot();
|
|
427
|
+
cpuSnapshot = sampled;
|
|
428
|
+
return compute(current, sampled);
|
|
429
|
+
}
|
|
430
|
+
const previous = cpuSnapshot;
|
|
431
|
+
cpuSnapshot = current;
|
|
432
|
+
return compute(previous, current);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/* -------------------------------------------------------------------------- */
|
|
436
|
+
/* Shared value mappers */
|
|
437
|
+
/* -------------------------------------------------------------------------- */
|
|
438
|
+
|
|
439
|
+
/** Map a Win32_Processor.Architecture code to a readable architecture. */
|
|
440
|
+
function mapProcessorArchitecture(value) {
|
|
441
|
+
switch (num(value)) {
|
|
442
|
+
case 0:
|
|
443
|
+
return 'x86';
|
|
444
|
+
case 5:
|
|
445
|
+
return 'arm';
|
|
446
|
+
case 6:
|
|
447
|
+
return 'ia64';
|
|
448
|
+
case 9:
|
|
449
|
+
return 'x64';
|
|
450
|
+
case 12:
|
|
451
|
+
return 'arm64';
|
|
452
|
+
default:
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Map a SMBIOS memory type code to a readable generation. */
|
|
458
|
+
function mapMemoryType(value) {
|
|
459
|
+
switch (num(value)) {
|
|
460
|
+
case 20:
|
|
461
|
+
return 'DDR';
|
|
462
|
+
case 21:
|
|
463
|
+
return 'DDR2';
|
|
464
|
+
case 22:
|
|
465
|
+
return 'DDR2 FB-DIMM';
|
|
466
|
+
case 24:
|
|
467
|
+
return 'DDR3';
|
|
468
|
+
case 26:
|
|
469
|
+
return 'DDR4';
|
|
470
|
+
case 27:
|
|
471
|
+
return 'LPDDR';
|
|
472
|
+
case 28:
|
|
473
|
+
return 'LPDDR2';
|
|
474
|
+
case 29:
|
|
475
|
+
return 'LPDDR3';
|
|
476
|
+
case 30:
|
|
477
|
+
return 'LPDDR4';
|
|
478
|
+
case 34:
|
|
479
|
+
return 'DDR5';
|
|
480
|
+
case 35:
|
|
481
|
+
return 'LPDDR5';
|
|
482
|
+
default:
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Best-effort GPU vendor from the adapter name. */
|
|
488
|
+
function gpuVendor(name) {
|
|
489
|
+
if (!name) return null;
|
|
490
|
+
if (/nvidia/i.test(name)) return 'NVIDIA';
|
|
491
|
+
if (/amd|radeon/i.test(name)) return 'AMD';
|
|
492
|
+
if (/intel/i.test(name)) return 'Intel';
|
|
493
|
+
if (/apple/i.test(name)) return 'Apple';
|
|
494
|
+
if (/qualcomm|adreno/i.test(name)) return 'Qualcomm';
|
|
495
|
+
if (/microsoft/i.test(name)) return 'Microsoft';
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Windows reports Wi-Fi link quality as a percentage, not dBm. The conventional
|
|
501
|
+
* quality% ≈ 2 × (dBm + 100) mapping is used so the UI receives a plausible
|
|
502
|
+
* `rssi`; this is an approximation and is documented as such.
|
|
503
|
+
* @param {unknown} percent
|
|
504
|
+
* @param {unknown} dbm
|
|
505
|
+
* @returns {number|null}
|
|
506
|
+
*/
|
|
507
|
+
function rssiFromSignal(percent, dbm) {
|
|
508
|
+
const explicit = num(dbm);
|
|
509
|
+
if (explicit !== null) return Math.round(explicit);
|
|
510
|
+
const quality = num(percent);
|
|
511
|
+
if (quality === null) return null;
|
|
512
|
+
return Math.round(clamp(quality, 0, 100) / 2 - 100);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Metrics-level Wi-Fi sample: `{ssid, rssi, channel} | null`.
|
|
517
|
+
* @param {unknown} raw
|
|
518
|
+
* @returns {{ssid: string, rssi: number|null, channel: number|null}|null}
|
|
519
|
+
*/
|
|
520
|
+
function mapMetricWifi(raw) {
|
|
521
|
+
if (!isObject(raw)) return null;
|
|
522
|
+
const ssid = str(raw.ssid);
|
|
523
|
+
// Windows may redact the SSID behind a location-permission placeholder.
|
|
524
|
+
if (!ssid || /^<.*>$/.test(ssid)) return null;
|
|
525
|
+
return {
|
|
526
|
+
ssid,
|
|
527
|
+
rssi: rssiFromSignal(raw.signalPercent, raw.rssiDbm),
|
|
528
|
+
channel: num(raw.channel),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Network-section Wi-Fi sample: `{ssid,bssid,rssi,noise,channel,security,txRateMbps} | null`.
|
|
534
|
+
* @param {unknown} raw
|
|
535
|
+
* @returns {{ssid: string, bssid: string|null, rssi: number|null, noise: null,
|
|
536
|
+
* channel: number|null, security: string|null, txRateMbps: number|null}|null}
|
|
537
|
+
*/
|
|
538
|
+
function mapNetworkWifi(raw) {
|
|
539
|
+
if (!isObject(raw)) return null;
|
|
540
|
+
const ssid = str(raw.ssid);
|
|
541
|
+
if (!ssid || /^<.*>$/.test(ssid)) return null;
|
|
542
|
+
return {
|
|
543
|
+
ssid,
|
|
544
|
+
bssid: str(raw.bssid),
|
|
545
|
+
rssi: rssiFromSignal(raw.signalPercent, raw.rssiDbm),
|
|
546
|
+
noise: null,
|
|
547
|
+
channel: num(raw.channel),
|
|
548
|
+
security: firstStr(raw, ['authentication', 'security']),
|
|
549
|
+
txRateMbps: num(raw.transmitRateMbps) ?? num(raw.receiveRateMbps),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Memory pressure estimate from the last metrics sample. Windows has no
|
|
555
|
+
* pressure-level counter, so this is a documented heuristic on available RAM.
|
|
556
|
+
* @param {number|null} totalBytes
|
|
557
|
+
* @param {number|null} freeBytes
|
|
558
|
+
* @param {number|null} availableBytes
|
|
559
|
+
* @returns {'normal'|'warning'|'critical'|null}
|
|
560
|
+
*/
|
|
561
|
+
function memoryPressure(totalBytes, freeBytes, availableBytes) {
|
|
562
|
+
const effectiveFree = availableBytes ?? freeBytes;
|
|
563
|
+
if (effectiveFree === null || totalBytes === null || totalBytes <= 0) return null;
|
|
564
|
+
const ratio = effectiveFree / totalBytes;
|
|
565
|
+
if (ratio < 0.05) return 'critical';
|
|
566
|
+
if (ratio < 0.12) return 'warning';
|
|
567
|
+
return 'normal';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Store the five standard `metrics.battery` fields (present==false → null).
|
|
572
|
+
* @param {unknown} raw
|
|
573
|
+
* @returns {object|null}
|
|
574
|
+
*/
|
|
575
|
+
function mapMetricBattery(raw) {
|
|
576
|
+
if (!isObject(raw) || raw.present === false) return null;
|
|
577
|
+
return {
|
|
578
|
+
present: true,
|
|
579
|
+
percent: num(raw.percent),
|
|
580
|
+
charging: boolOrNull(raw.charging) ?? false,
|
|
581
|
+
plugged: boolOrNull(raw.plugged) ?? false,
|
|
582
|
+
powerSource: str(raw.powerSource),
|
|
583
|
+
timeRemainingMin: num(raw.timeRemainingMin),
|
|
584
|
+
temperatureC: round(raw.temperatureC, 1),
|
|
585
|
+
cycleCount: num(raw.cycleCount),
|
|
586
|
+
healthPercent: num(raw.healthPercent),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/* -------------------------------------------------------------------------- */
|
|
591
|
+
/* Mapper: metrics.thermal and hardware.drivers */
|
|
592
|
+
/* -------------------------------------------------------------------------- */
|
|
593
|
+
|
|
594
|
+
/** Contract cap for `hardware.drivers` (both platforms). */
|
|
595
|
+
const MAX_DRIVERS = 250;
|
|
596
|
+
/** Contract cap for `hardware.driverProblems` (Windows only). */
|
|
597
|
+
const MAX_DRIVER_PROBLEMS = 50;
|
|
598
|
+
|
|
599
|
+
/** Allowed `metrics.thermal.note` values (frozen). */
|
|
600
|
+
const THERMAL_NOTES = ['cpu-needs-admin', 'wmi-unsupported', 'unavailable'];
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Accept only a physically plausible temperature (a malformed CIM/nvidia-smi
|
|
604
|
+
* value must never become a bogus reading).
|
|
605
|
+
* @param {unknown} value
|
|
606
|
+
* @returns {number|null}
|
|
607
|
+
*/
|
|
608
|
+
function plausibleTemperature(value) {
|
|
609
|
+
const parsed = num(value);
|
|
610
|
+
if (parsed === null || parsed < -20 || parsed > 150) return null;
|
|
611
|
+
return round(parsed, 1);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The contract's null-filled thermal object (fallback metrics / hard failure).
|
|
616
|
+
* @param {'cpu-needs-admin'|'wmi-unsupported'|'unavailable'|null} [note]
|
|
617
|
+
* @returns {{available: false, cpuCelsius: null, gpuCelsius: null,
|
|
618
|
+
* batteryCelsius: null, nvmeCelsius: null, pressure: null, fans: never[],
|
|
619
|
+
* sources: never[], note: 'cpu-needs-admin'|'wmi-unsupported'|'unavailable'|null}}
|
|
620
|
+
*/
|
|
621
|
+
function emptyThermal(note = null) {
|
|
622
|
+
return {
|
|
623
|
+
available: false,
|
|
624
|
+
cpuCelsius: null,
|
|
625
|
+
gpuCelsius: null,
|
|
626
|
+
batteryCelsius: null,
|
|
627
|
+
nvmeCelsius: null,
|
|
628
|
+
pressure: null,
|
|
629
|
+
fans: [],
|
|
630
|
+
sources: [],
|
|
631
|
+
note,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Pure mapper for the `thermal` block emitted by metrics.ps1.
|
|
637
|
+
*
|
|
638
|
+
* Windows sources: `MSAcpi_ThermalZoneTemperature` (WMI thermal zones, the
|
|
639
|
+
* script keeps only plausible zones and reports their max as `cpuCelsius`),
|
|
640
|
+
* optional `nvidia-smi` (GPU temperature + fan percent) and the
|
|
641
|
+
* `Win32_Battery`/`BatteryStatus` block for `batteryCelsius`. There is no
|
|
642
|
+
* Windows thermal-pressure counter, so `pressure` is always null.
|
|
643
|
+
*
|
|
644
|
+
* @param {unknown} raw thermal sub-document
|
|
645
|
+
* @param {object|null} battery mapped `metrics.battery`
|
|
646
|
+
* @returns {object} frozen `metrics.thermal` shape
|
|
647
|
+
*/
|
|
648
|
+
function mapThermal(raw, battery) {
|
|
649
|
+
const data = isObject(raw) ? raw : {};
|
|
650
|
+
const fans = asArray(data.fans)
|
|
651
|
+
.filter(isObject)
|
|
652
|
+
.slice(0, 8)
|
|
653
|
+
.map((fan) => ({ name: str(fan.name), rpm: num(fan.rpm) }));
|
|
654
|
+
const cpuCelsius = plausibleTemperature(data.cpuCelsius);
|
|
655
|
+
const gpuCelsius = plausibleTemperature(data.gpuCelsius);
|
|
656
|
+
const batteryCelsius =
|
|
657
|
+
battery && typeof battery.temperatureC === 'number'
|
|
658
|
+
? plausibleTemperature(battery.temperatureC)
|
|
659
|
+
: plausibleTemperature(data.batteryCelsius);
|
|
660
|
+
const sources = asArray(data.sources)
|
|
661
|
+
.map((value) => str(value))
|
|
662
|
+
.filter((value) => value !== null)
|
|
663
|
+
.slice(0, 12);
|
|
664
|
+
const noteValue = str(data.note);
|
|
665
|
+
const note = noteValue !== null && THERMAL_NOTES.includes(noteValue) ? noteValue : cpuCelsius === null ? 'wmi-unsupported' : null;
|
|
666
|
+
const nvmeCelsius = plausibleTemperature(data.nvmeCelsius);
|
|
667
|
+
const available = [cpuCelsius, gpuCelsius, batteryCelsius, nvmeCelsius].some(
|
|
668
|
+
(value) => typeof value === 'number' && Number.isFinite(value),
|
|
669
|
+
);
|
|
670
|
+
return {
|
|
671
|
+
available,
|
|
672
|
+
cpuCelsius,
|
|
673
|
+
gpuCelsius,
|
|
674
|
+
batteryCelsius,
|
|
675
|
+
nvmeCelsius,
|
|
676
|
+
pressure: null,
|
|
677
|
+
fans,
|
|
678
|
+
sources,
|
|
679
|
+
note,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Normalise a driver date into the contract's `yyyy-MM-dd` form. Accepts the
|
|
685
|
+
* string already formatted by hardware.ps1 as well as defensive alternatives
|
|
686
|
+
* (ISO timestamps, numeric epoch ms) without ever throwing.
|
|
687
|
+
* @param {unknown} value
|
|
688
|
+
* @returns {string|null}
|
|
689
|
+
*/
|
|
690
|
+
function mapDriverDate(value) {
|
|
691
|
+
if (value === null || value === undefined) return null;
|
|
692
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
693
|
+
const date = new Date(value);
|
|
694
|
+
return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : null;
|
|
695
|
+
}
|
|
696
|
+
const text = str(value);
|
|
697
|
+
if (text === null) return null;
|
|
698
|
+
const iso = /^(\d{4}-\d{2}-\d{2})/.exec(text);
|
|
699
|
+
if (iso) return iso[1];
|
|
700
|
+
const parsed = Date.parse(text);
|
|
701
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString().slice(0, 10) : null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Pure mapper for one `Win32_PnPSignedDriver` record.
|
|
706
|
+
* @param {unknown} raw
|
|
707
|
+
* @returns {{name: string, class: string|null, manufacturer: string|null,
|
|
708
|
+
* version: string|null, date: string|null, signer: string|null,
|
|
709
|
+
* inf: string|null, deviceId: string|null, kind: 'kext'|'driverkit'|'pnp',
|
|
710
|
+
* problem: string|null}}
|
|
711
|
+
*/
|
|
712
|
+
function mapDriver(raw) {
|
|
713
|
+
const entry = isObject(raw) ? raw : {};
|
|
714
|
+
const name = str(entry.name) ?? str(entry.deviceName) ?? str(entry.DeviceName) ?? 'Unknown device';
|
|
715
|
+
const kind = str(entry.kind);
|
|
716
|
+
return {
|
|
717
|
+
name,
|
|
718
|
+
class: str(entry.class) ?? str(entry.deviceClass),
|
|
719
|
+
manufacturer: str(entry.manufacturer),
|
|
720
|
+
version: str(entry.version) ?? str(entry.driverVersion),
|
|
721
|
+
date: mapDriverDate(entry.date ?? entry.driverDate),
|
|
722
|
+
signer: str(entry.signer) ?? str(entry.driverProviderName),
|
|
723
|
+
inf: str(entry.inf) ?? str(entry.infName),
|
|
724
|
+
deviceId: str(entry.deviceId) ?? str(entry.deviceID),
|
|
725
|
+
kind: kind === 'kext' || kind === 'driverkit' ? kind : 'pnp',
|
|
726
|
+
problem: str(entry.problem),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Pure mapper for one problem PnP device (`ConfigManagerErrorCode != 0`).
|
|
732
|
+
* @param {unknown} raw
|
|
733
|
+
* @returns {{name: string, code: string, class: string|null}}
|
|
734
|
+
*/
|
|
735
|
+
function mapDriverProblem(raw) {
|
|
736
|
+
const entry = isObject(raw) ? raw : {};
|
|
737
|
+
return {
|
|
738
|
+
name: str(entry.name) ?? str(entry.deviceName) ?? 'Unknown device',
|
|
739
|
+
code: str(entry.code) ?? str(entry.configManagerErrorCode) ?? 'unknown',
|
|
740
|
+
class: str(entry.class) ?? str(entry.deviceClass),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* A driver is third-party unless its provider/manufacturer is Microsoft.
|
|
746
|
+
* Empty vendors are not counted as third-party (never invent a value).
|
|
747
|
+
* @param {{signer: string|null, manufacturer: string|null}} driver
|
|
748
|
+
* @returns {boolean}
|
|
749
|
+
*/
|
|
750
|
+
function isThirdPartyDriver(driver) {
|
|
751
|
+
const vendor = driver.signer ?? driver.manufacturer;
|
|
752
|
+
return vendor !== null && !/^microsoft/i.test(vendor);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Build the header-style Windows OS version string, e.g.
|
|
757
|
+
* `Windows 11 23H2 (22631)`.
|
|
758
|
+
* @param {Record<string, any>} rawOs
|
|
759
|
+
* @returns {string|null}
|
|
760
|
+
*/
|
|
761
|
+
function windowsOsVersion(rawOs) {
|
|
762
|
+
if (!isObject(rawOs)) return null;
|
|
763
|
+
const caption = str(rawOs.caption);
|
|
764
|
+
const displayVersion = str(rawOs.displayVersion);
|
|
765
|
+
const version = str(rawOs.version);
|
|
766
|
+
const build = str(rawOs.build);
|
|
767
|
+
// Never fabricate a version: without any OS fact the value is genuinely
|
|
768
|
+
// unavailable and the contract allows null.
|
|
769
|
+
if (caption === null && displayVersion === null && version === null && build === null) return null;
|
|
770
|
+
let family = null;
|
|
771
|
+
if (caption) {
|
|
772
|
+
const cleaned = caption.replace(/^Microsoft\s+/i, '').trim();
|
|
773
|
+
const match = /^(Windows\s+\S+)/i.exec(cleaned);
|
|
774
|
+
family = match ? match[1] : cleaned;
|
|
775
|
+
}
|
|
776
|
+
if (!family) family = 'Windows';
|
|
777
|
+
let text = family;
|
|
778
|
+
if (displayVersion) text += ` ${displayVersion}`;
|
|
779
|
+
else if (version) text += ` ${version}`;
|
|
780
|
+
if (build && !text.includes(build)) text += ` (${build})`;
|
|
781
|
+
return text;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/* -------------------------------------------------------------------------- */
|
|
785
|
+
/* Mapper: metrics */
|
|
786
|
+
/* -------------------------------------------------------------------------- */
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Pure mapper for `metrics.ps1` output.
|
|
790
|
+
*
|
|
791
|
+
* `context.cpu` carries the `os.cpus()` delta sample; `context.rates` carries
|
|
792
|
+
* the network/disk byte-rate deltas. Both default to safe zero values so the
|
|
793
|
+
* mapper is directly usable by the offline fixture verification.
|
|
794
|
+
*
|
|
795
|
+
* @param {unknown} raw
|
|
796
|
+
* @param {{cpu?: {usagePercent?: number, perCore?: number[]}, rates?: {network?: object, disk?: object}, now?: number}} [context]
|
|
797
|
+
* @returns {object} frozen `Metrics` shape
|
|
798
|
+
*/
|
|
799
|
+
function mapMetrics(raw, context = {}) {
|
|
800
|
+
const data = isObject(raw) ? raw : {};
|
|
801
|
+
const now = num(context.now) ?? Date.now();
|
|
802
|
+
const cpuSample = isObject(context.cpu) ? context.cpu : {};
|
|
803
|
+
const rates = isObject(context.rates) ? context.rates : {};
|
|
804
|
+
const diskRates = isObject(rates.disk) ? rates.disk : {};
|
|
805
|
+
const networkRates = isObject(rates.network) ? rates.network : {};
|
|
806
|
+
|
|
807
|
+
const cpuRaw = isObject(data.cpu) ? data.cpu : {};
|
|
808
|
+
const memoryRaw = isObject(data.memory) ? data.memory : {};
|
|
809
|
+
const diskRaw = isObject(data.disk) ? data.disk : {};
|
|
810
|
+
const networkRaw = isObject(data.network) ? data.network : {};
|
|
811
|
+
const processRaw = isObject(data.processes) ? data.processes : {};
|
|
812
|
+
const osRaw = isObject(data.os) ? data.os : {};
|
|
813
|
+
const battery = mapMetricBattery(data.battery);
|
|
814
|
+
|
|
815
|
+
const logicalCores = num(cpuRaw.logicalCores) ?? safeCpuCount() ?? 0;
|
|
816
|
+
const physicalCores = num(cpuRaw.physicalCores) ?? logicalCores;
|
|
817
|
+
const perCore = asArray(cpuSample.perCore)
|
|
818
|
+
.map((value) => round(value, 1))
|
|
819
|
+
.filter((value) => value !== null);
|
|
820
|
+
|
|
821
|
+
const totalBytes = num(memoryRaw.totalBytes) ?? os.totalmem();
|
|
822
|
+
const freeBytes = num(memoryRaw.freeBytes) ?? os.freemem();
|
|
823
|
+
const usedBytes =
|
|
824
|
+
num(memoryRaw.usedBytes) ?? (totalBytes !== null && freeBytes !== null ? Math.max(0, totalBytes - freeBytes) : null);
|
|
825
|
+
const availableBytes = num(memoryRaw.availableBytes);
|
|
826
|
+
const cachedBytes = num(memoryRaw.cacheBytes);
|
|
827
|
+
|
|
828
|
+
const networkCounters = countersOf(networkRaw.counters);
|
|
829
|
+
const bootTimeMs = num(osRaw.lastBootUpTimeMs);
|
|
830
|
+
|
|
831
|
+
const topProcesses = asArray(processRaw.top)
|
|
832
|
+
.filter(isObject)
|
|
833
|
+
.slice(0, 5)
|
|
834
|
+
.map((entry) => {
|
|
835
|
+
const workingSetBytes = num(entry.workingSetBytes);
|
|
836
|
+
const memPercent =
|
|
837
|
+
num(entry.memPercent) ??
|
|
838
|
+
(workingSetBytes !== null && totalBytes !== null && totalBytes > 0
|
|
839
|
+
? round(clamp((workingSetBytes / totalBytes) * 100, 0, 100), 1)
|
|
840
|
+
: 0);
|
|
841
|
+
return {
|
|
842
|
+
pid: num(entry.pid) ?? 0,
|
|
843
|
+
name: str(entry.name),
|
|
844
|
+
cpu: round(clamp((num(entry.cpuPercent) ?? 0) / Math.max(1, logicalCores), 0, 100), 1) ?? 0,
|
|
845
|
+
memPercent: memPercent ?? 0,
|
|
846
|
+
user: str(entry.user),
|
|
847
|
+
};
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
const hostname = str(data.hostname) ?? safeHostname() ?? 'unknown';
|
|
851
|
+
const osVersion = windowsOsVersion(osRaw);
|
|
852
|
+
const uptimeSec =
|
|
853
|
+
bootTimeMs !== null && bootTimeMs > 0 && now > bootTimeMs
|
|
854
|
+
? Math.floor((now - bootTimeMs) / 1000)
|
|
855
|
+
: (() => {
|
|
856
|
+
try {
|
|
857
|
+
return Math.floor(os.uptime());
|
|
858
|
+
} catch {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
})();
|
|
862
|
+
|
|
863
|
+
return {
|
|
864
|
+
ts: now,
|
|
865
|
+
hostname,
|
|
866
|
+
osVersion,
|
|
867
|
+
uptimeSec,
|
|
868
|
+
cpu: {
|
|
869
|
+
model: str(cpuRaw.name) ?? (safeCpuCount() > 0 ? str(os.cpus()[0]?.model) : null),
|
|
870
|
+
usagePercent: round(cpuSample.usagePercent, 1) ?? 0,
|
|
871
|
+
perCore,
|
|
872
|
+
loadAvg: [], // Windows has no load-average metric; empty (never faked).
|
|
873
|
+
logicalCores,
|
|
874
|
+
physicalCores,
|
|
875
|
+
},
|
|
876
|
+
memory: {
|
|
877
|
+
totalBytes,
|
|
878
|
+
usedBytes,
|
|
879
|
+
appBytes: num(memoryRaw.appBytes),
|
|
880
|
+
wiredBytes: num(memoryRaw.wiredBytes),
|
|
881
|
+
compressedBytes: num(memoryRaw.compressedBytes),
|
|
882
|
+
cachedBytes:
|
|
883
|
+
cachedBytes !== null && totalBytes !== null ? clamp(Math.round(cachedBytes), 0, totalBytes) : null,
|
|
884
|
+
freeBytes,
|
|
885
|
+
swapTotalBytes: num(memoryRaw.pageFileTotalBytes),
|
|
886
|
+
swapUsedBytes: num(memoryRaw.pageFileUsedBytes),
|
|
887
|
+
pressure: memoryPressure(totalBytes, freeBytes, availableBytes),
|
|
888
|
+
},
|
|
889
|
+
disk: {
|
|
890
|
+
totalBytes: num(diskRaw.totalBytes),
|
|
891
|
+
usedBytes: num(diskRaw.usedBytes),
|
|
892
|
+
freeBytes: num(diskRaw.freeBytes),
|
|
893
|
+
readBytesPerSec: num(diskRates.readBytesPerSec) ?? 0,
|
|
894
|
+
writeBytesPerSec: num(diskRates.writeBytesPerSec) ?? 0,
|
|
895
|
+
},
|
|
896
|
+
network: {
|
|
897
|
+
rxBytesPerSec: num(networkRates.readBytesPerSec) ?? 0,
|
|
898
|
+
txBytesPerSec: num(networkRates.writeBytesPerSec) ?? 0,
|
|
899
|
+
totalRxBytes: networkCounters ? networkCounters.read : null,
|
|
900
|
+
totalTxBytes: networkCounters ? networkCounters.write : null,
|
|
901
|
+
wifi: mapMetricWifi(networkRaw.wifi),
|
|
902
|
+
primaryIpv4: asArray(networkRaw.primaryIpv4)
|
|
903
|
+
.map((value) => str(value))
|
|
904
|
+
.filter((value) => value !== null),
|
|
905
|
+
defaultInterface: str(networkRaw.defaultInterface),
|
|
906
|
+
},
|
|
907
|
+
battery,
|
|
908
|
+
thermal: mapThermal(data.thermal, battery),
|
|
909
|
+
processes: {
|
|
910
|
+
total: num(processRaw.total) ?? 0,
|
|
911
|
+
top: topProcesses,
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/* -------------------------------------------------------------------------- */
|
|
917
|
+
/* Mapper: hardware */
|
|
918
|
+
/* -------------------------------------------------------------------------- */
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Pure mapper for `hardware.ps1` output.
|
|
922
|
+
* @param {unknown} raw
|
|
923
|
+
* @param {string|null} [transportError]
|
|
924
|
+
* @returns {object} frozen `hardware` section shape
|
|
925
|
+
*/
|
|
926
|
+
function mapHardware(raw, transportError) {
|
|
927
|
+
const label = 'hardware.ps1';
|
|
928
|
+
const data = isObject(raw) ? raw : {};
|
|
929
|
+
const errors = collectErrors(raw, transportError, label);
|
|
930
|
+
const cpuRaw = isObject(data.cpu) ? data.cpu : {};
|
|
931
|
+
const memoryRaw = isObject(data.memory) ? data.memory : {};
|
|
932
|
+
const physicalRaw = asArray(data.physicalDisks).filter(isObject);
|
|
933
|
+
const diskDrivesRaw = asArray(data.diskDrives).filter(isObject);
|
|
934
|
+
const controllerRaw = asArray(data.videoControllers).filter(isObject);
|
|
935
|
+
const monitorRaw = asArray(data.monitors).filter(isObject);
|
|
936
|
+
|
|
937
|
+
const modules = asArray(memoryRaw.modules)
|
|
938
|
+
.filter(isObject)
|
|
939
|
+
.map((module) => ({
|
|
940
|
+
slot: str(module.deviceLocator) ?? str(module.slot),
|
|
941
|
+
sizeBytes: num(module.capacityBytes) ?? num(module.sizeBytes),
|
|
942
|
+
type: mapMemoryType(module.smbiosType) ?? str(module.type),
|
|
943
|
+
speedMHz: num(module.speedMHz) ?? num(module.configuredSpeedMHz),
|
|
944
|
+
manufacturer: str(module.manufacturer),
|
|
945
|
+
partNumber: str(module.partNumber),
|
|
946
|
+
}));
|
|
947
|
+
|
|
948
|
+
const moduleTotal = sumField(modules, 'sizeBytes');
|
|
949
|
+
const totalBytes = num(memoryRaw.totalBytes) ?? moduleTotal;
|
|
950
|
+
const memory = {
|
|
951
|
+
totalBytes,
|
|
952
|
+
type: modules.length > 0 ? modules[0].type : str(memoryRaw.type),
|
|
953
|
+
speedMHz: modules.length > 0 ? modules[0].speedMHz : num(memoryRaw.speedMHz),
|
|
954
|
+
modules,
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
const gpu = controllerRaw.map((controller) => ({
|
|
958
|
+
name: str(controller.name),
|
|
959
|
+
cores: num(controller.cores),
|
|
960
|
+
vramBytes: num(controller.adapterRamBytes),
|
|
961
|
+
vendor: gpuVendor(str(controller.name)),
|
|
962
|
+
metal: null,
|
|
963
|
+
}));
|
|
964
|
+
|
|
965
|
+
/** @type {Array<{name: string|null, resolution: string|null, refreshHz: number|null, main: boolean, connection: string|null}>} */
|
|
966
|
+
const displays = [];
|
|
967
|
+
for (const controller of controllerRaw) {
|
|
968
|
+
const width = num(controller.currentWidth);
|
|
969
|
+
const height = num(controller.currentHeight);
|
|
970
|
+
if (width === null || height === null || width <= 0 || height <= 0) continue;
|
|
971
|
+
displays.push({
|
|
972
|
+
name: str(controller.name),
|
|
973
|
+
resolution: `${width}x${height}`,
|
|
974
|
+
refreshHz: num(controller.currentRefreshHz),
|
|
975
|
+
main: displays.length === 0,
|
|
976
|
+
connection: null,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
if (displays.length === 0 && monitorRaw.length > 0) {
|
|
980
|
+
monitorRaw.forEach((monitor, index) => {
|
|
981
|
+
displays.push({
|
|
982
|
+
name: str(monitor.name) ?? str(monitor.manufacturer),
|
|
983
|
+
resolution: null,
|
|
984
|
+
refreshHz: null,
|
|
985
|
+
main: index === 0,
|
|
986
|
+
connection: null,
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const physical =
|
|
992
|
+
physicalRaw.length > 0
|
|
993
|
+
? physicalRaw.map((disk) => ({
|
|
994
|
+
name: str(disk.name) ?? str(disk.model),
|
|
995
|
+
model: str(disk.model) ?? str(disk.name),
|
|
996
|
+
sizeBytes: num(disk.sizeBytes),
|
|
997
|
+
mediaType: str(disk.mediaType),
|
|
998
|
+
smartStatus: str(disk.smartStatus),
|
|
999
|
+
}))
|
|
1000
|
+
: diskDrivesRaw.map((disk) => ({
|
|
1001
|
+
name: str(disk.model),
|
|
1002
|
+
model: str(disk.model),
|
|
1003
|
+
sizeBytes: num(disk.sizeBytes),
|
|
1004
|
+
mediaType: str(disk.mediaType),
|
|
1005
|
+
smartStatus: null,
|
|
1006
|
+
}));
|
|
1007
|
+
|
|
1008
|
+
const volumes = asArray(data.volumes)
|
|
1009
|
+
.filter(isObject)
|
|
1010
|
+
.map((volume) => {
|
|
1011
|
+
const total = num(volume.totalBytes);
|
|
1012
|
+
const free = num(volume.freeBytes);
|
|
1013
|
+
const driveType = num(volume.driveType);
|
|
1014
|
+
return {
|
|
1015
|
+
name: str(volume.name) ?? str(volume.deviceId),
|
|
1016
|
+
mount: str(volume.deviceId),
|
|
1017
|
+
fs: str(volume.fs),
|
|
1018
|
+
totalBytes: total,
|
|
1019
|
+
usedBytes: total !== null && free !== null ? Math.max(0, total - free) : null,
|
|
1020
|
+
freeBytes: free,
|
|
1021
|
+
removable: driveType === null ? null : driveType === 2,
|
|
1022
|
+
internal: driveType === null ? null : driveType === 3,
|
|
1023
|
+
};
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
let battery = null;
|
|
1027
|
+
if (isObject(data.battery) && data.battery.present !== false) {
|
|
1028
|
+
battery = {
|
|
1029
|
+
percent: num(data.battery.percent),
|
|
1030
|
+
condition: str(data.battery.condition),
|
|
1031
|
+
cycleCount: num(data.battery.cycleCount),
|
|
1032
|
+
designCapacityMah: num(data.battery.designCapacityMah),
|
|
1033
|
+
maxCapacityMah: num(data.battery.maxCapacityMah),
|
|
1034
|
+
temperatureC: round(data.battery.temperatureC, 1),
|
|
1035
|
+
healthPercent: num(data.battery.healthPercent),
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const usb = asArray(data.usb)
|
|
1040
|
+
.filter(isObject)
|
|
1041
|
+
.map((device) => ({
|
|
1042
|
+
name: str(device.name),
|
|
1043
|
+
vendor: str(device.vendor),
|
|
1044
|
+
productId: str(device.productId),
|
|
1045
|
+
serial: str(device.serial),
|
|
1046
|
+
speed: str(device.speed),
|
|
1047
|
+
bus: str(device.bus),
|
|
1048
|
+
}));
|
|
1049
|
+
|
|
1050
|
+
const bluetoothControllers = asArray(data.bluetoothControllers).filter(isObject);
|
|
1051
|
+
const bluetoothController =
|
|
1052
|
+
bluetoothControllers.length > 0
|
|
1053
|
+
? {
|
|
1054
|
+
address: str(bluetoothControllers[0].address),
|
|
1055
|
+
chipset: str(bluetoothControllers[0].chipset) ?? str(bluetoothControllers[0].name),
|
|
1056
|
+
firmware: str(bluetoothControllers[0].firmware),
|
|
1057
|
+
state: str(bluetoothControllers[0].state),
|
|
1058
|
+
}
|
|
1059
|
+
: null;
|
|
1060
|
+
const bluetoothDevices = asArray(data.bluetoothDevices)
|
|
1061
|
+
.filter(isObject)
|
|
1062
|
+
.map((device) => ({
|
|
1063
|
+
name: str(device.name),
|
|
1064
|
+
address: str(device.address),
|
|
1065
|
+
connected: device.connected === false ? false : true,
|
|
1066
|
+
batteryPercent: num(device.batteryPercent),
|
|
1067
|
+
type: str(device.type),
|
|
1068
|
+
}));
|
|
1069
|
+
|
|
1070
|
+
const audio = asArray(data.audio)
|
|
1071
|
+
.filter(isObject)
|
|
1072
|
+
.map((device) => ({
|
|
1073
|
+
name: str(device.name),
|
|
1074
|
+
kind: device.kind === 'input' ? 'input' : 'output',
|
|
1075
|
+
manufacturer: str(device.manufacturer),
|
|
1076
|
+
default: device.default === true,
|
|
1077
|
+
}));
|
|
1078
|
+
|
|
1079
|
+
const cameras = asArray(data.cameras)
|
|
1080
|
+
.filter(isObject)
|
|
1081
|
+
.map((camera) => ({
|
|
1082
|
+
name: str(camera.name),
|
|
1083
|
+
modelId: str(camera.modelId),
|
|
1084
|
+
uniqueId: str(camera.uniqueId),
|
|
1085
|
+
}));
|
|
1086
|
+
|
|
1087
|
+
// `drivers` comes from Win32_PnPSignedDriver (cap 250); `driverProblems` from
|
|
1088
|
+
// Win32_PnPEntity rows whose ConfigManagerErrorCode is non-zero (cap 50).
|
|
1089
|
+
// The summary is derived here so it can never disagree with the arrays;
|
|
1090
|
+
// `truncated` flags that the raw driver list exceeded the cap.
|
|
1091
|
+
const rawDrivers = asArray(data.drivers).filter(isObject);
|
|
1092
|
+
const drivers = rawDrivers.slice(0, MAX_DRIVERS).map(mapDriver);
|
|
1093
|
+
const driverProblems = asArray(data.driverProblems)
|
|
1094
|
+
.filter(isObject)
|
|
1095
|
+
.slice(0, MAX_DRIVER_PROBLEMS)
|
|
1096
|
+
.map(mapDriverProblem);
|
|
1097
|
+
const driverSummary = {
|
|
1098
|
+
total: drivers.length,
|
|
1099
|
+
thirdParty: drivers.filter(isThirdPartyDriver).length,
|
|
1100
|
+
problems: driverProblems.length,
|
|
1101
|
+
truncated: rawDrivers.length > MAX_DRIVERS,
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
return {
|
|
1105
|
+
cpu: {
|
|
1106
|
+
model: str(cpuRaw.name),
|
|
1107
|
+
vendor: str(cpuRaw.manufacturer),
|
|
1108
|
+
physicalCores: num(cpuRaw.physicalCores) ?? safeCpuCount(),
|
|
1109
|
+
logicalCores: num(cpuRaw.logicalCores) ?? safeCpuCount(),
|
|
1110
|
+
speedGHz: (() => {
|
|
1111
|
+
const mhz = num(cpuRaw.maxClockSpeedMHz);
|
|
1112
|
+
return mhz === null ? null : round(mhz / 1000, 2);
|
|
1113
|
+
})(),
|
|
1114
|
+
architecture: mapProcessorArchitecture(cpuRaw.architecture),
|
|
1115
|
+
features: asArray(cpuRaw.features)
|
|
1116
|
+
.map((value) => str(value))
|
|
1117
|
+
.filter((value) => value !== null),
|
|
1118
|
+
},
|
|
1119
|
+
memory,
|
|
1120
|
+
gpu,
|
|
1121
|
+
displays,
|
|
1122
|
+
storage: { physical, volumes },
|
|
1123
|
+
battery,
|
|
1124
|
+
usb,
|
|
1125
|
+
bluetooth: { controller: bluetoothController, devices: bluetoothDevices },
|
|
1126
|
+
audio,
|
|
1127
|
+
cameras,
|
|
1128
|
+
drivers,
|
|
1129
|
+
driverSummary,
|
|
1130
|
+
driverProblems,
|
|
1131
|
+
errors,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/* -------------------------------------------------------------------------- */
|
|
1136
|
+
/* Mapper: system */
|
|
1137
|
+
/* -------------------------------------------------------------------------- */
|
|
1138
|
+
|
|
1139
|
+
/** Map PROCESSOR_ARCHITECTURE to the contract-ish arch string. */
|
|
1140
|
+
function mapHostArchitecture(value) {
|
|
1141
|
+
const text = str(value);
|
|
1142
|
+
if (!text) return null;
|
|
1143
|
+
switch (text.toUpperCase()) {
|
|
1144
|
+
case 'AMD64':
|
|
1145
|
+
return 'x64';
|
|
1146
|
+
case 'ARM64':
|
|
1147
|
+
return 'arm64';
|
|
1148
|
+
case 'X86':
|
|
1149
|
+
return 'x86';
|
|
1150
|
+
case 'IA64':
|
|
1151
|
+
return 'ia64';
|
|
1152
|
+
default:
|
|
1153
|
+
return text.toLowerCase();
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Pure mapper for `system.ps1` output.
|
|
1159
|
+
* @param {unknown} raw
|
|
1160
|
+
* @param {string|null} [transportError]
|
|
1161
|
+
* @returns {object} frozen `system` section shape
|
|
1162
|
+
*/
|
|
1163
|
+
function mapSystem(raw, transportError) {
|
|
1164
|
+
const label = 'system.ps1';
|
|
1165
|
+
const data = isObject(raw) ? raw : {};
|
|
1166
|
+
const errors = collectErrors(raw, transportError, label);
|
|
1167
|
+
const osRaw = isObject(data.os) ? data.os : {};
|
|
1168
|
+
const deviceRaw = isObject(data.device) ? data.device : {};
|
|
1169
|
+
const bootRaw = isObject(data.boot) ? data.boot : {};
|
|
1170
|
+
const usersRaw = isObject(data.users) ? data.users : {};
|
|
1171
|
+
const currentRaw = isObject(usersRaw.current) ? usersRaw.current : null;
|
|
1172
|
+
const environmentRaw = isObject(data.environment) ? data.environment : {};
|
|
1173
|
+
const localeRaw = isObject(data.locale) ? data.locale : {};
|
|
1174
|
+
|
|
1175
|
+
const hostname = str(deviceRaw.hostname) ?? safeHostname() ?? 'unknown';
|
|
1176
|
+
const computerName = str(deviceRaw.computerName) ?? hostname;
|
|
1177
|
+
const localHostname = str(deviceRaw.localHostname) ?? hostname;
|
|
1178
|
+
|
|
1179
|
+
const caption = str(osRaw.caption);
|
|
1180
|
+
let family = null;
|
|
1181
|
+
if (caption) {
|
|
1182
|
+
const cleaned = caption.replace(/^Microsoft\s+/i, '').trim();
|
|
1183
|
+
const match = /^(Windows\s+\S+)/i.exec(cleaned);
|
|
1184
|
+
family = match ? match[1] : cleaned;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const version = str(osRaw.version);
|
|
1188
|
+
const build = str(osRaw.build);
|
|
1189
|
+
const bootTime = num(bootRaw.bootTimeMs);
|
|
1190
|
+
const now = Date.now();
|
|
1191
|
+
let uptimeSec = num(bootRaw.uptimeSec);
|
|
1192
|
+
if (uptimeSec === null) {
|
|
1193
|
+
if (bootTime !== null && bootTime > 0 && now > bootTime) {
|
|
1194
|
+
uptimeSec = Math.floor((now - bootTime) / 1000);
|
|
1195
|
+
} else {
|
|
1196
|
+
try {
|
|
1197
|
+
uptimeSec = Math.floor(os.uptime());
|
|
1198
|
+
} catch {
|
|
1199
|
+
uptimeSec = null;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const currentName = str(usersRaw.current) ?? str(currentRaw?.name) ?? safeUsername();
|
|
1205
|
+
const adminNames = asArray(usersRaw.admins)
|
|
1206
|
+
.map((value) => str(value))
|
|
1207
|
+
.filter((value) => value !== null);
|
|
1208
|
+
const adminSet = new Set(adminNames.map((value) => value.toLowerCase()));
|
|
1209
|
+
|
|
1210
|
+
const currentFullName = str(currentRaw?.fullName);
|
|
1211
|
+
const currentHome = str(currentRaw?.home) ?? str(environmentRaw.HOME);
|
|
1212
|
+
const user =
|
|
1213
|
+
currentName === null
|
|
1214
|
+
? null
|
|
1215
|
+
: {
|
|
1216
|
+
name: currentName,
|
|
1217
|
+
fullName: currentFullName,
|
|
1218
|
+
uid: null,
|
|
1219
|
+
gid: null,
|
|
1220
|
+
home: currentHome,
|
|
1221
|
+
shell: null,
|
|
1222
|
+
admin: adminSet.has(currentName.toLowerCase()),
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
const users = asArray(usersRaw.localUsers)
|
|
1226
|
+
.filter(isObject)
|
|
1227
|
+
.map((account) => {
|
|
1228
|
+
const name = str(account.name);
|
|
1229
|
+
return {
|
|
1230
|
+
name,
|
|
1231
|
+
uid: null,
|
|
1232
|
+
gid: null,
|
|
1233
|
+
fullName: str(account.fullName),
|
|
1234
|
+
home: str(account.home),
|
|
1235
|
+
shell: null,
|
|
1236
|
+
admin: name !== null && adminSet.has(name.toLowerCase()),
|
|
1237
|
+
isCurrent: name !== null && currentName !== null && name.toLowerCase() === currentName.toLowerCase(),
|
|
1238
|
+
};
|
|
1239
|
+
})
|
|
1240
|
+
.filter((account) => account.name !== null);
|
|
1241
|
+
|
|
1242
|
+
/** @type {Array<{label: string|null, path: string, scope: 'user'|'system', loaded: boolean, pid: number|null, lastExitStatus: number|null, program: string}>} */
|
|
1243
|
+
const launchAgents = asArray(data.startup)
|
|
1244
|
+
.filter(isObject)
|
|
1245
|
+
.filter((entry) => str(entry.source) === null || str(entry.source) === 'registry')
|
|
1246
|
+
.map((entry) => {
|
|
1247
|
+
const path = str(entry.path) ?? '';
|
|
1248
|
+
return {
|
|
1249
|
+
label: str(entry.label) ?? path,
|
|
1250
|
+
path,
|
|
1251
|
+
scope: /** @type {'user'|'system'} */ (str(entry.scope) === 'user' ? 'user' : 'system'),
|
|
1252
|
+
loaded: entry.loaded !== false,
|
|
1253
|
+
pid: null,
|
|
1254
|
+
lastExitStatus: null,
|
|
1255
|
+
program: str(entry.program) ?? '',
|
|
1256
|
+
};
|
|
1257
|
+
})
|
|
1258
|
+
.filter((entry) => entry.path !== '');
|
|
1259
|
+
launchAgents.sort((left, right) => {
|
|
1260
|
+
if (left.scope !== right.scope) return left.scope === 'user' ? -1 : 1;
|
|
1261
|
+
return (left.label ?? left.path).localeCompare(right.label ?? right.path);
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
const loginItems = asArray(data.loginItems)
|
|
1265
|
+
.map((item) => ({ name: isObject(item) ? str(item.name) : str(item) }))
|
|
1266
|
+
.filter((item) => item.name !== null);
|
|
1267
|
+
|
|
1268
|
+
let timezone = str(localeRaw.timezone);
|
|
1269
|
+
if (timezone === null) {
|
|
1270
|
+
try {
|
|
1271
|
+
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? null;
|
|
1272
|
+
} catch {
|
|
1273
|
+
timezone = null;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
return {
|
|
1278
|
+
os: {
|
|
1279
|
+
name: family ?? 'Windows',
|
|
1280
|
+
version,
|
|
1281
|
+
build,
|
|
1282
|
+
kernel: version ? `Windows NT ${version}` : null,
|
|
1283
|
+
arch: mapHostArchitecture(osRaw.processorArchitecture) ?? process.arch ?? null,
|
|
1284
|
+
release: build,
|
|
1285
|
+
},
|
|
1286
|
+
device: {
|
|
1287
|
+
hostname,
|
|
1288
|
+
computerName,
|
|
1289
|
+
localHostname,
|
|
1290
|
+
model: str(deviceRaw.model) ?? str(deviceRaw.manufacturer),
|
|
1291
|
+
modelId: str(deviceRaw.modelId),
|
|
1292
|
+
chip: str(deviceRaw.chip),
|
|
1293
|
+
serial: str(deviceRaw.serial),
|
|
1294
|
+
hardwareUuid: str(deviceRaw.hardwareUuid),
|
|
1295
|
+
},
|
|
1296
|
+
boot: { bootTime, uptimeSec },
|
|
1297
|
+
user,
|
|
1298
|
+
users,
|
|
1299
|
+
launchAgents,
|
|
1300
|
+
loginItems,
|
|
1301
|
+
environment: {
|
|
1302
|
+
PATH: str(environmentRaw.PATH) ?? str(process.env.PATH),
|
|
1303
|
+
SHELL: str(environmentRaw.SHELL),
|
|
1304
|
+
HOME: str(environmentRaw.HOME) ?? str(process.env.USERPROFILE) ?? str(process.env.HOME),
|
|
1305
|
+
USER: str(environmentRaw.USER) ?? str(process.env.USERNAME) ?? str(process.env.USER),
|
|
1306
|
+
LANG: str(environmentRaw.LANG),
|
|
1307
|
+
TERM: str(environmentRaw.TERM),
|
|
1308
|
+
},
|
|
1309
|
+
locale: {
|
|
1310
|
+
timezone,
|
|
1311
|
+
locale: str(localeRaw.locale),
|
|
1312
|
+
language: str(localeRaw.language),
|
|
1313
|
+
},
|
|
1314
|
+
errors,
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/* -------------------------------------------------------------------------- */
|
|
1319
|
+
/* Mapper: software */
|
|
1320
|
+
/* -------------------------------------------------------------------------- */
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Pure mapper for `software.ps1` output.
|
|
1324
|
+
*
|
|
1325
|
+
* Windows sources:
|
|
1326
|
+
* - installed applications from the registry Uninstall keys; `uninstallId` is
|
|
1327
|
+
* `<HIVE>:<full key path>` (the action engine's uninstall input) and
|
|
1328
|
+
* `bundleId` is the registry key name (GUID or product key);
|
|
1329
|
+
* - `source` is `user` for HKCU entries and `other` for HKLM entries (the
|
|
1330
|
+
* client only disables uninstall for `system`, which is reserved for
|
|
1331
|
+
* protected macOS apps);
|
|
1332
|
+
* - `homebrew` is present but unavailable; `winget` is an additive field
|
|
1333
|
+
* detected by the script (cached with the section's 10-minute TTL).
|
|
1334
|
+
*
|
|
1335
|
+
* @param {unknown} raw
|
|
1336
|
+
* @param {string|null} [transportError]
|
|
1337
|
+
* @returns {object} frozen `software` section shape (+ additive `winget`)
|
|
1338
|
+
*/
|
|
1339
|
+
function mapSoftware(raw, transportError) {
|
|
1340
|
+
const label = 'software.ps1';
|
|
1341
|
+
const data = isObject(raw) ? raw : {};
|
|
1342
|
+
const errors = collectErrors(raw, transportError, label);
|
|
1343
|
+
|
|
1344
|
+
const applications = asArray(data.applications)
|
|
1345
|
+
.filter(isObject)
|
|
1346
|
+
.map((application) => {
|
|
1347
|
+
const hiveText = str(application.hive)?.toUpperCase() ?? null;
|
|
1348
|
+
const hive = hiveText === 'HKLM' || hiveText === 'HKCU' ? hiveText : null;
|
|
1349
|
+
const keyPathText = str(application.keyPath);
|
|
1350
|
+
// Only build the action-facing uninstallId for a genuine Uninstall key
|
|
1351
|
+
// path; a malformed document must not yield an id the action engine would
|
|
1352
|
+
// reject (or worse, misread).
|
|
1353
|
+
const keyPath =
|
|
1354
|
+
keyPathText !== null &&
|
|
1355
|
+
/^Software\\(WOW6432Node\\)?Microsoft\\Windows\\CurrentVersion\\Uninstall\\/i.test(keyPathText)
|
|
1356
|
+
? keyPathText
|
|
1357
|
+
: null;
|
|
1358
|
+
const keyName = str(application.keyName);
|
|
1359
|
+
const estimatedSizeKb = num(application.estimatedSizeKB);
|
|
1360
|
+
return {
|
|
1361
|
+
name: str(application.displayName),
|
|
1362
|
+
path: str(application.installLocation) ?? '',
|
|
1363
|
+
version: str(application.version),
|
|
1364
|
+
bundleId: keyName,
|
|
1365
|
+
uninstallId: hive !== null && keyPath !== null ? `${hive}:${keyPath}` : null,
|
|
1366
|
+
source: hive === 'HKCU' ? 'user' : 'other',
|
|
1367
|
+
arch: str(application.arch),
|
|
1368
|
+
lastUsed: null,
|
|
1369
|
+
sizeBytes: estimatedSizeKb === null ? null : Math.round(estimatedSizeKb * 1024),
|
|
1370
|
+
};
|
|
1371
|
+
})
|
|
1372
|
+
.filter((application) => application.name !== null);
|
|
1373
|
+
applications.sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
|
|
1374
|
+
|
|
1375
|
+
const wingetRaw = isObject(data.winget) ? data.winget : {};
|
|
1376
|
+
|
|
1377
|
+
return {
|
|
1378
|
+
applications,
|
|
1379
|
+
totalApplications: applications.length,
|
|
1380
|
+
homebrew: { available: false, prefix: null, installed: [], outdated: [] },
|
|
1381
|
+
winget: {
|
|
1382
|
+
available: wingetRaw.available === true,
|
|
1383
|
+
version: str(wingetRaw.version),
|
|
1384
|
+
},
|
|
1385
|
+
errors,
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/* -------------------------------------------------------------------------- */
|
|
1390
|
+
/* Mapper: network */
|
|
1391
|
+
/* -------------------------------------------------------------------------- */
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Classify a Windows adapter for the contract's interface type enum.
|
|
1395
|
+
* @param {Record<string, any>} adapter
|
|
1396
|
+
* @returns {'wifi'|'ethernet'|'loopback'|'bridge'|'other'}
|
|
1397
|
+
*/
|
|
1398
|
+
function mapInterfaceType(adapter) {
|
|
1399
|
+
const name = str(adapter.name) ?? '';
|
|
1400
|
+
const description = str(adapter.description) ?? '';
|
|
1401
|
+
const combined = `${name} ${description}`;
|
|
1402
|
+
if (/loopback/i.test(combined)) return 'loopback';
|
|
1403
|
+
const medium = num(adapter.ndisMedium);
|
|
1404
|
+
if (medium === 9 || medium === 1 || medium === 8 || medium === 12) return 'wifi';
|
|
1405
|
+
if (/wi-?fi|wireless|wlan|802\.11/i.test(combined)) return 'wifi';
|
|
1406
|
+
if (/hyper-v|vethernet|bridge|virtual switch/i.test(combined)) return 'bridge';
|
|
1407
|
+
if (medium === 14 || medium === 17 || medium === 18) return 'ethernet';
|
|
1408
|
+
if (/ethernet|802\.3|gbe/i.test(combined)) return 'ethernet';
|
|
1409
|
+
return 'other';
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Map the WinHTTP/IE proxy settings into the contract's three slots.
|
|
1414
|
+
* @param {unknown} raw
|
|
1415
|
+
* @returns {{http: string|null, https: string|null, socks: string|null}}
|
|
1416
|
+
*/
|
|
1417
|
+
function mapProxy(raw) {
|
|
1418
|
+
/** @type {{http: string|null, https: string|null, socks: string|null}} */
|
|
1419
|
+
const result = { http: null, https: null, socks: null };
|
|
1420
|
+
if (!isObject(raw)) return result;
|
|
1421
|
+
const enabled = raw.enable === true || raw.enable === 1 || raw.enable === '1';
|
|
1422
|
+
if (!enabled) return result;
|
|
1423
|
+
const server = str(raw.server);
|
|
1424
|
+
if (!server) return result;
|
|
1425
|
+
if (server.includes('=')) {
|
|
1426
|
+
for (const part of server.split(';')) {
|
|
1427
|
+
const separator = part.indexOf('=');
|
|
1428
|
+
if (separator === -1) continue;
|
|
1429
|
+
const key = part.slice(0, separator).trim().toLowerCase();
|
|
1430
|
+
const value = str(part.slice(separator + 1));
|
|
1431
|
+
if (!value) continue;
|
|
1432
|
+
if (key === 'http') result.http = value;
|
|
1433
|
+
else if (key === 'https') result.https = value;
|
|
1434
|
+
else if (key === 'socks' || key === 'socks5') result.socks = value;
|
|
1435
|
+
}
|
|
1436
|
+
} else {
|
|
1437
|
+
result.http = server;
|
|
1438
|
+
result.https = server;
|
|
1439
|
+
}
|
|
1440
|
+
return result;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* Pure mapper for `network.ps1` output.
|
|
1445
|
+
* @param {unknown} raw
|
|
1446
|
+
* @param {string|null} [transportError]
|
|
1447
|
+
* @returns {object} frozen `network` section shape
|
|
1448
|
+
*/
|
|
1449
|
+
function mapNetwork(raw, transportError) {
|
|
1450
|
+
const label = 'network.ps1';
|
|
1451
|
+
const data = isObject(raw) ? raw : {};
|
|
1452
|
+
const errors = collectErrors(raw, transportError, label);
|
|
1453
|
+
const routeRaw = isObject(data.defaultRoute) ? data.defaultRoute : null;
|
|
1454
|
+
const defaultInterface = str(routeRaw?.interface);
|
|
1455
|
+
|
|
1456
|
+
/** @type {Array<{name: string, type: string, up: boolean, ipv4: string[], ipv6: string[], mac: string|null, mtu: number|null, primary: boolean}>} */
|
|
1457
|
+
const interfaces = [];
|
|
1458
|
+
const seenNames = new Set();
|
|
1459
|
+
for (const adapter of asArray(data.adapters).filter(isObject)) {
|
|
1460
|
+
const name = str(adapter.name);
|
|
1461
|
+
if (!name || seenNames.has(name)) continue;
|
|
1462
|
+
seenNames.add(name);
|
|
1463
|
+
const ipv4 = asArray(adapter.ipv4)
|
|
1464
|
+
.map((value) => str(value))
|
|
1465
|
+
.filter((value) => value !== null);
|
|
1466
|
+
const ipv6 = asArray(adapter.ipv6)
|
|
1467
|
+
.map((value) => str(value))
|
|
1468
|
+
.filter((value) => value !== null);
|
|
1469
|
+
const type = mapInterfaceType(adapter);
|
|
1470
|
+
const status = (str(adapter.status) ?? '').toLowerCase();
|
|
1471
|
+
const up = status === 'up' || status === 'connected' || adapter.up === true;
|
|
1472
|
+
const isPrimary = defaultInterface !== null && name === defaultInterface;
|
|
1473
|
+
if (type === 'loopback') {
|
|
1474
|
+
// network.ps1 intentionally drops 127.0.0.1/::1 from the address maps;
|
|
1475
|
+
// the loopback interface still needs its well-known addresses so the UI
|
|
1476
|
+
// receives a complete interface card.
|
|
1477
|
+
if (ipv4.length === 0) ipv4.push('127.0.0.1');
|
|
1478
|
+
if (ipv6.length === 0) ipv6.push('::1');
|
|
1479
|
+
}
|
|
1480
|
+
if (type !== 'loopback' && !isPrimary && !up && ipv4.length === 0 && ipv6.length === 0) continue;
|
|
1481
|
+
let mtu = num(adapter.mtu);
|
|
1482
|
+
// The Windows loopback adapter reports a sentinel MTU (4294967295); it is
|
|
1483
|
+
// not a meaningful value, so report null instead of a bogus number.
|
|
1484
|
+
if (mtu !== null && mtu > 1_000_000) mtu = null;
|
|
1485
|
+
interfaces.push({
|
|
1486
|
+
name,
|
|
1487
|
+
type,
|
|
1488
|
+
up,
|
|
1489
|
+
ipv4,
|
|
1490
|
+
ipv6,
|
|
1491
|
+
mac: str(adapter.mac),
|
|
1492
|
+
mtu,
|
|
1493
|
+
primary: isPrimary,
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
if (!interfaces.some((entry) => entry.type === 'loopback')) {
|
|
1497
|
+
interfaces.unshift({
|
|
1498
|
+
name: 'Loopback',
|
|
1499
|
+
type: 'loopback',
|
|
1500
|
+
up: true,
|
|
1501
|
+
ipv4: ['127.0.0.1'],
|
|
1502
|
+
ipv6: ['::1'],
|
|
1503
|
+
mac: null,
|
|
1504
|
+
mtu: null,
|
|
1505
|
+
primary: false,
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
interfaces.sort((left, right) => {
|
|
1509
|
+
if (left.primary !== right.primary) return left.primary ? -1 : 1;
|
|
1510
|
+
if (left.type === 'loopback' && right.type !== 'loopback') return 1;
|
|
1511
|
+
if (right.type === 'loopback' && left.type !== 'loopback') return -1;
|
|
1512
|
+
return left.name.localeCompare(right.name);
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
const dnsRaw = isObject(data.dns) ? data.dns : {};
|
|
1516
|
+
const dnsServers = [];
|
|
1517
|
+
for (const server of asArray(dnsRaw.servers)) {
|
|
1518
|
+
const text = str(server);
|
|
1519
|
+
if (text && !dnsServers.includes(text)) dnsServers.push(text);
|
|
1520
|
+
}
|
|
1521
|
+
const searchDomains = [];
|
|
1522
|
+
for (const domain of asArray(dnsRaw.searchDomains)) {
|
|
1523
|
+
const text = str(domain);
|
|
1524
|
+
if (text && !searchDomains.includes(text)) searchDomains.push(text);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/** @type {Array<{proto: string, address: string, port: number, pid: number|null, process: string}>} */
|
|
1528
|
+
const listeningPorts = [];
|
|
1529
|
+
const pushListeners = (items, proto) => {
|
|
1530
|
+
for (const listener of items) {
|
|
1531
|
+
if (!isObject(listener)) continue;
|
|
1532
|
+
const port = num(listener.port);
|
|
1533
|
+
if (port === null || port <= 0) continue;
|
|
1534
|
+
listeningPorts.push({
|
|
1535
|
+
proto,
|
|
1536
|
+
address: str(listener.address) ?? '*',
|
|
1537
|
+
port,
|
|
1538
|
+
pid: num(listener.pid),
|
|
1539
|
+
process: str(listener.process) ?? '',
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
pushListeners(asArray(data.tcpListeners), 'tcp');
|
|
1544
|
+
pushListeners(asArray(data.udpListeners), 'udp');
|
|
1545
|
+
listeningPorts.sort((left, right) => left.port - right.port || left.proto.localeCompare(right.proto));
|
|
1546
|
+
listeningPorts.splice(500);
|
|
1547
|
+
|
|
1548
|
+
return {
|
|
1549
|
+
interfaces,
|
|
1550
|
+
wifi: mapNetworkWifi(data.wifi),
|
|
1551
|
+
defaultRoute:
|
|
1552
|
+
routeRaw && (str(routeRaw.gateway) !== null || str(routeRaw.interface) !== null)
|
|
1553
|
+
? { gateway: str(routeRaw.gateway), interface: str(routeRaw.interface) }
|
|
1554
|
+
: null,
|
|
1555
|
+
dns: { servers: dnsServers.slice(0, 12), searchDomains: searchDomains.slice(0, 12) },
|
|
1556
|
+
proxy: mapProxy(data.proxy),
|
|
1557
|
+
listeningPorts,
|
|
1558
|
+
establishedCount: num(data.establishedCount) ?? 0,
|
|
1559
|
+
errors,
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/* -------------------------------------------------------------------------- */
|
|
1564
|
+
/* Mapper: security */
|
|
1565
|
+
/* -------------------------------------------------------------------------- */
|
|
1566
|
+
|
|
1567
|
+
/** Normalize one `{enabled, raw}` status block. */
|
|
1568
|
+
function statusFlag(raw) {
|
|
1569
|
+
const value = isObject(raw) ? raw : {};
|
|
1570
|
+
return { enabled: boolOrNull(value.enabled), raw: str(value.raw) };
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
* Pure mapper for `security.ps1` output.
|
|
1575
|
+
*
|
|
1576
|
+
* Windows mapping: BitLocker → fileVault, Secure Boot → sip, Defender real-time
|
|
1577
|
+
* protection → gatekeeper, Windows Firewall profiles → firewall, Remote Desktop
|
|
1578
|
+
* → remoteLogin, Winlogon auto-logon → autoLogin. TCC has no Windows analogue,
|
|
1579
|
+
* so the documented fixed `{readable:false, ...}` payload is returned.
|
|
1580
|
+
*
|
|
1581
|
+
* @param {unknown} raw
|
|
1582
|
+
* @param {string|null} [transportError]
|
|
1583
|
+
* @returns {object} frozen `security` section shape
|
|
1584
|
+
*/
|
|
1585
|
+
function mapSecurity(raw, transportError) {
|
|
1586
|
+
const label = 'security.ps1';
|
|
1587
|
+
const data = isObject(raw) ? raw : {};
|
|
1588
|
+
const errors = collectErrors(raw, transportError, label);
|
|
1589
|
+
const usersRaw = isObject(data.users) ? data.users : {};
|
|
1590
|
+
const firewallRaw = isObject(data.firewall) ? data.firewall : {};
|
|
1591
|
+
|
|
1592
|
+
const admins = asArray(usersRaw.admins)
|
|
1593
|
+
.map((value) => str(value))
|
|
1594
|
+
.filter((value) => value !== null);
|
|
1595
|
+
|
|
1596
|
+
return {
|
|
1597
|
+
fileVault: statusFlag(data.fileVault),
|
|
1598
|
+
sip: statusFlag(data.sip),
|
|
1599
|
+
gatekeeper: statusFlag(data.gatekeeper),
|
|
1600
|
+
firewall: {
|
|
1601
|
+
...statusFlag(firewallRaw),
|
|
1602
|
+
stealthMode: boolOrNull(firewallRaw.stealthMode),
|
|
1603
|
+
},
|
|
1604
|
+
tcc: {
|
|
1605
|
+
readable: false,
|
|
1606
|
+
reason: 'Windows does not expose a per-app consent store',
|
|
1607
|
+
grants: [],
|
|
1608
|
+
},
|
|
1609
|
+
users: {
|
|
1610
|
+
current: str(usersRaw.current) ?? safeUsername(),
|
|
1611
|
+
admins,
|
|
1612
|
+
total: num(usersRaw.total),
|
|
1613
|
+
},
|
|
1614
|
+
remoteLogin: boolOrNull(data.remoteLogin),
|
|
1615
|
+
autoLogin: str(data.autoLogin),
|
|
1616
|
+
errors,
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/* -------------------------------------------------------------------------- */
|
|
1621
|
+
/* Mapper: processes */
|
|
1622
|
+
/* -------------------------------------------------------------------------- */
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* Pure mapper for `processes.ps1` output.
|
|
1626
|
+
*
|
|
1627
|
+
* Windows perf counters report CPU across all logical processors; the raw value
|
|
1628
|
+
* is divided by the logical core count to get the Task-Manager-style 0..100
|
|
1629
|
+
* percent used by the rest of the contract.
|
|
1630
|
+
*
|
|
1631
|
+
* @param {unknown} raw
|
|
1632
|
+
* @param {string|null} [transportError]
|
|
1633
|
+
* @returns {object} frozen `processes` section shape
|
|
1634
|
+
*/
|
|
1635
|
+
function mapProcesses(raw, transportError) {
|
|
1636
|
+
const label = 'processes.ps1';
|
|
1637
|
+
const data = isObject(raw) ? raw : {};
|
|
1638
|
+
const errors = collectErrors(raw, transportError, label);
|
|
1639
|
+
const cores = num(data.logicalCores) ?? safeCpuCount();
|
|
1640
|
+
const totalMemoryBytes = num(data.totalMemoryBytes) ?? (() => {
|
|
1641
|
+
try {
|
|
1642
|
+
return os.totalmem();
|
|
1643
|
+
} catch {
|
|
1644
|
+
return null;
|
|
1645
|
+
}
|
|
1646
|
+
})();
|
|
1647
|
+
|
|
1648
|
+
const table = asArray(data.processes)
|
|
1649
|
+
.filter(isObject)
|
|
1650
|
+
.map((process) => {
|
|
1651
|
+
const pid = num(process.pid);
|
|
1652
|
+
const workingSetBytes = num(process.workingSetBytes) ?? num(process.privateWorkingSetBytes) ?? num(process.rssBytes);
|
|
1653
|
+
const memPercent =
|
|
1654
|
+
num(process.memPercent) ??
|
|
1655
|
+
(workingSetBytes !== null && totalMemoryBytes !== null && totalMemoryBytes > 0
|
|
1656
|
+
? round(clamp((workingSetBytes / totalMemoryBytes) * 100, 0, 100), 1)
|
|
1657
|
+
: null);
|
|
1658
|
+
const creationDateMs = num(process.creationDateMs);
|
|
1659
|
+
let elapsed = num(process.elapsed);
|
|
1660
|
+
if (elapsed === null && creationDateMs !== null) {
|
|
1661
|
+
const seconds = Math.floor((Date.now() - creationDateMs) / 1000);
|
|
1662
|
+
elapsed = seconds >= 0 ? seconds : null;
|
|
1663
|
+
}
|
|
1664
|
+
return {
|
|
1665
|
+
pid: pid ?? 0,
|
|
1666
|
+
ppid: num(process.ppid),
|
|
1667
|
+
user: str(process.user),
|
|
1668
|
+
cpu: round(clamp((num(process.cpuPercent) ?? 0) / Math.max(1, cores || 1), 0, 100), 1) ?? 0,
|
|
1669
|
+
memPercent: memPercent ?? 0,
|
|
1670
|
+
rssBytes: workingSetBytes,
|
|
1671
|
+
state: str(process.state),
|
|
1672
|
+
elapsed,
|
|
1673
|
+
command: str(process.command) ?? str(process.name) ?? '',
|
|
1674
|
+
};
|
|
1675
|
+
})
|
|
1676
|
+
.filter((process) => process.pid > 0);
|
|
1677
|
+
|
|
1678
|
+
const sorted = [...table].sort((left, right) => {
|
|
1679
|
+
if (right.cpu !== left.cpu) return right.cpu - left.cpu;
|
|
1680
|
+
return right.memPercent - left.memPercent;
|
|
1681
|
+
});
|
|
1682
|
+
const list = sorted.slice(0, 200);
|
|
1683
|
+
|
|
1684
|
+
/** @type {Map<string, number>} */
|
|
1685
|
+
const counts = new Map();
|
|
1686
|
+
for (const process of table) {
|
|
1687
|
+
const user = process.user ?? 'unknown';
|
|
1688
|
+
counts.set(user, (counts.get(user) ?? 0) + 1);
|
|
1689
|
+
}
|
|
1690
|
+
const byUser = [...counts.entries()]
|
|
1691
|
+
.map(([user, count]) => ({ user, count }))
|
|
1692
|
+
.sort((left, right) => right.count - left.count);
|
|
1693
|
+
|
|
1694
|
+
return {
|
|
1695
|
+
total: num(data.total) ?? table.length,
|
|
1696
|
+
list,
|
|
1697
|
+
byUser,
|
|
1698
|
+
errors,
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/* -------------------------------------------------------------------------- */
|
|
1703
|
+
/* Section builders (PowerShell → cached contract shapes) */
|
|
1704
|
+
/* -------------------------------------------------------------------------- */
|
|
1705
|
+
|
|
1706
|
+
/**
|
|
1707
|
+
* Run a collection script and map its JSON document.
|
|
1708
|
+
* @param {string} scriptName
|
|
1709
|
+
* @param {(raw: any, error?: string|null) => object} mapper
|
|
1710
|
+
* @returns {Promise<object>}
|
|
1711
|
+
*/
|
|
1712
|
+
async function collectWith(scriptName, mapper) {
|
|
1713
|
+
const result = await runScript(scriptName);
|
|
1714
|
+
if (!result.ok) return mapper(null, result.error);
|
|
1715
|
+
try {
|
|
1716
|
+
return mapper(result.data);
|
|
1717
|
+
} catch (error) {
|
|
1718
|
+
// A mapper bug must still yield a contract-shaped, failing section.
|
|
1719
|
+
return mapper(null, `mapper failure: ${error instanceof Error ? error.message : String(error)}`);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
async function buildHardware() {
|
|
1724
|
+
return collectWith('hardware.ps1', mapHardware);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
async function buildSystem() {
|
|
1728
|
+
return collectWith('system.ps1', mapSystem);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
async function buildSoftware() {
|
|
1732
|
+
return collectWith('software.ps1', mapSoftware);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
async function buildNetwork() {
|
|
1736
|
+
return collectWith('network.ps1', mapNetwork);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
async function buildSecurity() {
|
|
1740
|
+
return collectWith('security.ps1', mapSecurity);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
async function buildProcesses() {
|
|
1744
|
+
return collectWith('processes.ps1', mapProcesses);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
const BUILDERS = {
|
|
1748
|
+
hardware: buildHardware,
|
|
1749
|
+
system: buildSystem,
|
|
1750
|
+
software: buildSoftware,
|
|
1751
|
+
network: buildNetwork,
|
|
1752
|
+
security: buildSecurity,
|
|
1753
|
+
processes: buildProcesses,
|
|
1754
|
+
};
|
|
1755
|
+
|
|
1756
|
+
/* -------------------------------------------------------------------------- */
|
|
1757
|
+
/* Metrics */
|
|
1758
|
+
/* -------------------------------------------------------------------------- */
|
|
1759
|
+
|
|
1760
|
+
/** @type {object|null} */
|
|
1761
|
+
let lastMetrics = null;
|
|
1762
|
+
let lastMetricsAt = 0;
|
|
1763
|
+
/** @type {Promise<object>|null} */
|
|
1764
|
+
let metricsPending = null;
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* Best-effort zeroed metrics object (the tick path must never throw).
|
|
1768
|
+
* @returns {object}
|
|
1769
|
+
*/
|
|
1770
|
+
function fallbackMetrics() {
|
|
1771
|
+
return {
|
|
1772
|
+
ts: Date.now(),
|
|
1773
|
+
hostname: safeHostname() ?? 'unknown',
|
|
1774
|
+
osVersion: null,
|
|
1775
|
+
uptimeSec: (() => {
|
|
1776
|
+
try {
|
|
1777
|
+
return Math.floor(os.uptime());
|
|
1778
|
+
} catch {
|
|
1779
|
+
return null;
|
|
1780
|
+
}
|
|
1781
|
+
})(),
|
|
1782
|
+
cpu: {
|
|
1783
|
+
model: null,
|
|
1784
|
+
usagePercent: 0,
|
|
1785
|
+
perCore: [],
|
|
1786
|
+
loadAvg: [],
|
|
1787
|
+
logicalCores: safeCpuCount(),
|
|
1788
|
+
physicalCores: safeCpuCount(),
|
|
1789
|
+
},
|
|
1790
|
+
memory: {
|
|
1791
|
+
totalBytes: (() => {
|
|
1792
|
+
try {
|
|
1793
|
+
return os.totalmem();
|
|
1794
|
+
} catch {
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
})(),
|
|
1798
|
+
usedBytes: null,
|
|
1799
|
+
appBytes: null,
|
|
1800
|
+
wiredBytes: null,
|
|
1801
|
+
compressedBytes: null,
|
|
1802
|
+
cachedBytes: null,
|
|
1803
|
+
freeBytes: (() => {
|
|
1804
|
+
try {
|
|
1805
|
+
return os.freemem();
|
|
1806
|
+
} catch {
|
|
1807
|
+
return null;
|
|
1808
|
+
}
|
|
1809
|
+
})(),
|
|
1810
|
+
swapTotalBytes: null,
|
|
1811
|
+
swapUsedBytes: null,
|
|
1812
|
+
pressure: null,
|
|
1813
|
+
},
|
|
1814
|
+
disk: { totalBytes: null, usedBytes: null, freeBytes: null, readBytesPerSec: 0, writeBytesPerSec: 0 },
|
|
1815
|
+
network: {
|
|
1816
|
+
rxBytesPerSec: 0,
|
|
1817
|
+
txBytesPerSec: 0,
|
|
1818
|
+
totalRxBytes: null,
|
|
1819
|
+
totalTxBytes: null,
|
|
1820
|
+
wifi: null,
|
|
1821
|
+
primaryIpv4: [],
|
|
1822
|
+
defaultInterface: null,
|
|
1823
|
+
},
|
|
1824
|
+
battery: null,
|
|
1825
|
+
thermal: emptyThermal(),
|
|
1826
|
+
processes: { total: 0, top: [] },
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* Build one metrics snapshot: a single PowerShell invocation (OS-specific
|
|
1832
|
+
* facts) in parallel with the cheap `os.cpus()` CPU delta sample.
|
|
1833
|
+
* @returns {Promise<object>}
|
|
1834
|
+
*/
|
|
1835
|
+
async function buildMetrics() {
|
|
1836
|
+
const [result, usage] = await Promise.all([runScript('metrics.ps1'), cpuUsage()]);
|
|
1837
|
+
const ts = Date.now();
|
|
1838
|
+
if (!result.ok) {
|
|
1839
|
+
// Keep the last good sample for continuity (documented); otherwise fall
|
|
1840
|
+
// back to the Node-only facts.
|
|
1841
|
+
if (lastMetrics !== null) return { ...lastMetrics, ts };
|
|
1842
|
+
return jsonSafe(mapMetrics(null, { cpu: usage, now: ts }));
|
|
1843
|
+
}
|
|
1844
|
+
const data = isObject(result.data) ? result.data : {};
|
|
1845
|
+
const rates = {
|
|
1846
|
+
network: rateOf('network', countersOf(data.network?.counters), ts),
|
|
1847
|
+
disk: rateOf('disk', countersOf(data.diskIo), ts),
|
|
1848
|
+
};
|
|
1849
|
+
return jsonSafe(mapMetrics(data, { cpu: usage, rates, now: ts }));
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/* -------------------------------------------------------------------------- */
|
|
1853
|
+
/* Public collectors */
|
|
1854
|
+
/* -------------------------------------------------------------------------- */
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* Start slow section collection in the background (fire-and-forget). Two small
|
|
1858
|
+
* waves keep the number of simultaneous PowerShell processes low; metrics are
|
|
1859
|
+
* primed last so the first UI poll still gets a fresh sample.
|
|
1860
|
+
* @returns {void}
|
|
1861
|
+
*/
|
|
1862
|
+
export function warmup() {
|
|
1863
|
+
const prime = async () => {
|
|
1864
|
+
for (const group of [['system', 'network', 'processes'], ['hardware', 'security', 'software']]) {
|
|
1865
|
+
await Promise.all(group.map((section) => collectSection(section).catch(() => {})));
|
|
1866
|
+
}
|
|
1867
|
+
await collectMetrics().catch(() => {});
|
|
1868
|
+
};
|
|
1869
|
+
Promise.resolve()
|
|
1870
|
+
.then(prime)
|
|
1871
|
+
.catch(() => {});
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* One metrics snapshot for the 2500 ms UI poll. Never throws.
|
|
1876
|
+
* @returns {Promise<object>}
|
|
1877
|
+
*/
|
|
1878
|
+
export async function collectMetrics() {
|
|
1879
|
+
const now = Date.now();
|
|
1880
|
+
if (lastMetrics !== null && now - lastMetricsAt < METRICS_TTL) return lastMetrics;
|
|
1881
|
+
if (metricsPending !== null) return metricsPending;
|
|
1882
|
+
const task = (async () => {
|
|
1883
|
+
try {
|
|
1884
|
+
const value = await buildMetrics();
|
|
1885
|
+
if (value && typeof value === 'object') {
|
|
1886
|
+
lastMetrics = value;
|
|
1887
|
+
lastMetricsAt = Date.now();
|
|
1888
|
+
return value;
|
|
1889
|
+
}
|
|
1890
|
+
return fallbackMetrics();
|
|
1891
|
+
} catch {
|
|
1892
|
+
return fallbackMetrics();
|
|
1893
|
+
}
|
|
1894
|
+
})().finally(() => {
|
|
1895
|
+
if (metricsPending === task) metricsPending = null;
|
|
1896
|
+
});
|
|
1897
|
+
metricsPending = task;
|
|
1898
|
+
return task;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* Build (or serve from cache) one section.
|
|
1903
|
+
*
|
|
1904
|
+
* Unknown section names reject; every PowerShell/data failure is captured in
|
|
1905
|
+
* the returned section's `errors[]` instead of throwing.
|
|
1906
|
+
*
|
|
1907
|
+
* @param {string} section
|
|
1908
|
+
* @param {{fresh?: boolean}} [options]
|
|
1909
|
+
* @returns {Promise<object>}
|
|
1910
|
+
*/
|
|
1911
|
+
export async function collectSection(section, options = {}) {
|
|
1912
|
+
if (!SECTIONS.includes(section)) {
|
|
1913
|
+
throw new Error(`Unknown section: ${String(section)}`);
|
|
1914
|
+
}
|
|
1915
|
+
const fresh = options?.fresh === true;
|
|
1916
|
+
const ttl = SECTION_TTL[section] ?? 30_000;
|
|
1917
|
+
const now = Date.now();
|
|
1918
|
+
const cached = sectionCache.get(section);
|
|
1919
|
+
if (!fresh && cached && now - cached.at < ttl) return cached.value;
|
|
1920
|
+
const pending = sectionInflight.get(section);
|
|
1921
|
+
if (pending) return pending;
|
|
1922
|
+
|
|
1923
|
+
const builder = BUILDERS[section];
|
|
1924
|
+
const task = (async () => {
|
|
1925
|
+
let value;
|
|
1926
|
+
try {
|
|
1927
|
+
value = await builder();
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
value = { errors: [shortErr(error instanceof Error ? error.message : error)] };
|
|
1930
|
+
}
|
|
1931
|
+
const safe = jsonSafe(value);
|
|
1932
|
+
sectionCache.set(section, { value: safe, at: Date.now() });
|
|
1933
|
+
return safe;
|
|
1934
|
+
})().finally(() => {
|
|
1935
|
+
if (sectionInflight.get(section) === task) sectionInflight.delete(section);
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
sectionInflight.set(section, task);
|
|
1939
|
+
return task;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* Full inventory for the panel's initial load and the agent tool.
|
|
1944
|
+
* @param {{sections?: string[], fresh?: boolean}} [options]
|
|
1945
|
+
* @returns {Promise<object>}
|
|
1946
|
+
*/
|
|
1947
|
+
export async function collectInventory(options = {}) {
|
|
1948
|
+
const sectionFilter = Array.isArray(options?.sections) ? options.sections : null;
|
|
1949
|
+
const requested = sectionFilter !== null
|
|
1950
|
+
? SECTIONS.filter((section) => sectionFilter.includes(section))
|
|
1951
|
+
: [...SECTIONS];
|
|
1952
|
+
const fresh = options?.fresh === true;
|
|
1953
|
+
const errors = [];
|
|
1954
|
+
|
|
1955
|
+
const [metrics, ...sectionValues] = await Promise.all([
|
|
1956
|
+
collectMetrics(),
|
|
1957
|
+
...requested.map((section) => collectSection(section, { fresh })),
|
|
1958
|
+
]);
|
|
1959
|
+
/** @type {Record<string, object>} */
|
|
1960
|
+
const sections = {};
|
|
1961
|
+
requested.forEach((section, index) => {
|
|
1962
|
+
sections[section] = sectionValues[index];
|
|
1963
|
+
});
|
|
1964
|
+
if (!metrics || typeof metrics !== 'object') errors.push('metrics unavailable');
|
|
1965
|
+
|
|
1966
|
+
let catalog = [];
|
|
1967
|
+
let history = [];
|
|
1968
|
+
try {
|
|
1969
|
+
catalog = asArray(actionCatalog());
|
|
1970
|
+
} catch (error) {
|
|
1971
|
+
errors.push(`action catalog: ${shortErr(error instanceof Error ? error.message : error)}`);
|
|
1972
|
+
}
|
|
1973
|
+
try {
|
|
1974
|
+
history = asArray(actionHistory());
|
|
1975
|
+
} catch (error) {
|
|
1976
|
+
errors.push(`action history: ${shortErr(error instanceof Error ? error.message : error)}`);
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
return jsonSafe({
|
|
1980
|
+
generatedAt: Date.now(),
|
|
1981
|
+
platform: PLATFORM,
|
|
1982
|
+
hostname: safeHostname() ?? 'unknown',
|
|
1983
|
+
metrics,
|
|
1984
|
+
sections,
|
|
1985
|
+
actions: { catalog, history },
|
|
1986
|
+
errors,
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* Drop cached section data (one section or all of them).
|
|
1992
|
+
* `clearCache('metrics')` drops the fast-metrics cache as well.
|
|
1993
|
+
* @param {string} [section]
|
|
1994
|
+
* @returns {void}
|
|
1995
|
+
*/
|
|
1996
|
+
export function clearCache(section) {
|
|
1997
|
+
if (section === undefined || section === null || section === '') {
|
|
1998
|
+
sectionCache.clear();
|
|
1999
|
+
lastMetrics = null;
|
|
2000
|
+
lastMetricsAt = 0;
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
if (section === 'metrics') {
|
|
2004
|
+
lastMetrics = null;
|
|
2005
|
+
lastMetricsAt = 0;
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
sectionCache.delete(String(section));
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/* -------------------------------------------------------------------------- */
|
|
2012
|
+
/* Test seam */
|
|
2013
|
+
/* -------------------------------------------------------------------------- */
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* Reset all module-level state (caches, deltas, metrics) and restore the real
|
|
2017
|
+
* PowerShell runner. Intended for the offline verification harness only.
|
|
2018
|
+
* @returns {void}
|
|
2019
|
+
*/
|
|
2020
|
+
function resetInternalState() {
|
|
2021
|
+
sectionCache.clear();
|
|
2022
|
+
sectionInflight.clear();
|
|
2023
|
+
rateState.clear();
|
|
2024
|
+
cpuSnapshot = null;
|
|
2025
|
+
lastCpuUsage = { usagePercent: 0, perCore: [] };
|
|
2026
|
+
lastMetrics = null;
|
|
2027
|
+
lastMetricsAt = 0;
|
|
2028
|
+
metricsPending = null;
|
|
2029
|
+
scriptRunner = defaultRunner;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* Stable pure helpers used by `scripts/verify-windows.mjs` and the offline
|
|
2034
|
+
* fixture harness. `setRunner` is the optional test seam for the PowerShell
|
|
2035
|
+
* bridge: it makes the caching/warmup logic testable on any platform.
|
|
2036
|
+
*/
|
|
2037
|
+
export const __test = {
|
|
2038
|
+
asArray,
|
|
2039
|
+
mapMetrics,
|
|
2040
|
+
mapHardware,
|
|
2041
|
+
mapSystem,
|
|
2042
|
+
mapSoftware,
|
|
2043
|
+
mapNetwork,
|
|
2044
|
+
mapSecurity,
|
|
2045
|
+
mapProcesses,
|
|
2046
|
+
cpuUsage,
|
|
2047
|
+
SECTION_TTL,
|
|
2048
|
+
setRunner(fn) {
|
|
2049
|
+
scriptRunner = typeof fn === 'function' ? fn : defaultRunner;
|
|
2050
|
+
},
|
|
2051
|
+
resetRunner() {
|
|
2052
|
+
scriptRunner = defaultRunner;
|
|
2053
|
+
},
|
|
2054
|
+
reset() {
|
|
2055
|
+
resetInternalState();
|
|
2056
|
+
},
|
|
2057
|
+
};
|