@yourfam/yf-vitals 0.1.1 → 0.3.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/README.md CHANGED
@@ -36,13 +36,16 @@ One live dashboard. Refresh every **1.0s** (or `--interval`, or **2.0s** with `-
36
36
  |---|---|
37
37
  | **CPU** | Overall utilization 0–100%, plus physical / logical core counts |
38
38
  | **RAM** | Percent used, plus used / total (`GiB` / `MiB`) |
39
+ | **USE** | Local hard-disk fill (system volume first). Skips Google Drive, iCloud, network, FAT/USB. Extra local disks as extra lines |
39
40
  | **DSK** | Read and write **rates** (KiB/s, MiB/s, GiB/s) |
40
41
  | **NET** | Send ↑ and receive ↓ in **kbps / Mbps / Gbps** (decimal bits; 1 Mbps = 1e6 bit/s) |
41
42
  | **GPU** | Utilization and VRAM when telemetry exists; **the row is omitted** when it does not |
42
43
 
43
44
  Header: `yf-vitals` · hostname · OS · `NP/NL` cores · RAM total · tick interval.
44
45
 
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
+ Each percent row has a 20-cell bar and a sparkline of the last 60 ticks. Unused spark slots use the lowest tick (`▁` / `_`), not blank space. Bar fill stays the row color below 50%, turns **yellow at 50%**, **red at 80%**. Sparklines stay the row color.
47
+
48
+ Disk and net each show **two** sparks (`R`/`W`, `↑`/`↓`) scaled to the **max of that pair** in the buffer (rates have no 100% ceiling). CPU / RAM / GPU sparks are percent 0–100.
46
49
 
47
50
  First disk/net tick may show `—` while counters settle.
48
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yourfam/yf-vitals",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Live terminal dashboard for CPU, RAM, disk, net, and optional GPU",
5
5
  "type": "module",
6
6
  "bin": {
package/src/args.js CHANGED
@@ -98,7 +98,9 @@ Usage:
98
98
  --version, -V Package version
99
99
 
100
100
  q / Q / Ctrl+C quit. GPU row is omitted when the OS has no GPU telemetry.
101
+ USE is local NTFS/APFS/HFS fill (no Google Drive, iCloud, or network shares).
101
102
  Disk rates are KiB/s–GiB/s; network rates are kbps/Mbps/Gbps.
102
- Disk / net sparklines scale to the max in the last 60 ticks (no 100% ceiling).
103
+ Disk / net sparks are split (R/W, ↑/↓) and scale to the pair max in the last 60 ticks.
104
+ Bar fill turns yellow ≥50% and red ≥80%. Unused spark slots use the lowest tick.
103
105
  Needs a terminal (no pipes). Node 20+.`;
104
106
  }
