@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/src/format.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const KIB = 1024;
|
|
2
|
+
const MIB = 1024 * 1024;
|
|
3
|
+
const GIB = 1024 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {number} bytes
|
|
7
|
+
*/
|
|
8
|
+
export function formatBytes(bytes) {
|
|
9
|
+
const n = Number(bytes);
|
|
10
|
+
if (!Number.isFinite(n) || n < 0) return "n/a";
|
|
11
|
+
if (n >= GIB) return `${(n / GIB).toFixed(1)} GiB`;
|
|
12
|
+
return `${(n / MIB).toFixed(1)} MiB`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {number} used
|
|
17
|
+
* @param {number} total
|
|
18
|
+
*/
|
|
19
|
+
export function formatBytePair(used, total) {
|
|
20
|
+
const t = Number(total);
|
|
21
|
+
const u = Number(used);
|
|
22
|
+
if (!Number.isFinite(t) || t < 0 || !Number.isFinite(u) || u < 0) return "n/a";
|
|
23
|
+
const unit = t >= GIB ? "GiB" : "MiB";
|
|
24
|
+
const div = unit === "GiB" ? GIB : MIB;
|
|
25
|
+
return `${(u / div).toFixed(1)} / ${(t / div).toFixed(1)} ${unit}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Disk throughput. `null` → em dash (first tick).
|
|
30
|
+
* @param {number | null | undefined} bytesPerSec
|
|
31
|
+
*/
|
|
32
|
+
export function formatByteRate(bytesPerSec) {
|
|
33
|
+
if (bytesPerSec == null) return "—";
|
|
34
|
+
const n = Number(bytesPerSec);
|
|
35
|
+
if (!Number.isFinite(n) || n < 0) return "—";
|
|
36
|
+
if (n >= GIB) return `${(n / GIB).toFixed(1)} GiB/s`;
|
|
37
|
+
if (n >= MIB) return `${(n / MIB).toFixed(1)} MiB/s`;
|
|
38
|
+
return `${(n / KIB).toFixed(1)} KiB/s`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Network throughput in decimal Mbps (1 Mbps = 1e6 bit/s).
|
|
43
|
+
* @param {number | null | undefined} bytesPerSec
|
|
44
|
+
*/
|
|
45
|
+
export function formatBitRate(bytesPerSec) {
|
|
46
|
+
if (bytesPerSec == null) return "—";
|
|
47
|
+
const n = Number(bytesPerSec);
|
|
48
|
+
if (!Number.isFinite(n) || n < 0) return "—";
|
|
49
|
+
const mbps = (n * 8) / 1e6;
|
|
50
|
+
return `${mbps.toFixed(1)} Mbps`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {number} percent
|
|
55
|
+
*/
|
|
56
|
+
export function formatPercent(percent) {
|
|
57
|
+
const n = Number(percent);
|
|
58
|
+
if (!Number.isFinite(n)) return "n/a";
|
|
59
|
+
const p = Math.min(100, Math.max(0, Math.round(n)));
|
|
60
|
+
return `${String(p).padStart(3, " ")}%`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {number} seconds
|
|
65
|
+
*/
|
|
66
|
+
export function formatInterval(seconds) {
|
|
67
|
+
const n = Number(seconds);
|
|
68
|
+
if (!Number.isFinite(n)) return "1.0s";
|
|
69
|
+
return `${n.toFixed(1)}s`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {number} physical
|
|
74
|
+
* @param {number} logical
|
|
75
|
+
*/
|
|
76
|
+
export function formatCores(physical, logical) {
|
|
77
|
+
const p = Number(physical);
|
|
78
|
+
const l = Number(logical);
|
|
79
|
+
const ps = Number.isFinite(p) && p > 0 ? String(Math.round(p)) : "?";
|
|
80
|
+
const ls = Number.isFinite(l) && l > 0 ? String(Math.round(l)) : "?";
|
|
81
|
+
return `${ps}P/${ls}L`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatCoresDetail(physical, logical) {
|
|
85
|
+
return formatCores(physical, logical).replace("/", " / ");
|
|
86
|
+
}
|
package/src/history.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { HISTORY_SIZE } from "./bar.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} History
|
|
5
|
+
* @property {number[]} cpu
|
|
6
|
+
* @property {number[]} ram
|
|
7
|
+
* @property {number[]} gpu
|
|
8
|
+
* @property {number[]} dsk
|
|
9
|
+
* @property {number[]} net
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function createHistory() {
|
|
13
|
+
return { cpu: [], ram: [], gpu: [], dsk: [], net: [] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {number[]} buf
|
|
18
|
+
* @param {number | null | undefined} value
|
|
19
|
+
* @param {number} [size]
|
|
20
|
+
*/
|
|
21
|
+
export function pushSample(buf, value, size = HISTORY_SIZE) {
|
|
22
|
+
if (value == null || Number.isNaN(Number(value))) return buf;
|
|
23
|
+
const n = Number(value);
|
|
24
|
+
if (!Number.isFinite(n)) return buf;
|
|
25
|
+
const next = buf.length >= size ? buf.slice(buf.length - size + 1) : buf.slice();
|
|
26
|
+
next.push(n);
|
|
27
|
+
return next;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {History} history
|
|
32
|
+
* @param {import("./sample.js").Snapshot} snap
|
|
33
|
+
* @returns {History}
|
|
34
|
+
*/
|
|
35
|
+
export function appendHistory(history, snap) {
|
|
36
|
+
const cpu = snap.cpu?.percent;
|
|
37
|
+
const ram = snap.ram?.percent;
|
|
38
|
+
const gpu = snap.gpu?.percent;
|
|
39
|
+
const read = snap.disk?.readBps;
|
|
40
|
+
const write = snap.disk?.writeBps;
|
|
41
|
+
const tx = snap.net?.txBps;
|
|
42
|
+
const rx = snap.net?.rxBps;
|
|
43
|
+
let dsk;
|
|
44
|
+
if (read != null || write != null) {
|
|
45
|
+
dsk = (read ?? 0) + (write ?? 0);
|
|
46
|
+
}
|
|
47
|
+
let net;
|
|
48
|
+
if (tx != null || rx != null) {
|
|
49
|
+
net = (tx ?? 0) + (rx ?? 0);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
cpu: pushSample(history.cpu, cpu),
|
|
53
|
+
ram: pushSample(history.ram, ram),
|
|
54
|
+
gpu: pushSample(history.gpu, gpu),
|
|
55
|
+
dsk: pushSample(history.dsk, dsk),
|
|
56
|
+
net: pushSample(history.net, net),
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { helpText, parseArgs } from "./args.js";
|
|
5
|
+
import { runDashboard } from "./dashboard.js";
|
|
6
|
+
import { CliError } from "./errors.js";
|
|
7
|
+
import { restoreTerminal } from "./tty.js";
|
|
8
|
+
|
|
9
|
+
const pkg = JSON.parse(
|
|
10
|
+
readFileSync(
|
|
11
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"),
|
|
12
|
+
"utf8",
|
|
13
|
+
),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
function defaultDeps() {
|
|
17
|
+
return {
|
|
18
|
+
env: process.env,
|
|
19
|
+
version: pkg.version,
|
|
20
|
+
log: (msg) => console.log(msg),
|
|
21
|
+
err: (msg) => console.error(msg),
|
|
22
|
+
write: (s) => process.stdout.write(s),
|
|
23
|
+
isTTY: () => Boolean(process.stdout.isTTY),
|
|
24
|
+
columns: () => process.stdout.columns || 80,
|
|
25
|
+
stdin: process.stdin,
|
|
26
|
+
process,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string[]} argv
|
|
32
|
+
* @param {Partial<ReturnType<typeof defaultDeps>> & { dashboard?: Function }} [overrides]
|
|
33
|
+
* @returns {Promise<number>}
|
|
34
|
+
*/
|
|
35
|
+
export async function main(argv, overrides = {}) {
|
|
36
|
+
const deps = { ...defaultDeps(), ...overrides };
|
|
37
|
+
let args;
|
|
38
|
+
try {
|
|
39
|
+
args = parseArgs(argv);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err instanceof CliError) {
|
|
42
|
+
deps.err(err.message);
|
|
43
|
+
return err.exitCode;
|
|
44
|
+
}
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (args.help) {
|
|
49
|
+
deps.log(helpText());
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
if (args.version) {
|
|
53
|
+
deps.log(deps.version);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!deps.isTTY()) {
|
|
58
|
+
deps.err("yf-vitals needs a terminal.");
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const dash = deps.dashboard || runDashboard;
|
|
63
|
+
return dash(args, deps);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string[]} argv
|
|
68
|
+
*/
|
|
69
|
+
export async function run(argv) {
|
|
70
|
+
let entered = false;
|
|
71
|
+
const wrapWrite = (s) => process.stdout.write(s);
|
|
72
|
+
try {
|
|
73
|
+
const code = await main(argv, {
|
|
74
|
+
write: (s) => {
|
|
75
|
+
entered = entered || s.includes("\u001b[?1049h");
|
|
76
|
+
wrapWrite(s);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
process.exit(typeof code === "number" ? code : 0);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (entered) {
|
|
82
|
+
try {
|
|
83
|
+
restoreTerminal(wrapWrite, process.stdin);
|
|
84
|
+
} catch {
|
|
85
|
+
// last-ditch
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const message = err instanceof CliError ? err.message : err?.message || String(err);
|
|
89
|
+
console.error(message);
|
|
90
|
+
process.exit(err instanceof CliError ? err.exitCode : 1);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/rates.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bytes/s (or any counter/s) from two cumulative counters.
|
|
3
|
+
* First sample returns null. Counter wrap or non-positive elapsed → null.
|
|
4
|
+
*
|
|
5
|
+
* @param {number | null | undefined} prev
|
|
6
|
+
* @param {number | null | undefined} current
|
|
7
|
+
* @param {number | null | undefined} prevTs
|
|
8
|
+
* @param {number | null | undefined} currentTs
|
|
9
|
+
* @returns {number | null}
|
|
10
|
+
*/
|
|
11
|
+
export function rateFromCounters(prev, current, prevTs, currentTs) {
|
|
12
|
+
if (prev == null || current == null || prevTs == null || currentTs == null) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const dtMs = Number(currentTs) - Number(prevTs);
|
|
16
|
+
if (!Number.isFinite(dtMs) || dtMs <= 0) return null;
|
|
17
|
+
const a = Number(prev);
|
|
18
|
+
const b = Number(current);
|
|
19
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || b < a) return null;
|
|
20
|
+
return (b - a) / (dtMs / 1000);
|
|
21
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useAscii } from "./ascii.js";
|
|
2
|
+
import { BAR_WIDTH, bar, barCharset, sparkWidth, sparkline } from "./bar.js";
|
|
3
|
+
import { createColor, stripAnsi } from "./color.js";
|
|
4
|
+
import {
|
|
5
|
+
formatBitRate,
|
|
6
|
+
formatBytePair,
|
|
7
|
+
formatByteRate,
|
|
8
|
+
formatBytes,
|
|
9
|
+
formatCores,
|
|
10
|
+
formatCoresDetail,
|
|
11
|
+
formatInterval,
|
|
12
|
+
formatPercent,
|
|
13
|
+
} from "./format.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} line
|
|
17
|
+
* @param {number} columns
|
|
18
|
+
*/
|
|
19
|
+
export function fitLine(line, columns) {
|
|
20
|
+
const cols = Number(columns);
|
|
21
|
+
if (!Number.isFinite(cols) || cols < 8) return line;
|
|
22
|
+
const plain = stripAnsi(line);
|
|
23
|
+
if (plain.length <= cols) return line;
|
|
24
|
+
if (plain === line) return line.slice(0, cols);
|
|
25
|
+
let out = "";
|
|
26
|
+
let visible = 0;
|
|
27
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
28
|
+
if (line[i] === "\u001b") {
|
|
29
|
+
const end = line.indexOf("m", i);
|
|
30
|
+
if (end === -1) break;
|
|
31
|
+
out += line.slice(i, end + 1);
|
|
32
|
+
i = end;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (visible >= cols) break;
|
|
36
|
+
out += line[i];
|
|
37
|
+
visible += 1;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {import("./sample.js").GpuSample | null | undefined} gpu
|
|
44
|
+
* @param {{ history: number[], ascii: boolean, color: ReturnType<typeof createColor>, sparkW: number }} opts
|
|
45
|
+
* @returns {string[] | null}
|
|
46
|
+
*/
|
|
47
|
+
export function buildGpuRow(gpu, opts) {
|
|
48
|
+
if (gpu == null) return null;
|
|
49
|
+
const { ascii, color, sparkW, history } = opts;
|
|
50
|
+
const cs = barCharset(ascii);
|
|
51
|
+
const pct = gpu.percent;
|
|
52
|
+
const barStr =
|
|
53
|
+
pct == null ? null : bar(pct, BAR_WIDTH, cs);
|
|
54
|
+
const pctStr = pct == null ? "n/a" : formatPercent(pct);
|
|
55
|
+
const mem =
|
|
56
|
+
gpu.used != null && gpu.total != null ? formatBytePair(gpu.used, gpu.total) : "";
|
|
57
|
+
const label = color.green("GPU");
|
|
58
|
+
const head =
|
|
59
|
+
barStr == null
|
|
60
|
+
? `${label} ${pctStr}${mem ? ` ${mem}` : ""}`
|
|
61
|
+
: `${label} [${color.green(barStr)}] ${pctStr}${mem ? ` ${mem}` : ""}`;
|
|
62
|
+
const spark = color.green(sparkline(history, sparkW, { ascii, max: 100 }));
|
|
63
|
+
return [head, ` ${spark}`];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {import("./sample.js").Snapshot} snap
|
|
68
|
+
* @param {import("./history.js").History} history
|
|
69
|
+
* @param {{ columns?: number, env?: NodeJS.ProcessEnv, isTTY?: boolean, intervalSec?: number }} [opts]
|
|
70
|
+
*/
|
|
71
|
+
export function renderFrame(snap, history, opts = {}) {
|
|
72
|
+
const columns = opts.columns ?? 80;
|
|
73
|
+
const env = opts.env ?? {};
|
|
74
|
+
const ascii = useAscii(env);
|
|
75
|
+
const color = createColor({ isTTY: Boolean(opts.isTTY), env });
|
|
76
|
+
const sparkW = sparkWidth(columns);
|
|
77
|
+
const cs = barCharset(ascii);
|
|
78
|
+
const interval = formatInterval(opts.intervalSec ?? 1);
|
|
79
|
+
const cores = formatCores(snap.physical, snap.logical);
|
|
80
|
+
const ramTot = formatBytes(snap.ramTotal);
|
|
81
|
+
const header = [
|
|
82
|
+
"yf-vitals",
|
|
83
|
+
snap.hostname || "localhost",
|
|
84
|
+
snap.osName || "unknown",
|
|
85
|
+
cores,
|
|
86
|
+
ramTot,
|
|
87
|
+
interval,
|
|
88
|
+
].join(" ");
|
|
89
|
+
|
|
90
|
+
/** @type {string[]} */
|
|
91
|
+
const lines = [header, ""];
|
|
92
|
+
|
|
93
|
+
if (snap.cpu) {
|
|
94
|
+
const b = color.cyan(bar(snap.cpu.percent, BAR_WIDTH, cs));
|
|
95
|
+
const detail = formatCoresDetail(snap.physical, snap.logical);
|
|
96
|
+
lines.push(
|
|
97
|
+
`${color.cyan("CPU")} [${b}] ${formatPercent(snap.cpu.percent)} ${detail}`,
|
|
98
|
+
);
|
|
99
|
+
lines.push(` ${color.cyan(sparkline(history.cpu, sparkW, { ascii, max: 100 }))}`);
|
|
100
|
+
} else {
|
|
101
|
+
lines.push(`${color.cyan("CPU")} n/a`);
|
|
102
|
+
lines.push(` ${color.cyan(sparkline(history.cpu, sparkW, { ascii, max: 100 }))}`);
|
|
103
|
+
}
|
|
104
|
+
lines.push("");
|
|
105
|
+
|
|
106
|
+
if (snap.ram) {
|
|
107
|
+
const b = color.magenta(bar(snap.ram.percent, BAR_WIDTH, cs));
|
|
108
|
+
lines.push(
|
|
109
|
+
`${color.magenta("RAM")} [${b}] ${formatPercent(snap.ram.percent)} ${formatBytePair(snap.ram.used, snap.ram.total)}`,
|
|
110
|
+
);
|
|
111
|
+
lines.push(` ${color.magenta(sparkline(history.ram, sparkW, { ascii, max: 100 }))}`);
|
|
112
|
+
} else {
|
|
113
|
+
lines.push(`${color.magenta("RAM")} n/a`);
|
|
114
|
+
lines.push(` ${color.magenta(sparkline(history.ram, sparkW, { ascii, max: 100 }))}`);
|
|
115
|
+
}
|
|
116
|
+
lines.push("");
|
|
117
|
+
|
|
118
|
+
const gpuLines = buildGpuRow(snap.gpu, {
|
|
119
|
+
history: history.gpu,
|
|
120
|
+
ascii,
|
|
121
|
+
color,
|
|
122
|
+
sparkW,
|
|
123
|
+
});
|
|
124
|
+
if (gpuLines) {
|
|
125
|
+
lines.push(...gpuLines);
|
|
126
|
+
lines.push("");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (snap.disk) {
|
|
130
|
+
const r = formatByteRate(snap.disk.readBps);
|
|
131
|
+
const w = formatByteRate(snap.disk.writeBps);
|
|
132
|
+
lines.push(`${color.green("DSK")} R ${r.padStart(11)} W ${w.padStart(11)}`);
|
|
133
|
+
lines.push(` ${color.green(sparkline(history.dsk, sparkW, { ascii }))}`);
|
|
134
|
+
} else {
|
|
135
|
+
lines.push(`${color.green("DSK")} n/a`);
|
|
136
|
+
lines.push(` ${color.green(sparkline(history.dsk, sparkW, { ascii }))}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push("");
|
|
139
|
+
|
|
140
|
+
if (snap.net) {
|
|
141
|
+
const up = formatBitRate(snap.net.txBps);
|
|
142
|
+
const down = formatBitRate(snap.net.rxBps);
|
|
143
|
+
lines.push(`${color.yellow("NET")} ↑ ${up.padStart(10)} ↓ ${down.padStart(10)}`);
|
|
144
|
+
lines.push(` ${color.yellow(sparkline(history.net, sparkW, { ascii }))}`);
|
|
145
|
+
} else {
|
|
146
|
+
lines.push(`${color.yellow("NET")} n/a`);
|
|
147
|
+
lines.push(` ${color.yellow(sparkline(history.net, sparkW, { ascii }))}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push("");
|
|
150
|
+
lines.push("q quit");
|
|
151
|
+
|
|
152
|
+
return lines.map((ln) => fitLine(ln, columns)).join("\n");
|
|
153
|
+
}
|