@yourfam/yf-vitals 0.1.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 +63 -0
- package/bin/yf-vitals.js +5 -0
- package/package.json +50 -0
- package/src/args.js +104 -0
- package/src/ascii.js +12 -0
- package/src/bar.js +83 -0
- package/src/color.js +24 -0
- package/src/dashboard.js +125 -0
- package/src/disk.js +110 -0
- package/src/errors.js +11 -0
- package/src/format.js +86 -0
- package/src/history.js +58 -0
- package/src/index.js +92 -0
- package/src/rates.js +21 -0
- package/src/render.js +153 -0
- package/src/sample.js +411 -0
- package/src/tty.js +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 YourFam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# yf-vitals
|
|
2
|
+
|
|
3
|
+
Live CPU, RAM, disk, and network bars (plus GPU when the OS exposes it) in your terminal.
|
|
4
|
+
|
|
5
|
+
Works on any machine with **Node 20+**. No git repo. No API key. Press **q** to quit.
|
|
6
|
+
|
|
7
|
+
- **Source / README:** https://github.com/YourFam/yf-vitals
|
|
8
|
+
- **npm:** https://www.npmjs.com/package/@yourfam/yf-vitals
|
|
9
|
+
- **Unscoped alias:** https://www.npmjs.com/package/yf-vitals
|
|
10
|
+
- **Issues:** https://github.com/YourFam/yf-vitals/issues
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Node 20+.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx yf-vitals
|
|
18
|
+
npx yf-vitals --low-power
|
|
19
|
+
npx yf-vitals --interval 0.5
|
|
20
|
+
|
|
21
|
+
npm install -g @yourfam/yf-vitals
|
|
22
|
+
yf-vitals
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Friend one-liners: `npx @yourfam/yf-vitals` **and** `npx yf-vitals`.
|
|
26
|
+
|
|
27
|
+
`npx yf-vitals` is the unscoped alias of `@yourfam/yf-vitals` (same CLI, same version). Both are yours.
|
|
28
|
+
|
|
29
|
+
The command is `yf-vitals`. It does not need a git work tree; `cwd` can be anywhere.
|
|
30
|
+
|
|
31
|
+
## What you see
|
|
32
|
+
|
|
33
|
+
One live dashboard. Refresh every **1.0s** (or `--interval`, or **2.0s** with `--low-power`).
|
|
34
|
+
|
|
35
|
+
| Row | Meaning |
|
|
36
|
+
|---|---|
|
|
37
|
+
| **CPU** | Overall utilization 0–100%, plus physical / logical core counts |
|
|
38
|
+
| **RAM** | Percent used, plus used / total (`GiB` / `MiB`) |
|
|
39
|
+
| **DSK** | Read and write **rates** (KiB/s, MiB/s, GiB/s) |
|
|
40
|
+
| **NET** | Send ↑ and receive ↓ in **Mbps** (1 Mbps = 1e6 bit/s) |
|
|
41
|
+
| **GPU** | Utilization and VRAM when telemetry exists; **the row is omitted** when it does not |
|
|
42
|
+
|
|
43
|
+
Header: `yf-vitals` · hostname · OS · `NP/NL` cores · RAM total · tick interval.
|
|
44
|
+
|
|
45
|
+
Each percent row has a 20-cell bar and a sparkline of the last 60 ticks. Disk and net sparklines scale to the **max in that buffer** (rates have no 100% ceiling). CPU / RAM / GPU sparklines are percent 0–100.
|
|
46
|
+
|
|
47
|
+
First disk/net tick may show `—` while counters settle.
|
|
48
|
+
|
|
49
|
+
`NO_COLOR` turns off ANSI. `YF_VITALS_ASCII=1` forces `#`/`-` bars and `_-=#` sparks (also used when `WT_SESSION` is missing and `TERM` looks dumb).
|
|
50
|
+
|
|
51
|
+
Pipes and CI print `yf-vitals needs a terminal.` and exit 1 (no redraw loop).
|
|
52
|
+
|
|
53
|
+
## Flags
|
|
54
|
+
|
|
55
|
+
| Token | Default | Meaning |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| *(none)* | | Live dashboard, 1.0s tick |
|
|
58
|
+
| `--interval <seconds>` | `1` | Tick period, `0.25`–`10`. Also `--interval=1` |
|
|
59
|
+
| `--low-power` | off | Tick **2.0s** (wins over `--interval`) |
|
|
60
|
+
| `--help` / `-h` | | Usage. Exit 0 |
|
|
61
|
+
| `--version` / `-V` | | Package version. Exit 0 |
|
|
62
|
+
|
|
63
|
+
Quit: `q` / `Q` / Ctrl+C. The terminal is restored (cursor on, no leftover alt-screen).
|
package/bin/yf-vitals.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yourfam/yf-vitals",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Live terminal dashboard for CPU, RAM, disk, net, and optional GPU",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"yf-vitals": "bin/yf-vitals.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test test/*.test.js",
|
|
20
|
+
"yf-vitals": "node ./bin/yf-vitals.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"cli",
|
|
24
|
+
"cpu",
|
|
25
|
+
"ram",
|
|
26
|
+
"dashboard",
|
|
27
|
+
"sysmon"
|
|
28
|
+
],
|
|
29
|
+
"author": {
|
|
30
|
+
"name": "YourFam",
|
|
31
|
+
"email": "kamal@yourfam.co",
|
|
32
|
+
"url": "https://github.com/YourFam"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/YourFam/yf-vitals.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/YourFam/yf-vitals/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/YourFam/yf-vitals#readme",
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"picocolors": "^1.1.1",
|
|
48
|
+
"systeminformation": "^5.27.11"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
const MIN_INTERVAL = 0.25;
|
|
4
|
+
const MAX_INTERVAL = 10;
|
|
5
|
+
const DEFAULT_INTERVAL = 1;
|
|
6
|
+
const LOW_POWER_INTERVAL = 2;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} ParsedArgs
|
|
10
|
+
* @property {boolean} help
|
|
11
|
+
* @property {boolean} version
|
|
12
|
+
* @property {number} interval
|
|
13
|
+
* @property {boolean} lowPower
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} flag
|
|
18
|
+
* @param {string | undefined} value
|
|
19
|
+
*/
|
|
20
|
+
function parseIntervalValue(flag, value) {
|
|
21
|
+
if (value == null || value === "" || value.startsWith("-")) {
|
|
22
|
+
throw new CliError(
|
|
23
|
+
`Flag ${flag} requires a number from ${MIN_INTERVAL} to ${MAX_INTERVAL}.\nSee yf-vitals --help`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const n = Number(value);
|
|
27
|
+
if (!Number.isFinite(n) || n < MIN_INTERVAL || n > MAX_INTERVAL) {
|
|
28
|
+
throw new CliError(
|
|
29
|
+
`Flag ${flag} requires a number from ${MIN_INTERVAL} to ${MAX_INTERVAL}.\nSee yf-vitals --help`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return n;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string[]} argv process.argv
|
|
37
|
+
* @returns {ParsedArgs}
|
|
38
|
+
*/
|
|
39
|
+
export function parseArgs(argv) {
|
|
40
|
+
const args = argv.slice(2);
|
|
41
|
+
let help = false;
|
|
42
|
+
let version = false;
|
|
43
|
+
let interval = DEFAULT_INTERVAL;
|
|
44
|
+
let intervalSet = false;
|
|
45
|
+
let lowPower = false;
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
48
|
+
const a = args[i];
|
|
49
|
+
if (a === "--help" || a === "-h") {
|
|
50
|
+
help = true;
|
|
51
|
+
} else if (a === "--version" || a === "-V") {
|
|
52
|
+
version = true;
|
|
53
|
+
} else if (a === "--low-power") {
|
|
54
|
+
lowPower = true;
|
|
55
|
+
} else if (a === "--interval") {
|
|
56
|
+
interval = parseIntervalValue("--interval", args[i + 1]);
|
|
57
|
+
intervalSet = true;
|
|
58
|
+
i += 1;
|
|
59
|
+
} else if (a.startsWith("--interval=")) {
|
|
60
|
+
interval = parseIntervalValue("--interval", a.slice("--interval=".length));
|
|
61
|
+
intervalSet = true;
|
|
62
|
+
} else if (a.startsWith("-")) {
|
|
63
|
+
throw new CliError(`Unknown flag: ${a}\nSee yf-vitals --help`);
|
|
64
|
+
} else {
|
|
65
|
+
throw new CliError(`Unknown argument: ${a}\nSee yf-vitals --help`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (help || version) {
|
|
70
|
+
return {
|
|
71
|
+
help,
|
|
72
|
+
version,
|
|
73
|
+
interval: DEFAULT_INTERVAL,
|
|
74
|
+
lowPower: false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (lowPower) {
|
|
79
|
+
interval = LOW_POWER_INTERVAL;
|
|
80
|
+
} else if (!intervalSet) {
|
|
81
|
+
interval = DEFAULT_INTERVAL;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { help: false, version: false, interval, lowPower };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function helpText() {
|
|
88
|
+
return `yf-vitals — live CPU, RAM, disk, net (and GPU) bars in the terminal
|
|
89
|
+
|
|
90
|
+
Usage:
|
|
91
|
+
yf-vitals
|
|
92
|
+
yf-vitals --low-power
|
|
93
|
+
yf-vitals --interval 0.5
|
|
94
|
+
|
|
95
|
+
--interval <seconds> Tick period (${MIN_INTERVAL}–${MAX_INTERVAL}, default ${DEFAULT_INTERVAL})
|
|
96
|
+
--low-power Tick every ${LOW_POWER_INTERVAL}s (wins over --interval)
|
|
97
|
+
--help, -h This text
|
|
98
|
+
--version, -V Package version
|
|
99
|
+
|
|
100
|
+
q / Q / Ctrl+C quit. GPU row is omitted when the OS has no GPU telemetry.
|
|
101
|
+
Disk rates are MiB/s; network rates are Mbps.
|
|
102
|
+
Disk / net sparklines scale to the max in the last 60 ticks (no 100% ceiling).
|
|
103
|
+
Needs a terminal (no pipes). Node 20+.`;
|
|
104
|
+
}
|
package/src/ascii.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const DUMB_TERM = /^(dumb|unknown)?$/i;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ASCII bars when YF_VITALS_ASCII=1, or when WT_SESSION is missing and TERM looks dumb.
|
|
5
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
6
|
+
*/
|
|
7
|
+
export function useAscii(env = process.env) {
|
|
8
|
+
if (env.YF_VITALS_ASCII === "1") return true;
|
|
9
|
+
const term = env.TERM ?? "";
|
|
10
|
+
const dumb = DUMB_TERM.test(term);
|
|
11
|
+
return !env.WT_SESSION && dumb;
|
|
12
|
+
}
|
package/src/bar.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const BAR_WIDTH = 20;
|
|
2
|
+
export const HISTORY_SIZE = 60;
|
|
3
|
+
export const SPARK_MIN = 16;
|
|
4
|
+
export const SPARK_MAX = 60;
|
|
5
|
+
export const SPARK_GUTTER = 36;
|
|
6
|
+
|
|
7
|
+
export const UNI_BAR_FILLED = "█";
|
|
8
|
+
export const UNI_BAR_EMPTY = "░";
|
|
9
|
+
export const ASC_BAR_FILLED = "#";
|
|
10
|
+
export const ASC_BAR_EMPTY = "-";
|
|
11
|
+
|
|
12
|
+
export const UNI_SPARK = "▁▂▃▄▅▆▇█";
|
|
13
|
+
export const ASC_SPARK = "_-=#";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {number} percent
|
|
17
|
+
* @param {number} width
|
|
18
|
+
* @param {{ filled?: string, empty?: string }} [opts]
|
|
19
|
+
*/
|
|
20
|
+
export function bar(percent, width, opts = {}) {
|
|
21
|
+
const filled = opts.filled ?? UNI_BAR_FILLED;
|
|
22
|
+
const empty = opts.empty ?? UNI_BAR_EMPTY;
|
|
23
|
+
const w = Math.max(0, Math.floor(width));
|
|
24
|
+
const n = Number(percent);
|
|
25
|
+
const p = Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0;
|
|
26
|
+
const fillCount = w === 0 ? 0 : Math.round((p / 100) * w);
|
|
27
|
+
return filled.repeat(fillCount) + empty.repeat(w - fillCount);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {boolean} ascii
|
|
32
|
+
*/
|
|
33
|
+
export function barCharset(ascii) {
|
|
34
|
+
return ascii
|
|
35
|
+
? { filled: ASC_BAR_FILLED, empty: ASC_BAR_EMPTY }
|
|
36
|
+
: { filled: UNI_BAR_FILLED, empty: UNI_BAR_EMPTY };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {number} columns
|
|
41
|
+
*/
|
|
42
|
+
export function sparkWidth(columns) {
|
|
43
|
+
const cols = Number(columns);
|
|
44
|
+
const available = Number.isFinite(cols) && cols > 0 ? cols - SPARK_GUTTER : SPARK_MAX;
|
|
45
|
+
return Math.max(SPARK_MIN, Math.min(SPARK_MAX, available));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {number[]} values
|
|
50
|
+
* @param {number} width
|
|
51
|
+
* @param {{ ascii?: boolean, max?: number }} [opts]
|
|
52
|
+
*/
|
|
53
|
+
export function sparkline(values, width, opts = {}) {
|
|
54
|
+
const w = Math.max(0, Math.floor(width));
|
|
55
|
+
if (w === 0) return "";
|
|
56
|
+
const charset = opts.ascii ? ASC_SPARK : UNI_SPARK;
|
|
57
|
+
const list = Array.isArray(values) ? values : [];
|
|
58
|
+
if (list.length === 0) {
|
|
59
|
+
return " ".repeat(w);
|
|
60
|
+
}
|
|
61
|
+
const slice = list.slice(-w);
|
|
62
|
+
const pad = w - slice.length;
|
|
63
|
+
const explicitMax = opts.max;
|
|
64
|
+
const dataMax = slice.reduce((m, v) => (v > m ? v : m), 0);
|
|
65
|
+
const max = explicitMax != null ? explicitMax : dataMax;
|
|
66
|
+
const chars = slice.map((v) => sparkChar(v, max, charset));
|
|
67
|
+
return " ".repeat(pad) + chars.join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {number} value
|
|
72
|
+
* @param {number} max
|
|
73
|
+
* @param {string} charset
|
|
74
|
+
*/
|
|
75
|
+
function sparkChar(value, max, charset) {
|
|
76
|
+
const n = Number(value);
|
|
77
|
+
if (!Number.isFinite(n) || n <= 0 || !Number.isFinite(max) || max <= 0) {
|
|
78
|
+
return charset[0];
|
|
79
|
+
}
|
|
80
|
+
const ratio = Math.min(1, n / max);
|
|
81
|
+
const idx = Math.round(ratio * (charset.length - 1));
|
|
82
|
+
return charset[idx];
|
|
83
|
+
}
|
package/src/color.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import picocolors from "picocolors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{ isTTY: boolean, env: NodeJS.ProcessEnv }} opts
|
|
5
|
+
*/
|
|
6
|
+
export function createColor(opts) {
|
|
7
|
+
const enabled = Boolean(opts.isTTY) && opts.env.NO_COLOR === undefined;
|
|
8
|
+
const pc = picocolors.createColors(enabled);
|
|
9
|
+
return {
|
|
10
|
+
enabled,
|
|
11
|
+
cyan: (value) => pc.cyan(String(value)),
|
|
12
|
+
magenta: (value) => pc.magenta(String(value)),
|
|
13
|
+
green: (value) => pc.green(String(value)),
|
|
14
|
+
yellow: (value) => pc.yellow(String(value)),
|
|
15
|
+
dim: (value) => pc.dim(String(value)),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} value
|
|
21
|
+
*/
|
|
22
|
+
export function stripAnsi(value) {
|
|
23
|
+
return String(value).replace(/\u001b\[[0-9;]*m/g, "");
|
|
24
|
+
}
|
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { appendHistory, createHistory } from "./history.js";
|
|
2
|
+
import { renderFrame } from "./render.js";
|
|
3
|
+
import { createSampler, mergeLastGood } from "./sample.js";
|
|
4
|
+
import { CLEAR_HOME, enterTerminal, restoreTerminal } from "./tty.js";
|
|
5
|
+
|
|
6
|
+
const CTRL_C = 0x03;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {Buffer | string} chunk
|
|
10
|
+
*/
|
|
11
|
+
function isQuitKey(chunk) {
|
|
12
|
+
if (chunk == null) return false;
|
|
13
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
|
|
14
|
+
if (buf.length === 0) return false;
|
|
15
|
+
if (buf[0] === CTRL_C) return true;
|
|
16
|
+
const s = buf.toString("utf8");
|
|
17
|
+
return s.includes("q") || s.includes("Q");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {number} ms
|
|
22
|
+
* @param {() => boolean} shouldStop
|
|
23
|
+
*/
|
|
24
|
+
export function sleep(ms, shouldStop) {
|
|
25
|
+
const n = Math.max(0, Number(ms) || 0);
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
if (n === 0 || shouldStop()) {
|
|
28
|
+
resolve();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let settled = false;
|
|
32
|
+
const done = () => {
|
|
33
|
+
if (settled) return;
|
|
34
|
+
settled = true;
|
|
35
|
+
clearTimeout(t);
|
|
36
|
+
clearInterval(iv);
|
|
37
|
+
resolve();
|
|
38
|
+
};
|
|
39
|
+
const t = setTimeout(done, n);
|
|
40
|
+
const iv = setInterval(() => {
|
|
41
|
+
if (shouldStop()) done();
|
|
42
|
+
}, 50);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {import("./args.js").ParsedArgs} args
|
|
48
|
+
* @param {object} deps
|
|
49
|
+
*/
|
|
50
|
+
export async function runDashboard(args, deps) {
|
|
51
|
+
const write = deps.write;
|
|
52
|
+
const stdin = deps.stdin;
|
|
53
|
+
let quit = false;
|
|
54
|
+
let restored = false;
|
|
55
|
+
const shouldStop = () => quit || Boolean(deps.shouldStop?.());
|
|
56
|
+
|
|
57
|
+
const restore = () => {
|
|
58
|
+
if (restored) return;
|
|
59
|
+
restored = true;
|
|
60
|
+
restoreTerminal(write, stdin);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
enterTerminal(write, stdin);
|
|
64
|
+
|
|
65
|
+
const onData = (chunk) => {
|
|
66
|
+
if (isQuitKey(chunk)) quit = true;
|
|
67
|
+
};
|
|
68
|
+
const onSig = () => {
|
|
69
|
+
quit = true;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const proc = deps.process ?? process;
|
|
73
|
+
if (stdin && typeof stdin.on === "function") stdin.on("data", onData);
|
|
74
|
+
if (proc && typeof proc.on === "function") {
|
|
75
|
+
proc.on("SIGINT", onSig);
|
|
76
|
+
proc.on("SIGTERM", onSig);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @type {{ sample: Function, close?: Function } | null} */
|
|
80
|
+
let sampler = null;
|
|
81
|
+
try {
|
|
82
|
+
sampler = deps.sampler || createSampler();
|
|
83
|
+
let history = createHistory();
|
|
84
|
+
/** @type {import("./sample.js").Snapshot | null} */
|
|
85
|
+
let last = null;
|
|
86
|
+
|
|
87
|
+
while (!shouldStop()) {
|
|
88
|
+
let snap;
|
|
89
|
+
try {
|
|
90
|
+
snap = await sampler.sample();
|
|
91
|
+
} catch {
|
|
92
|
+
snap = last;
|
|
93
|
+
}
|
|
94
|
+
if (!snap) {
|
|
95
|
+
await (deps.sleep || sleep)(args.interval * 1000, shouldStop);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
snap = mergeLastGood(last, snap);
|
|
99
|
+
last = snap;
|
|
100
|
+
history = appendHistory(history, snap);
|
|
101
|
+
const frame = renderFrame(snap, history, {
|
|
102
|
+
columns: deps.columns(),
|
|
103
|
+
env: deps.env,
|
|
104
|
+
isTTY: true,
|
|
105
|
+
intervalSec: args.interval,
|
|
106
|
+
});
|
|
107
|
+
write(CLEAR_HOME + frame);
|
|
108
|
+
if (shouldStop()) break;
|
|
109
|
+
await (deps.sleep || sleep)(args.interval * 1000, shouldStop);
|
|
110
|
+
}
|
|
111
|
+
return 0;
|
|
112
|
+
} finally {
|
|
113
|
+
try {
|
|
114
|
+
sampler?.close?.();
|
|
115
|
+
} catch {
|
|
116
|
+
// ignore
|
|
117
|
+
}
|
|
118
|
+
if (stdin && typeof stdin.off === "function") stdin.off("data", onData);
|
|
119
|
+
if (proc && typeof proc.off === "function") {
|
|
120
|
+
proc.off("SIGINT", onSig);
|
|
121
|
+
proc.off("SIGTERM", onSig);
|
|
122
|
+
}
|
|
123
|
+
restore();
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/disk.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const PS_DISK = `
|
|
4
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
5
|
+
while (($line = [Console]::In.ReadLine()) -ne $null) {
|
|
6
|
+
if ($line -eq 'q') { break }
|
|
7
|
+
$d = Get-CimInstance -ClassName Win32_PerfRawData_PerfDisk_PhysicalDisk -Filter "Name='_Total'"
|
|
8
|
+
if ($d) { Write-Output ("{0} {1}" -f $d.DiskReadBytesPersec, $d.DiskWriteBytesPersec) }
|
|
9
|
+
else { Write-Output "n n" }
|
|
10
|
+
}
|
|
11
|
+
`.trim();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} line
|
|
15
|
+
* @returns {{ rx: number, wx: number } | null}
|
|
16
|
+
*/
|
|
17
|
+
export function parseDiskCounterLine(line) {
|
|
18
|
+
const m = String(line || "").trim().match(/^(\d+)\s+(\d+)$/);
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
return { rx: Number(m[1]), wx: Number(m[2]) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function encodedCommand(script) {
|
|
24
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Long-lived PowerShell so WMI stays warm (~10ms/tick after first query).
|
|
29
|
+
*/
|
|
30
|
+
export function createWindowsDiskReader() {
|
|
31
|
+
let child = null;
|
|
32
|
+
let buf = "";
|
|
33
|
+
/** @type {{ resolve: (v: { rx: number, wx: number } | null) => void }[]} */
|
|
34
|
+
let pending = [];
|
|
35
|
+
let closed = false;
|
|
36
|
+
|
|
37
|
+
function flushLine(line) {
|
|
38
|
+
const job = pending.shift();
|
|
39
|
+
if (job) job.resolve(parseDiskCounterLine(line));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ensure() {
|
|
43
|
+
if (child || closed) return;
|
|
44
|
+
child = spawn(
|
|
45
|
+
"powershell.exe",
|
|
46
|
+
["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand(PS_DISK)],
|
|
47
|
+
{ windowsHide: true, stdio: ["pipe", "pipe", "ignore"] },
|
|
48
|
+
);
|
|
49
|
+
child.stdout.setEncoding("utf8");
|
|
50
|
+
child.stdout.on("data", (chunk) => {
|
|
51
|
+
buf += chunk;
|
|
52
|
+
let idx;
|
|
53
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
54
|
+
const line = buf.slice(0, idx);
|
|
55
|
+
buf = buf.slice(idx + 1);
|
|
56
|
+
flushLine(line);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
child.on("exit", () => {
|
|
60
|
+
child = null;
|
|
61
|
+
buf = "";
|
|
62
|
+
while (pending.length) {
|
|
63
|
+
pending.shift()?.resolve(null);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
/**
|
|
70
|
+
* @returns {Promise<{ rx: number, wx: number } | null>}
|
|
71
|
+
*/
|
|
72
|
+
read() {
|
|
73
|
+
if (closed) return Promise.resolve(null);
|
|
74
|
+
ensure();
|
|
75
|
+
if (!child || !child.stdin.writable) return Promise.resolve(null);
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const t = setTimeout(() => {
|
|
78
|
+
const i = pending.findIndex((p) => p.resolve === done);
|
|
79
|
+
if (i >= 0) pending.splice(i, 1);
|
|
80
|
+
resolve(null);
|
|
81
|
+
}, 2500);
|
|
82
|
+
const done = (v) => {
|
|
83
|
+
clearTimeout(t);
|
|
84
|
+
resolve(v);
|
|
85
|
+
};
|
|
86
|
+
pending.push({ resolve: done });
|
|
87
|
+
try {
|
|
88
|
+
child.stdin.write("g\n");
|
|
89
|
+
} catch {
|
|
90
|
+
clearTimeout(t);
|
|
91
|
+
resolve(null);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
close() {
|
|
96
|
+
closed = true;
|
|
97
|
+
try {
|
|
98
|
+
child?.stdin.write("q\n");
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
child?.kill();
|
|
104
|
+
} catch {
|
|
105
|
+
// ignore
|
|
106
|
+
}
|
|
107
|
+
child = null;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|