package/src/bar.js CHANGED
@@ -55,8 +55,9 @@ export function sparkline(values, width, opts = {}) {
55
55
  if (w === 0) return "";
56
56
  const charset = opts.ascii ? ASC_SPARK : UNI_SPARK;
57
57
  const list = Array.isArray(values) ? values : [];
58
+ const floor = charset[0];
58
59
  if (list.length === 0) {
59
- return " ".repeat(w);
60
+ return floor.repeat(w);
60
61
  }
61
62
  const slice = list.slice(-w);
62
63
  const pad = w - slice.length;
@@ -64,7 +65,21 @@ export function sparkline(values, width, opts = {}) {
64
65
  const dataMax = slice.reduce((m, v) => (v > m ? v : m), 0);
65
66
  const max = explicitMax != null ? explicitMax : dataMax;
66
67
  const chars = slice.map((v) => sparkChar(v, max, charset));
67
- return " ".repeat(pad) + chars.join("");
68
+ return floor.repeat(pad) + chars.join("");
69
+ }
70
+
71
+ export const HEAT_WARM = 50;
72
+ export const HEAT_HOT = 80;
73
+
74
+ /**
75
+ * @param {number} percent
76
+ * @returns {"ok" | "warm" | "hot"}
77
+ */
78
+ export function heatLevel(percent) {
79
+ const n = Number(percent);
80
+ if (!Number.isFinite(n) || n < HEAT_WARM) return "ok";
81
+ if (n < HEAT_HOT) return "warm";
82
+ return "hot";
68
83
  }
69
84
 
70
85
  /**
package/src/color.js CHANGED
@@ -12,6 +12,7 @@ export function createColor(opts) {
12
12
  magenta: (value) => pc.magenta(String(value)),
13
13
  green: (value) => pc.green(String(value)),
14
14
  yellow: (value) => pc.yellow(String(value)),
15
+ red: (value) => pc.red(String(value)),
15
16
  dim: (value) => pc.dim(String(value)),
16
17
  };
17
18
  }
package/src/dashboard.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { appendHistory, createHistory } from "./history.js";
2
2
  import { renderFrame } from "./render.js";
3
- import { createSampler, mergeLastGood } from "./sample.js";
3
+ import { createSampler, instantSnapshot, mergeLastGood } from "./sample.js";
4
4
  import { CLEAR_HOME, enterTerminal, restoreTerminal } from "./tty.js";
5
5
 
6
6
  const CTRL_C = 0x03;
@@ -61,6 +61,16 @@ export async function runDashboard(args, deps) {
61
61
  };
62
62
 
63
63
  enterTerminal(write, stdin);
64
+ const first = instantSnapshot();
65
+ write(
66
+ CLEAR_HOME +
67
+ renderFrame(first, createHistory(), {
68
+ columns: deps.columns(),
69
+ env: deps.env,
70
+ isTTY: true,
71
+ intervalSec: args.interval,
72
+ }),
73
+ );
64
74
 
65
75
  const onData = (chunk) => {
66
76
  if (isQuitKey(chunk)) quit = true;
@@ -82,7 +92,7 @@ export async function runDashboard(args, deps) {
82
92
  sampler = deps.sampler || createSampler();
83
93
  let history = createHistory();
84
94
  /** @type {import("./sample.js").Snapshot | null} */
85
- let last = null;
95
+ let last = first;
86
96
 
87
97
  while (!shouldStop()) {
88
98
  let snap;
package/src/disk.js CHANGED
@@ -87,10 +87,14 @@ export function createWindowsDiskReader() {
87
87
  }
88
88
 
89
89
  return {
90
+ start() {
91
+ ensure();
92
+ },
90
93
  /**
94
+ * @param {number} [timeoutMs]
91
95
  * @returns {Promise<ReturnType<typeof parseDiskCounterLine>>}
92
96
  */
93
- read() {
97
+ read(timeoutMs = 400) {
94
98
  if (closed) return Promise.resolve(null);
95
99
  ensure();
96
100
  if (!child || !child.stdin.writable) return Promise.resolve(null);
@@ -99,7 +103,7 @@ export function createWindowsDiskReader() {
99
103
  const i = pending.findIndex((p) => p.resolve === done);
100
104
  if (i >= 0) pending.splice(i, 1);
101
105
  resolve(null);
102
- }, 2500);
106
+ }, timeoutMs);
103
107
  const done = (v) => {
104
108
  clearTimeout(t);
105
109
  resolve(v);
package/src/diskuse.js ADDED
@@ -0,0 +1,103 @@
1
+ const MIN_BYTES = 8 * 1024 * 1024 * 1024;
2
+
3
+ const SKIP_FS =
4
+ /^(fat|fat12|fat16|fat32|msdos|nfs|smbfs|cifs|afp|webdav|fuse|sshfs|osxfuse|macfuse|tmpfs|devfs|autofs|iso9660|udf|overlay|9p)$/i;
5
+
6
+ const SKIP_NAME =
7
+ /google\s*drive|gdrive|icloud|onedrive|dropbox|cloudstorage|cloudmounter|box\.com/i;
8
+
9
+ const SKIP_MOUNT =
10
+ /^\/(System\/Volumes\/(Preboot|VM|Update|Recovery|iOS|hardware|xarts|cryptex)|\.MobileBackups|Volumes\/\.timemachine)/i;
11
+
12
+ const LOCAL_FS = /^(ntfs|refs|apfs|hfs|hfs\+|ext[234]|xfs|btrfs|zfs)$/i;
13
+
14
+ /**
15
+ * @param {{ fs?: string, type?: string, mount?: string, size?: number, used?: number }} row
16
+ */
17
+ export function isLocalCapacityVolume(row) {
18
+ if (!row) return false;
19
+ const type = String(row.type || "");
20
+ const mount = String(row.mount || "");
21
+ const fs = String(row.fs || "");
22
+ const blob = `${type} ${mount} ${fs}`;
23
+ if (SKIP_NAME.test(blob)) return false;
24
+ if (SKIP_FS.test(type)) return false;
25
+ if (SKIP_MOUNT.test(mount)) return false;
26
+ const size = Number(row.size) || 0;
27
+ if (size < MIN_BYTES) return false;
28
+ if (LOCAL_FS.test(type)) return true;
29
+ if (/^[A-Z]:\\?$/i.test(mount || fs) && LOCAL_FS.test(type)) return true;
30
+ if (mount === "/" || mount === "/System/Volumes/Data") return true;
31
+ return false;
32
+ }
33
+
34
+ /**
35
+ * @param {string} mount
36
+ */
37
+ export function shortMount(mount) {
38
+ const m = String(mount || "");
39
+ const drive = m.match(/^([A-Z]:)\\?$/i);
40
+ if (drive) return drive[1].toUpperCase();
41
+ if (m === "/System/Volumes/Data") return "Data";
42
+ if (m === "/") return "/";
43
+ const parts = m.split(/[/\\]/).filter(Boolean);
44
+ return parts[parts.length - 1] || m || "?";
45
+ }
46
+
47
+ /**
48
+ * @param {{ fs?: string, type?: string, mount?: string, size?: number, used?: number, use?: number }} row
49
+ */
50
+ function systemScore(row) {
51
+ const m = String(row.mount || "");
52
+ if (/^C:\\?$/i.test(m)) return 3;
53
+ if (m === "/System/Volumes/Data") return 2;
54
+ if (m === "/") return 1;
55
+ return 0;
56
+ }
57
+
58
+ /**
59
+ * @param {Array<{ fs?: string, type?: string, mount?: string, size?: number, used?: number, use?: number }>} rows
60
+ * @returns {{ percent: number, used: number, total: number, mount: string, others: { percent: number, used: number, total: number, mount: string }[] } | null}
61
+ */
62
+ export function summarizeLocalDisks(rows) {
63
+ const local = (Array.isArray(rows) ? rows : []).filter(isLocalCapacityVolume);
64
+ /** @type {typeof local} */
65
+ const uniq = [];
66
+ for (const r of local.slice().sort((a, b) => systemScore(b) - systemScore(a) || (b.size || 0) - (a.size || 0))) {
67
+ const dupIdx = uniq.findIndex(
68
+ (u) =>
69
+ Math.abs((u.size || 0) - (r.size || 0)) < 1024 * 1024 &&
70
+ Math.abs((u.used || 0) - (r.used || 0)) < 0.01 * (r.size || 1),
71
+ );
72
+ if (dupIdx >= 0) continue;
73
+ uniq.push(r);
74
+ }
75
+ if (!uniq.length) return null;
76
+ const primary = uniq[0];
77
+ const total = Number(primary.size) || 0;
78
+ const used = Number(primary.used) || 0;
79
+ const percent =
80
+ typeof primary.use === "number" && Number.isFinite(primary.use)
81
+ ? primary.use
82
+ : total > 0
83
+ ? (used / total) * 100
84
+ : 0;
85
+ return {
86
+ percent: Math.min(100, Math.max(0, percent)),
87
+ used,
88
+ total,
89
+ mount: shortMount(primary.mount || primary.fs || ""),
90
+ others: uniq.slice(1).map((r) => {
91
+ const t = Number(r.size) || 0;
92
+ const u = Number(r.used) || 0;
93
+ const p =
94
+ typeof r.use === "number" && Number.isFinite(r.use) ? r.use : t > 0 ? (u / t) * 100 : 0;
95
+ return {
96
+ percent: Math.min(100, Math.max(0, p)),
97
+ used: u,
98
+ total: t,
99
+ mount: shortMount(r.mount || r.fs || ""),
100
+ };
101
+ }),
102
+ };
103
+ }
package/src/format.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const KIB = 1024;
2
2
  const MIB = 1024 * 1024;
3
3
  const GIB = 1024 * 1024 * 1024;
4
+ const TIB = 1024 * 1024 * 1024 * 1024;
4
5
 
5
6
  /**
6
7
  * @param {number} bytes
@@ -8,6 +9,7 @@ const GIB = 1024 * 1024 * 1024;
8
9
  export function formatBytes(bytes) {
9
10
  const n = Number(bytes);
10
11
  if (!Number.isFinite(n) || n < 0) return "n/a";
12
+ if (n >= TIB) return `${(n / TIB).toFixed(1)} TiB`;
11
13
  if (n >= GIB) return `${(n / GIB).toFixed(1)} GiB`;
12
14
  return `${(n / MIB).toFixed(1)} MiB`;
13
15
  }
@@ -20,8 +22,8 @@ export function formatBytePair(used, total) {
20
22
  const t = Number(total);
21
23
  const u = Number(used);
22
24
  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
+ const unit = t >= TIB ? "TiB" : t >= GIB ? "GiB" : "MiB";
26
+ const div = unit === "TiB" ? TIB : unit === "GiB" ? GIB : MIB;
25
27
  return `${(u / div).toFixed(1)} / ${(t / div).toFixed(1)} ${unit}`;
26
28
  }
27
29
 
package/src/history.js CHANGED
@@ -5,12 +5,15 @@ import { HISTORY_SIZE } from "./bar.js";
5
5
  * @property {number[]} cpu
6
6
  * @property {number[]} ram
7
7
  * @property {number[]} gpu
8
- * @property {number[]} dsk
9
- * @property {number[]} net
8
+ * @property {number[]} dskR
9
+ * @property {number[]} dskW
10
+ * @property {number[]} netUp
11
+ * @property {number[]} netDn
12
+ * @property {number[]} diskUse
10
13
  */
11
14
 
12
15
  export function createHistory() {
13
- return { cpu: [], ram: [], gpu: [], dsk: [], net: [] };
16
+ return { cpu: [], ram: [], gpu: [], dskR: [], dskW: [], netUp: [], netDn: [], diskUse: [] };
14
17
  }
15
18
 
16
19
  /**
@@ -33,26 +36,14 @@ export function pushSample(buf, value, size = HISTORY_SIZE) {
33
36
  * @returns {History}
34
37
  */
35
38
  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
39
  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),
40
+ cpu: pushSample(history.cpu, snap.cpu?.percent),
41
+ ram: pushSample(history.ram, snap.ram?.percent),
42
+ gpu: pushSample(history.gpu, snap.gpu?.percent),
43
+ dskR: pushSample(history.dskR, snap.disk?.readBps),
44
+ dskW: pushSample(history.dskW, snap.disk?.writeBps),
45
+ netUp: pushSample(history.netUp, snap.net?.txBps),
46
+ netDn: pushSample(history.netDn, snap.net?.rxBps),
47
+ diskUse: pushSample(history.diskUse, snap.diskUse?.percent),
57
48
  };
58
49
  }
package/src/render.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { useAscii } from "./ascii.js";
2
- import { BAR_WIDTH, bar, barCharset, sparkWidth, sparkline } from "./bar.js";
2
+ import {
3
+ BAR_WIDTH,
4
+ bar,
5
+ barCharset,
6
+ heatLevel,
7
+ sparkWidth,
8
+ sparkline,
9
+ } from "./bar.js";
3
10
  import { createColor, stripAnsi } from "./color.js";
4
11
  import {
5
12
  formatBitRate,
@@ -39,6 +46,58 @@ export function fitLine(line, columns) {
39
46
  return out;
40
47
  }
41
48
 
49
+ /**
50
+ * @param {ReturnType<typeof createColor>} color
51
+ * @param {number} percent
52
+ * @param {"cyan" | "magenta" | "green"} row
53
+ */
54
+ export function heatPaint(color, percent, row) {
55
+ const level = heatLevel(percent);
56
+ if (level === "hot") return color.red;
57
+ if (level === "warm") return color.yellow;
58
+ return color[row];
59
+ }
60
+
61
+ /**
62
+ * Percent bar: fill takes heat color, empty stays dim. Sparkline stays the row color.
63
+ *
64
+ * @param {number} percent
65
+ * @param {ReturnType<typeof createColor>} color
66
+ * @param {"cyan" | "magenta" | "green"} row
67
+ * @param {boolean} ascii
68
+ */
69
+ export function paintPercentBar(percent, color, row, ascii) {
70
+ const cs = barCharset(ascii);
71
+ const raw = bar(percent, BAR_WIDTH, cs);
72
+ const filled = raw.replaceAll(cs.empty, "").length;
73
+ const fill = raw.slice(0, filled);
74
+ const empty = raw.slice(filled);
75
+ const paint = heatPaint(color, percent, row);
76
+ return `[${paint(fill)}${color.dim(empty)}]`;
77
+ }
78
+
79
+ /**
80
+ * Two labeled sparks on one line, sharing a max so the pair is comparable.
81
+ *
82
+ * @param {string} leftLabel
83
+ * @param {number[]} leftVals
84
+ * @param {string} rightLabel
85
+ * @param {number[]} rightVals
86
+ * @param {number} sparkW
87
+ * @param {boolean} ascii
88
+ */
89
+ export function splitSparkLine(leftLabel, leftVals, rightLabel, rightVals, sparkW, ascii) {
90
+ const leftTag = `${leftLabel} `;
91
+ const mid = ` ${rightLabel} `;
92
+ const overhead = leftTag.length + mid.length;
93
+ const inner = Math.max(overhead + 8, Number(sparkW) || 16);
94
+ const each = Math.max(4, Math.floor((inner - overhead) / 2));
95
+ const pairMax = Math.max(0, ...leftVals, ...rightVals);
96
+ const left = sparkline(leftVals, each, { ascii, max: pairMax || undefined });
97
+ const right = sparkline(rightVals, each, { ascii, max: pairMax || undefined });
98
+ return `${leftTag}${left}${mid}${right}`;
99
+ }
100
+
42
101
  /**
43
102
  * @param {import("./sample.js").GpuSample | null | undefined} gpu
44
103
  * @param {{ history: number[], ascii: boolean, color: ReturnType<typeof createColor>, sparkW: number }} opts
@@ -47,18 +106,15 @@ export function fitLine(line, columns) {
47
106
  export function buildGpuRow(gpu, opts) {
48
107
  if (gpu == null) return null;
49
108
  const { ascii, color, sparkW, history } = opts;
50
- const cs = barCharset(ascii);
51
109
  const pct = gpu.percent;
52
- const barStr =
53
- pct == null ? null : bar(pct, BAR_WIDTH, cs);
54
110
  const pctStr = pct == null ? "n/a" : formatPercent(pct);
55
111
  const mem =
56
112
  gpu.used != null && gpu.total != null ? formatBytePair(gpu.used, gpu.total) : "";
57
113
  const label = color.green("GPU");
58
114
  const head =
59
- barStr == null
115
+ pct == null
60
116
  ? `${label} ${pctStr}${mem ? ` ${mem}` : ""}`
61
- : `${label} [${color.green(barStr)}] ${pctStr}${mem ? ` ${mem}` : ""}`;
117
+ : `${label} ${paintPercentBar(pct, color, "green", ascii)} ${pctStr}${mem ? ` ${mem}` : ""}`;
62
118
  const spark = color.green(sparkline(history, sparkW, { ascii, max: 100 }));
63
119
  return [head, ` ${spark}`];
64
120
  }
@@ -74,7 +130,6 @@ export function renderFrame(snap, history, opts = {}) {
74
130
  const ascii = useAscii(env);
75
131
  const color = createColor({ isTTY: Boolean(opts.isTTY), env });
76
132
  const sparkW = sparkWidth(columns);
77
- const cs = barCharset(ascii);
78
133
  const interval = formatInterval(opts.intervalSec ?? 1);
79
134
  const cores = formatCores(snap.physical, snap.logical);
80
135
  const ramTot = formatBytes(snap.ramTotal);
@@ -91,10 +146,9 @@ export function renderFrame(snap, history, opts = {}) {
91
146
  const lines = [header, ""];
92
147
 
93
148
  if (snap.cpu) {
94
- const b = color.cyan(bar(snap.cpu.percent, BAR_WIDTH, cs));
95
149
  const detail = formatCoresDetail(snap.physical, snap.logical);
96
150
  lines.push(
97
- `${color.cyan("CPU")} [${b}] ${formatPercent(snap.cpu.percent)} ${detail}`,
151
+ `${color.cyan("CPU")} ${paintPercentBar(snap.cpu.percent, color, "cyan", ascii)} ${formatPercent(snap.cpu.percent)} ${detail}`,
98
152
  );
99
153
  lines.push(` ${color.cyan(sparkline(history.cpu, sparkW, { ascii, max: 100 }))}`);
100
154
  } else {
@@ -104,9 +158,8 @@ export function renderFrame(snap, history, opts = {}) {
104
158
  lines.push("");
105
159
 
106
160
  if (snap.ram) {
107
- const b = color.magenta(bar(snap.ram.percent, BAR_WIDTH, cs));
108
161
  lines.push(
109
- `${color.magenta("RAM")} [${b}] ${formatPercent(snap.ram.percent)} ${formatBytePair(snap.ram.used, snap.ram.total)}`,
162
+ `${color.magenta("RAM")} ${paintPercentBar(snap.ram.percent, color, "magenta", ascii)} ${formatPercent(snap.ram.percent)} ${formatBytePair(snap.ram.used, snap.ram.total)}`,
110
163
  );
111
164
  lines.push(` ${color.magenta(sparkline(history.ram, sparkW, { ascii, max: 100 }))}`);
112
165
  } else {
@@ -115,6 +168,19 @@ export function renderFrame(snap, history, opts = {}) {
115
168
  }
116
169
  lines.push("");
117
170
 
171
+ if (snap.diskUse) {
172
+ lines.push(
173
+ `${color.green("USE")} ${paintPercentBar(snap.diskUse.percent, color, "green", ascii)} ${formatPercent(snap.diskUse.percent)} ${formatBytePair(snap.diskUse.used, snap.diskUse.total)} ${snap.diskUse.mount}`,
174
+ );
175
+ lines.push(` ${color.green(sparkline(history.diskUse, sparkW, { ascii, max: 100 }))}`);
176
+ for (const extra of snap.diskUse.others || []) {
177
+ lines.push(
178
+ ` ${extra.mount} ${formatPercent(extra.percent).trim()} ${formatBytePair(extra.used, extra.total)}`,
179
+ );
180
+ }
181
+ }
182
+ if (snap.diskUse) lines.push("");
183
+
118
184
  const gpuLines = buildGpuRow(snap.gpu, {
119
185
  history: history.gpu,
120
186
  ascii,
@@ -130,10 +196,14 @@ export function renderFrame(snap, history, opts = {}) {
130
196
  const r = formatByteRate(snap.disk.readBps);
131
197
  const w = formatByteRate(snap.disk.writeBps);
132
198
  lines.push(`${color.green("DSK")} R ${r.padStart(11)} W ${w.padStart(11)}`);
133
- lines.push(` ${color.green(sparkline(history.dsk, sparkW, { ascii }))}`);
199
+ lines.push(
200
+ ` ${color.green(splitSparkLine("R", history.dskR, "W", history.dskW, sparkW, ascii))}`,
201
+ );
134
202
  } else {
135
203
  lines.push(`${color.green("DSK")} n/a`);
136
- lines.push(` ${color.green(sparkline(history.dsk, sparkW, { ascii }))}`);
204
+ lines.push(
205
+ ` ${color.green(splitSparkLine("R", history.dskR, "W", history.dskW, sparkW, ascii))}`,
206
+ );
137
207
  }
138
208
  lines.push("");
139
209
 
@@ -141,10 +211,14 @@ export function renderFrame(snap, history, opts = {}) {
141
211
  const up = formatBitRate(snap.net.txBps);
142
212
  const down = formatBitRate(snap.net.rxBps);
143
213
  lines.push(`${color.yellow("NET")} ↑ ${up.padStart(12)} ↓ ${down.padStart(12)}`);
144
- lines.push(` ${color.yellow(sparkline(history.net, sparkW, { ascii }))}`);
214
+ lines.push(
215
+ ` ${color.yellow(splitSparkLine("↑", history.netUp, "↓", history.netDn, sparkW, ascii))}`,
216
+ );
145
217
  } else {
146
218
  lines.push(`${color.yellow("NET")} n/a`);
147
- lines.push(` ${color.yellow(sparkline(history.net, sparkW, { ascii }))}`);
219
+ lines.push(
220
+ ` ${color.yellow(splitSparkLine("↑", history.netUp, "↓", history.netDn, sparkW, ascii))}`,
221
+ );
148
222
  }
149
223
  lines.push("");
150
224
  lines.push("q quit");
package/src/sample.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import os from "node:os";
2
2
  import { createWindowsDiskReader } from "./disk.js";
3
+ import { summarizeLocalDisks } from "./diskuse.js";
3
4
  import { rateFromCounters } from "./rates.js";
4
5
 
5
6
  /**
@@ -36,6 +37,7 @@ import { rateFromCounters } from "./rates.js";
36
37
  * @property {DiskSample | null} disk
37
38
  * @property {NetSample | null} net
38
39
  * @property {GpuSample | null} gpu
40
+ * @property {{ percent: number, used: number, total: number, mount: string, others: { percent: number, used: number, total: number, mount: string }[] } | null} diskUse
39
41
  */
40
42
 
41
43
  /**
@@ -82,6 +84,43 @@ function windowsLabel(release) {
82
84
  return "Windows";
83
85
  }
84
86
 
87
+ /**
88
+ * Sync snapshot from `os` only — first paint must not wait on PowerShell / nvidia-smi.
89
+ * @returns {Snapshot}
90
+ */
91
+ export function instantSnapshot() {
92
+ const logical = Math.max(os.cpus().length, 1);
93
+ const total = os.totalmem();
94
+ const free = os.freemem();
95
+ const used = Math.max(0, total - free);
96
+ return {
97
+ ts: Date.now(),
98
+ hostname: os.hostname(),
99
+ osName: shortOsName(null),
100
+ physical: logical,
101
+ logical,
102
+ ramTotal: total,
103
+ cpu: null,
104
+ ram: total > 0 ? { percent: Math.min(100, (used / total) * 100), used, total } : null,
105
+ disk: { readBps: null, writeBps: null },
106
+ net: { txBps: null, rxBps: null },
107
+ gpu: null,
108
+ diskUse: null,
109
+ };
110
+ }
111
+
112
+ function ramFromOs(ramTotal) {
113
+ const total = ramTotal || os.totalmem();
114
+ const free = os.freemem();
115
+ const used = Math.max(0, total - free);
116
+ if (total <= 0) return null;
117
+ return {
118
+ percent: Math.min(100, Math.max(0, (used / total) * 100)),
119
+ used,
120
+ total,
121
+ };
122
+ }
123
+
85
124
  /**
86
125
  * GPU memory fields in systeminformation are sometimes MiB, sometimes bytes.
87
126
  * @param {number | null} n
@@ -139,6 +178,7 @@ export function mergeLastGood(last, next) {
139
178
  disk: null,
140
179
  net: null,
141
180
  gpu: null,
181
+ diskUse: null,
142
182
  };
143
183
  return {
144
184
  ts: next.ts,
@@ -152,6 +192,7 @@ export function mergeLastGood(last, next) {
152
192
  disk: mergePair(next.disk, base.disk, "readBps", "writeBps"),
153
193
  net: mergePair(next.net, base.net, "txBps", "rxBps"),
154
194
  gpu: next.gpu ?? base.gpu,
195
+ diskUse: next.diskUse ?? base.diskUse,
155
196
  };
156
197
  }
157
198
 
@@ -248,9 +289,29 @@ export function createSampler(si) {
248
289
  let prevTs = null;
249
290
  /** @type {Snapshot | null} */
250
291
  let last = null;
251
- /** @type {Promise<{ hostname: string, osName: string, physical: number, logical: number, ramTotal: number }> | null} */
252
- let staticP = null;
253
- const winDisk = process.platform === "win32" ? createWindowsDiskReader() : null;
292
+ /** @type {{ hostname: string, osName: string, physical: number, logical: number, ramTotal: number }} */
293
+ let meta = (() => {
294
+ const s = instantSnapshot();
295
+ return {
296
+ hostname: s.hostname,
297
+ osName: s.osName,
298
+ physical: s.physical,
299
+ logical: s.logical,
300
+ ramTotal: s.ramTotal,
301
+ };
302
+ })();
303
+ let staticStarted = false;
304
+ /** @type {GpuSample | null} */
305
+ let gpuCache = null;
306
+ let gpuStarted = false;
307
+ /** @type {Snapshot["diskUse"]} */
308
+ let diskUseCache = null;
309
+ let diskUseStarted = false;
310
+ const win32 = process.platform === "win32";
311
+ const winDisk = win32 ? createWindowsDiskReader() : null;
312
+ winDisk?.start();
313
+ let winReady = false;
314
+ prevCpu = cpuIdleTotal(os.cpus());
254
315
 
255
316
  async function loadSi() {
256
317
  if (si) return si;
@@ -258,43 +319,27 @@ export function createSampler(si) {
258
319
  return mod.default ?? mod;
259
320
  }
260
321
 
261
- async function loadStatic() {
262
- const lib = await loadSi();
263
- const hostname = os.hostname();
264
- let osName = shortOsName(null);
265
- let logical = Math.max(os.cpus().length, 1);
266
- let physical = logical;
267
- let ramTotal = os.totalmem();
268
- try {
269
- const info = await lib.osInfo();
270
- osName = shortOsName(info);
271
- } catch {
272
- // keep fallback
273
- }
274
- try {
275
- const cpu = await lib.cpu();
276
- if (cpu?.physicalCores) physical = cpu.physicalCores;
277
- if (cpu?.cores) logical = cpu.cores;
278
- } catch {
279
- // keep os.cpus() counts
280
- }
281
- try {
282
- const mem = await lib.mem();
283
- if (mem?.total) ramTotal = mem.total;
284
- } catch {
285
- // keep os.totalmem
286
- }
287
- return { hostname, osName, physical, logical, ramTotal };
322
+ function kickStatic(lib) {
323
+ if (staticStarted) return;
324
+ staticStarted = true;
325
+ (async () => {
326
+ try {
327
+ const info = await lib.osInfo();
328
+ meta = { ...meta, osName: shortOsName(info) };
329
+ } catch {
330
+ // keep fallback
331
+ }
332
+ try {
333
+ const cpu = await lib.cpu();
334
+ if (cpu?.physicalCores) meta = { ...meta, physical: cpu.physicalCores };
335
+ if (cpu?.cores) meta = { ...meta, logical: cpu.cores };
336
+ } catch {
337
+ // keep os.cpus() counts
338
+ }
339
+ })();
288
340
  }
289
341
 
290
- async function sampleCpu(lib) {
291
- try {
292
- const load = await lib.currentLoad();
293
- const pct = num(load?.currentLoad);
294
- if (pct != null) return { percent: Math.min(100, Math.max(0, pct)) };
295
- } catch {
296
- // fall through to os.cpus() idle-delta
297
- }
342
+ async function sampleCpu() {
298
343
  const curr = cpuIdleTotal(os.cpus());
299
344
  const pct = cpuPercentFromDelta(prevCpu, curr);
300
345
  prevCpu = curr;
@@ -303,6 +348,7 @@ export function createSampler(si) {
303
348
  }
304
349
 
305
350
  async function sampleRam(lib, ramTotal) {
351
+ if (win32) return ramFromOs(ramTotal);
306
352
  try {
307
353
  const mem = await lib.mem();
308
354
  const total = num(mem?.total) ?? ramTotal;
@@ -310,22 +356,14 @@ export function createSampler(si) {
310
356
  let used = null;
311
357
  if (total != null && available != null) used = Math.max(0, total - available);
312
358
  else used = num(mem?.used) ?? num(mem?.active);
313
- if (used == null || total == null || total <= 0) return null;
359
+ if (used == null || total == null || total <= 0) return ramFromOs(ramTotal);
314
360
  return {
315
361
  percent: Math.min(100, Math.max(0, (used / total) * 100)),
316
362
  used,
317
363
  total,
318
364
  };
319
365
  } catch {
320
- const total = ramTotal || os.totalmem();
321
- const free = os.freemem();
322
- const used = total - free;
323
- if (total <= 0) return null;
324
- return {
325
- percent: Math.min(100, Math.max(0, (used / total) * 100)),
326
- used,
327
- total,
328
- };
366
+ return ramFromOs(ramTotal);
329
367
  }
330
368
  }
331
369
 
@@ -336,13 +374,13 @@ export function createSampler(si) {
336
374
  async function sampleDisk(lib, ts, win) {
337
375
  let rx = win ? num(win.rx) : null;
338
376
  let wx = win ? num(win.wx) : null;
339
- if (rx == null && wx == null) {
377
+ if (rx == null && wx == null && !win32) {
340
378
  try {
341
379
  const fs = await lib.fsStats();
342
380
  rx = num(fs?.rx);
343
381
  wx = num(fs?.wx);
344
382
  } catch {
345
- // Windows fsStats is always null
383
+ // ignore
346
384
  }
347
385
  }
348
386
  if (rx == null && wx == null) return { readBps: null, writeBps: null };
@@ -359,7 +397,7 @@ export function createSampler(si) {
359
397
  async function sampleNet(lib, ts, win) {
360
398
  let rx = win ? num(win.netRx) : null;
361
399
  let tx = win ? num(win.netTx) : null;
362
- if (rx == null && tx == null) {
400
+ if (rx == null && tx == null && !win32) {
363
401
  try {
364
402
  const stats = await lib.networkStats("*");
365
403
  const summed = sumNetBytes(Array.isArray(stats) ? stats : stats ? [stats] : []);
@@ -375,13 +413,36 @@ export function createSampler(si) {
375
413
  return { txBps, rxBps };
376
414
  }
377
415
 
378
- async function sampleGpu(lib) {
379
- try {
380
- const g = await lib.graphics();
381
- return pickGpu(g?.controllers || []);
382
- } catch {
383
- return null;
384
- }
416
+ function kickGpu(lib) {
417
+ if (gpuStarted) return;
418
+ gpuStarted = true;
419
+ lib
420
+ .graphics()
421
+ .then((g) => {
422
+ gpuCache = pickGpu(g?.controllers || []);
423
+ })
424
+ .catch(() => {
425
+ gpuCache = null;
426
+ })
427
+ .finally(() => {
428
+ gpuStarted = false;
429
+ });
430
+ }
431
+
432
+ function kickDiskUse(lib) {
433
+ if (diskUseStarted) return;
434
+ diskUseStarted = true;
435
+ lib
436
+ .fsSize()
437
+ .then((rows) => {
438
+ diskUseCache = summarizeLocalDisks(rows);
439
+ })
440
+ .catch(() => {})
441
+ .finally(() => {
442
+ setTimeout(() => {
443
+ diskUseStarted = false;
444
+ }, 15000);
445
+ });
385
446
  }
386
447
 
387
448
  return {
@@ -390,16 +451,17 @@ export function createSampler(si) {
390
451
  */
391
452
  async sample() {
392
453
  const lib = await loadSi();
393
- if (!staticP) staticP = loadStatic();
394
- const meta = await staticP;
395
- const win = winDisk ? await winDisk.read().catch(() => null) : null;
454
+ kickStatic(lib);
455
+ kickGpu(lib);
456
+ kickDiskUse(lib);
457
+ const win = winDisk ? await winDisk.read(winReady ? 400 : 50).catch(() => null) : null;
458
+ if (win) winReady = true;
396
459
  const ts = Date.now();
397
- const [cpu, ram, disk, net, gpu] = await Promise.all([
398
- sampleCpu(lib).catch(() => last?.cpu ?? null),
460
+ const [cpu, ram, disk, net] = await Promise.all([
461
+ sampleCpu().catch(() => last?.cpu ?? null),
399
462
  sampleRam(lib, meta.ramTotal).catch(() => last?.ram ?? null),
400
463
  sampleDisk(lib, ts, win).catch(() => last?.disk ?? { readBps: null, writeBps: null }),
401
464
  sampleNet(lib, ts, win).catch(() => last?.net ?? { txBps: null, rxBps: null }),
402
- sampleGpu(lib).catch(() => last?.gpu ?? null),
403
465
  ]);
404
466
  prevTs = ts;
405
467
  const snap = mergeLastGood(last, {
@@ -409,7 +471,8 @@ export function createSampler(si) {
409
471
  ram,
410
472
  disk,
411
473
  net,
412
- gpu,
474
+ gpu: gpuCache,
475
+ diskUse: diskUseCache,
413
476
  });
414
477
  last = snap;
415
478
  return snap;