@zeluizr/lattice 1.0.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/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/MANIFESTO.md +37 -0
- package/README.md +139 -0
- package/dist/app.js +196 -0
- package/dist/cli.js +116 -0
- package/dist/collectors/disks.js +96 -0
- package/dist/collectors/git.js +89 -0
- package/dist/collectors/gpu.js +28 -0
- package/dist/collectors/power.js +105 -0
- package/dist/collectors/sensors.js +80 -0
- package/dist/collectors/system.js +71 -0
- package/dist/collectors/tokens.js +185 -0
- package/dist/collectors/types.js +2 -0
- package/dist/collectors/vtex.js +37 -0
- package/dist/components/LanguageSelect.js +25 -0
- package/dist/components/Panel.js +6 -0
- package/dist/config.js +42 -0
- package/dist/format.js +56 -0
- package/dist/i18n/en.js +76 -0
- package/dist/i18n/es.js +75 -0
- package/dist/i18n/index.js +38 -0
- package/dist/i18n/pt-BR.js +75 -0
- package/dist/icons.js +50 -0
- package/dist/theme.js +34 -0
- package/native/build.sh +23 -0
- package/native/smc.c +151 -0
- package/package.json +74 -0
- package/prebuilds/darwin-arm64/lattice-smc +0 -0
- package/scripts/setup-sudoers.sh +29 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
|
|
5
|
+
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [1.0.0] — 2026-06-24
|
|
8
|
+
|
|
9
|
+
First open-source release as **lattice** — a full rewrite of the project
|
|
10
|
+
previously known as `commente.me`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Node.js / TypeScript + Ink rewrite.** The dashboard is now a proper CLI,
|
|
14
|
+
distributed on npm and runnable with `npx @zeluizr/lattice` or
|
|
15
|
+
`npm i -g @zeluizr/lattice` (command: `lattice`).
|
|
16
|
+
- **Per-disk panel.** One row per real mount — `/` and every volume under
|
|
17
|
+
`/Volumes` — with live read/write throughput (from IOKit block-storage byte
|
|
18
|
+
counters via `ioreg`, no sudo) and space used.
|
|
19
|
+
- **Git panel.** Scans a folder of repos (default: the parent of the current
|
|
20
|
+
directory, or `--repos <dir>`) and shows each repo's current branch with its
|
|
21
|
+
state — clean/dirty and commits ahead/behind upstream. Hidden when empty.
|
|
22
|
+
- **Internationalization** — English, Español and Português (Brasil). Chosen on
|
|
23
|
+
first run, persisted in `~/.config/lattice/config.json`, switchable with
|
|
24
|
+
`--lang`.
|
|
25
|
+
- **Native SMC helper** (`lattice-smc`, IOKit in C, shipped prebuilt) for CPU/GPU
|
|
26
|
+
temperatures and fan speeds without sudo, with graceful fallback.
|
|
27
|
+
- **Six Dracula Pro themes** selectable with `--theme`.
|
|
28
|
+
- Live panels: CPU, memory/swap, GPU, temperature/fans, network, per-disk I/O &
|
|
29
|
+
usage, AI token cost (today), git branches per repo, VTEX status, and top
|
|
30
|
+
processes.
|
|
31
|
+
- Flags: `--no-power`, `--no-vtex`, `--repos`, `--interval`, `--procs`,
|
|
32
|
+
`--icons`, `--lang`, `--theme`.
|
|
33
|
+
- Hotkeys: `q` quit, `p` pause, `+`/`-` refresh speed.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- Documentation and UI are now in English (plus es / pt-BR at runtime); the
|
|
37
|
+
project was previously Portuguese-only.
|
|
38
|
+
- Power collection uses a streaming `powermetrics` subprocess parsed in Node.
|
|
39
|
+
- lattice is a focused, local-only monitor: there is no embedded chat and no
|
|
40
|
+
network calls — every metric is read from the machine.
|
|
41
|
+
|
|
42
|
+
### Migration
|
|
43
|
+
- The previous Python/Textual implementation is preserved in git under the
|
|
44
|
+
`v0-python` tag.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jose Luiz Rodrigues
|
|
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/MANIFESTO.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# The lattice manifesto
|
|
2
|
+
|
|
3
|
+
Every machine hums with signals — cores spiking, watts flowing, silicon warming
|
|
4
|
+
and cooling in a rhythm you rarely see. **lattice makes that rhythm visible.**
|
|
5
|
+
|
|
6
|
+
### 1. Your machine, your data.
|
|
7
|
+
Every metric is read locally and stays local. No telemetry, no accounts, no
|
|
8
|
+
cloud — nothing ever leaves your Mac.
|
|
9
|
+
|
|
10
|
+
### 2. Built for the metal it runs on.
|
|
11
|
+
lattice is not a generic monitor with a Mac coat of paint. It speaks IOKit, SMC,
|
|
12
|
+
`ioreg` and `powermetrics` natively — tuned for Apple Silicon, from the M1 to
|
|
13
|
+
whatever comes next.
|
|
14
|
+
|
|
15
|
+
### 3. The terminal deserves beauty.
|
|
16
|
+
A dashboard you stare at all day should be a pleasure to look at. Real colors,
|
|
17
|
+
live sparklines, considered typography. Function and form are not opposites.
|
|
18
|
+
|
|
19
|
+
### 4. Every disk, every volume.
|
|
20
|
+
Storage isn't one number. lattice shows each disk on its own — `/` and every
|
|
21
|
+
external volume under `/Volumes` — with live read/write and space used, so you
|
|
22
|
+
see exactly which one is working.
|
|
23
|
+
|
|
24
|
+
### 5. Fast, small, out of the way.
|
|
25
|
+
A monitor that hogs the resources it measures has failed. lattice stays light.
|
|
26
|
+
|
|
27
|
+
### 6. Open and yours to bend.
|
|
28
|
+
MIT-licensed, readable, hackable. Fork it, theme it, extend it.
|
|
29
|
+
|
|
30
|
+
### 7. In your language.
|
|
31
|
+
English, Español, Português — because good tools shouldn't assume where you're
|
|
32
|
+
from.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
*lattice is the ordered structure beneath the chip, made legible.*
|
|
37
|
+
**Watch your machine think.**
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# ◇ lattice
|
|
4
|
+
|
|
5
|
+
**A real-time terminal dashboard for macOS Apple Silicon.**
|
|
6
|
+
|
|
7
|
+
GPU · power · temps & fans · per-disk I/O · network · memory · processes ·
|
|
8
|
+
AI token cost. A focused monitor — every metric read locally, nothing leaves
|
|
9
|
+
your Mac.
|
|
10
|
+
|
|
11
|
+
[](https://www.npmjs.com/package/@zeluizr/lattice)
|
|
12
|
+
[](./LICENSE)
|
|
13
|
+
[](https://nodejs.org)
|
|
14
|
+
[](#requirements)
|
|
15
|
+
|
|
16
|
+
*Watch your machine think.* — [read the manifesto](./MANIFESTO.md)
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Install & run
|
|
23
|
+
|
|
24
|
+
No install needed — run it straight from npm:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx @zeluizr/lattice
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or install it globally so you can just type `lattice`:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm i -g @zeluizr/lattice
|
|
34
|
+
lattice
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
On first run, lattice asks for your language (English · Español · Português) and
|
|
38
|
+
remembers it. Watts need `sudo` (see below); everything else works without it.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
lattice # full dashboard (asks for sudo once, for watts)
|
|
44
|
+
lattice --no-power # skip sudo; CPU/GPU/RAM/disk/net/temps only
|
|
45
|
+
lattice --no-vtex # hide the VTEX panel (for non-VTEX users)
|
|
46
|
+
lattice --repos ~/code # git branches for the repos in ~/code
|
|
47
|
+
lattice --interval 2 # refresh every 2s
|
|
48
|
+
lattice --procs 12 # show 12 top processes
|
|
49
|
+
lattice --icons emoji # nerd | emoji | none
|
|
50
|
+
lattice --lang es # en | es | pt-BR (persists)
|
|
51
|
+
lattice --theme blade # pro | blade | buffy | lincoln | morbius | van-helsing
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Flag | Default | Description |
|
|
55
|
+
|------|---------|-------------|
|
|
56
|
+
| `--no-power` | off | Skip `powermetrics`/sudo (no watts) |
|
|
57
|
+
| `--no-vtex` | off | Hide the VTEX panel (for non-VTEX users) |
|
|
58
|
+
| `--repos` | *(parent of cwd)* | Folder of git repos to show branches for |
|
|
59
|
+
| `--interval`, `-i` | `1` | Refresh interval in seconds |
|
|
60
|
+
| `--procs`, `-n` | `8` | Number of top processes |
|
|
61
|
+
| `--icons` | `nerd` | Icon style: `nerd`, `emoji`, `none` |
|
|
62
|
+
| `--lang` | *(asked)* | `en`, `es`, `pt-BR` |
|
|
63
|
+
| `--theme` | `pro` | Dracula Pro variant |
|
|
64
|
+
|
|
65
|
+
### Hotkeys
|
|
66
|
+
|
|
67
|
+
| Key | Action |
|
|
68
|
+
|-----|--------|
|
|
69
|
+
| `q` | quit |
|
|
70
|
+
| `p` | pause / resume |
|
|
71
|
+
| `+` / `-` | faster / slower refresh |
|
|
72
|
+
|
|
73
|
+
## What it shows
|
|
74
|
+
|
|
75
|
+
| Panel | Source | Needs sudo |
|
|
76
|
+
|-------|--------|:----------:|
|
|
77
|
+
| GPU usage & memory | `ioreg` (IOAccelerator) | — |
|
|
78
|
+
| CPU, RAM, swap, network, processes | system APIs | — |
|
|
79
|
+
| Per-disk I/O & usage — `/` and every `/Volumes/*` | `ioreg` + system APIs | — |
|
|
80
|
+
| Temperatures & fans | SMC via IOKit (native helper) | — |
|
|
81
|
+
| Power (watts), GPU freq, thermal pressure | `powermetrics` | **yes** |
|
|
82
|
+
| AI tokens & cost (today) | Claude Code logs (`~/.claude`) | — |
|
|
83
|
+
| Git branches — current branch + dirty/ahead·behind per repo | `git status` | — |
|
|
84
|
+
| VTEX status | VTEX CLI configstore | — |
|
|
85
|
+
|
|
86
|
+
Temperatures and fans read straight from the SMC, so they work **without sudo**.
|
|
87
|
+
Desktops (Mac mini / Studio) simply report no battery.
|
|
88
|
+
|
|
89
|
+
The **disks** panel breaks activity out per mount — one row each for `/` and
|
|
90
|
+
every volume under `/Volumes` — with live read/write throughput and space used,
|
|
91
|
+
so you can see exactly which disk is busy.
|
|
92
|
+
|
|
93
|
+
The **git** panel scans a folder of repositories (by default the parent of the
|
|
94
|
+
current directory, or pass `--repos <dir>`) and shows each repo's current branch
|
|
95
|
+
with its state — clean/dirty and commits ahead/behind upstream. It hides itself
|
|
96
|
+
when the folder has no repos.
|
|
97
|
+
|
|
98
|
+
## Languages
|
|
99
|
+
|
|
100
|
+
lattice ships in **English**, **Español** and **Português (Brasil)**. It asks on
|
|
101
|
+
first run, stores your choice in `~/.config/lattice/config.json`, and you can
|
|
102
|
+
switch any time with `--lang`.
|
|
103
|
+
|
|
104
|
+
## Themes
|
|
105
|
+
|
|
106
|
+
Six Dracula Pro variants — `pro` (default), `blade`, `buffy`, `lincoln`,
|
|
107
|
+
`morbius`, `van-helsing`. Switch with `--theme <name>` (persisted). Best in a
|
|
108
|
+
truecolor terminal (Warp, iTerm2, Ghostty, kitty). For Nerd Font icons, use a
|
|
109
|
+
patched font like MesloLGS NF — or pass `--icons emoji` / `--icons none`.
|
|
110
|
+
|
|
111
|
+
## Passwordless watts (optional)
|
|
112
|
+
|
|
113
|
+
To skip the sudo prompt for `powermetrics`:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
sudo bash scripts/setup-sudoers.sh
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This adds `/etc/sudoers.d/lattice-powermetrics` allowing passwordless
|
|
120
|
+
`powermetrics` for your user.
|
|
121
|
+
|
|
122
|
+
## Requirements
|
|
123
|
+
|
|
124
|
+
- **macOS on Apple Silicon** (M1/M2/M3/M4…). Intel Macs and other OSes are not
|
|
125
|
+
supported.
|
|
126
|
+
- **Node.js ≥ 18.**
|
|
127
|
+
- A truecolor terminal; optionally a Nerd Font for icons.
|
|
128
|
+
|
|
129
|
+
## Contributing
|
|
130
|
+
|
|
131
|
+
Issues and PRs welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md). Adding a new
|
|
132
|
+
language is just one file in `src/i18n/`.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT © Jose Luiz Rodrigues. See [LICENSE](./LICENSE).
|
|
137
|
+
|
|
138
|
+
> Previously released as **commente.me** (Python / Textual). The Node/TypeScript
|
|
139
|
+
> rewrite is tagged from `v0-python` onward — see [CHANGELOG](./CHANGELOG.md).
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
import { Panel } from "./components/Panel.js";
|
|
5
|
+
import { SystemCollector } from "./collectors/system.js";
|
|
6
|
+
import { DisksCollector } from "./collectors/disks.js";
|
|
7
|
+
import { GitCollector } from "./collectors/git.js";
|
|
8
|
+
import { readGpu } from "./collectors/gpu.js";
|
|
9
|
+
import { readSensors } from "./collectors/sensors.js";
|
|
10
|
+
import { PowerCollector } from "./collectors/power.js";
|
|
11
|
+
import { TokenCollector } from "./collectors/tokens.js";
|
|
12
|
+
import { readVtex } from "./collectors/vtex.js";
|
|
13
|
+
import { coreCell, fmtTok, humanBytes, humanRate, sparkline, statusLevel } from "./format.js";
|
|
14
|
+
const MAX_HIST = 60;
|
|
15
|
+
const push = (h, v) => [...h, v].slice(-MAX_HIST);
|
|
16
|
+
function timeStr() {
|
|
17
|
+
const d = new Date();
|
|
18
|
+
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
19
|
+
}
|
|
20
|
+
export function App(props) {
|
|
21
|
+
const { t, pal, icon, usePower, useVtex, gitDir, topN } = props;
|
|
22
|
+
const { exit } = useApp();
|
|
23
|
+
const [sys, setSys] = useState(null);
|
|
24
|
+
const [disks, setDisks] = useState(null);
|
|
25
|
+
const [git, setGit] = useState(null);
|
|
26
|
+
const [gpu, setGpu] = useState(null);
|
|
27
|
+
const [sensors, setSensors] = useState(null);
|
|
28
|
+
const [power, setPower] = useState(null);
|
|
29
|
+
const [tokens, setTokens] = useState(null);
|
|
30
|
+
const [vtex, setVtex] = useState(null);
|
|
31
|
+
const [hCpu, setHCpu] = useState([]);
|
|
32
|
+
const [hMem, setHMem] = useState([]);
|
|
33
|
+
const [hNet, setHNet] = useState([]);
|
|
34
|
+
const [hGpu, setHGpu] = useState([]);
|
|
35
|
+
const [hTemp, setHTemp] = useState([]);
|
|
36
|
+
const [paused, setPaused] = useState(false);
|
|
37
|
+
const [interval, setIntervalState] = useState(props.interval);
|
|
38
|
+
const [now, setNow] = useState(timeStr());
|
|
39
|
+
const [cols, setCols] = useState(process.stdout.columns || 100);
|
|
40
|
+
const sysRef = useRef(null);
|
|
41
|
+
const disksRef = useRef(null);
|
|
42
|
+
const gitRef = useRef(null);
|
|
43
|
+
const tokRef = useRef(null);
|
|
44
|
+
const powerRef = useRef(null);
|
|
45
|
+
const pausedRef = useRef(false);
|
|
46
|
+
const mounted = useRef(true);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
pausedRef.current = paused;
|
|
49
|
+
}, [paused]);
|
|
50
|
+
// ----- init collectors + power subprocess + clock -------------------------
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
mounted.current = true;
|
|
53
|
+
sysRef.current = new SystemCollector(topN);
|
|
54
|
+
disksRef.current = new DisksCollector();
|
|
55
|
+
gitRef.current = new GitCollector(gitDir);
|
|
56
|
+
tokRef.current = new TokenCollector();
|
|
57
|
+
if (usePower) {
|
|
58
|
+
powerRef.current = new PowerCollector(Math.round(props.interval * 1000));
|
|
59
|
+
powerRef.current.start();
|
|
60
|
+
}
|
|
61
|
+
const clock = setInterval(() => mounted.current && setNow(timeStr()), 1000);
|
|
62
|
+
const onResize = () => setCols(process.stdout.columns || 100);
|
|
63
|
+
process.stdout.on("resize", onResize);
|
|
64
|
+
return () => {
|
|
65
|
+
mounted.current = false;
|
|
66
|
+
clearInterval(clock);
|
|
67
|
+
process.stdout.off("resize", onResize);
|
|
68
|
+
powerRef.current?.stop();
|
|
69
|
+
};
|
|
70
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
71
|
+
}, []);
|
|
72
|
+
// ----- main refresh loop --------------------------------------------------
|
|
73
|
+
const refreshData = useCallback(async () => {
|
|
74
|
+
if (pausedRef.current || !sysRef.current || !disksRef.current)
|
|
75
|
+
return;
|
|
76
|
+
const [s, d, g, st] = await Promise.all([
|
|
77
|
+
sysRef.current.read(),
|
|
78
|
+
disksRef.current.read(),
|
|
79
|
+
readGpu(),
|
|
80
|
+
readSensors(),
|
|
81
|
+
]);
|
|
82
|
+
const p = powerRef.current ? powerRef.current.read() : null;
|
|
83
|
+
if (!mounted.current)
|
|
84
|
+
return;
|
|
85
|
+
setSys(s);
|
|
86
|
+
setDisks(d);
|
|
87
|
+
setGpu(g);
|
|
88
|
+
setSensors(st);
|
|
89
|
+
setPower(p);
|
|
90
|
+
setHCpu((h) => push(h, s.cpuTotal));
|
|
91
|
+
setHMem((h) => push(h, s.memPercent));
|
|
92
|
+
setHNet((h) => push(h, (s.netRecvBps + s.netSentBps) / 1024 / 1024));
|
|
93
|
+
setHGpu((h) => push(h, g.utilPct ?? 0));
|
|
94
|
+
const tv = st.cpuTemp ?? st.gpuTemp;
|
|
95
|
+
if (tv != null)
|
|
96
|
+
setHTemp((h) => push(h, tv));
|
|
97
|
+
}, []);
|
|
98
|
+
const refreshAux = useCallback(async () => {
|
|
99
|
+
if (pausedRef.current || !tokRef.current)
|
|
100
|
+
return;
|
|
101
|
+
const [tk, vx, gt] = await Promise.all([
|
|
102
|
+
tokRef.current.read(),
|
|
103
|
+
useVtex ? readVtex() : Promise.resolve(null),
|
|
104
|
+
gitRef.current ? gitRef.current.read() : Promise.resolve(null),
|
|
105
|
+
]);
|
|
106
|
+
if (!mounted.current)
|
|
107
|
+
return;
|
|
108
|
+
setTokens(tk);
|
|
109
|
+
setVtex(vx);
|
|
110
|
+
setGit(gt);
|
|
111
|
+
}, [useVtex]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
refreshData();
|
|
114
|
+
const id = setInterval(refreshData, Math.max(250, interval * 1000));
|
|
115
|
+
return () => clearInterval(id);
|
|
116
|
+
}, [interval, refreshData]);
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
refreshAux();
|
|
119
|
+
const id = setInterval(refreshAux, 3000);
|
|
120
|
+
return () => clearInterval(id);
|
|
121
|
+
}, [refreshAux]);
|
|
122
|
+
useInput((input) => {
|
|
123
|
+
if (input === "q") {
|
|
124
|
+
powerRef.current?.stop();
|
|
125
|
+
exit();
|
|
126
|
+
}
|
|
127
|
+
else if (input === "p") {
|
|
128
|
+
setPaused((p) => !p);
|
|
129
|
+
}
|
|
130
|
+
else if (input === "+" || input === "=") {
|
|
131
|
+
setIntervalState((i) => Math.max(0.25, i / 2));
|
|
132
|
+
}
|
|
133
|
+
else if (input === "-" || input === "_") {
|
|
134
|
+
setIntervalState((i) => Math.min(10, i * 2));
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
// ----- derived helpers ----------------------------------------------------
|
|
138
|
+
const m = 1; // marginRight per panel
|
|
139
|
+
const col2 = Math.max(24, Math.floor((cols - 2 * m) / 2));
|
|
140
|
+
const col3 = Math.max(18, Math.floor((cols - 3 * m) / 3));
|
|
141
|
+
const fullW = Math.max(40, cols - m);
|
|
142
|
+
const inner = (w) => Math.max(6, w - 4); // minus border (2) + padding (2)
|
|
143
|
+
const w2 = inner(col2);
|
|
144
|
+
const w3 = inner(col3);
|
|
145
|
+
const statColor = (lvl) => lvl === "ok" ? pal.green : lvl === "warn" ? pal.yellow : pal.red;
|
|
146
|
+
const Stat = ({ value, warn, crit, metric }) => {
|
|
147
|
+
const lvl = statusLevel(value, warn, crit);
|
|
148
|
+
return (_jsxs(Text, { color: statColor(lvl), children: ["\u25CF ", t(`status.${metric}.${lvl}`)] }));
|
|
149
|
+
};
|
|
150
|
+
const caption = (h, fmt) => {
|
|
151
|
+
if (!h.length)
|
|
152
|
+
return t("spark.collecting");
|
|
153
|
+
const lo = Math.min(...h);
|
|
154
|
+
const hi = Math.max(...h);
|
|
155
|
+
const body = lo === hi ? fmt(lo) : `${fmt(lo)}–${fmt(hi)}`;
|
|
156
|
+
return t("spark.lastMin", { range: body });
|
|
157
|
+
};
|
|
158
|
+
// ----- values -------------------------------------------------------------
|
|
159
|
+
const cpu = sys?.cpuTotal ?? 0;
|
|
160
|
+
const cores = sys?.cpuPer ?? [];
|
|
161
|
+
const memPct = sys?.memPercent ?? 0;
|
|
162
|
+
const ct = sensors?.cpuTemp ?? null;
|
|
163
|
+
const gt = sensors?.gpuTemp ?? null;
|
|
164
|
+
const util = gpu?.utilPct ?? 0;
|
|
165
|
+
const diskRows = disks?.disks ?? [];
|
|
166
|
+
const gitRepos = git?.repos ?? [];
|
|
167
|
+
const tokTotal = tokens ? tokens.input + tokens.output + tokens.cacheW + tokens.cacheR : 0;
|
|
168
|
+
const modelItems = Object.entries(tokens?.byModel ?? {}).sort((a, b) => b[1].cost - a[1].cost);
|
|
169
|
+
const modelParts = modelItems.slice(0, 2).map(([k, v]) => `${k} $${v.cost.toFixed(2)}`);
|
|
170
|
+
const modelExtra = modelItems.length > 2 ? ` +${modelItems.length - 2}` : "";
|
|
171
|
+
const web = tokens && (tokens.webSearch || tokens.webFetch) ? ` · web ${tokens.webSearch}/${tokens.webFetch}` : "";
|
|
172
|
+
const models = modelParts.length ? modelParts.join(" · ") + modelExtra + web : t("tokens.none");
|
|
173
|
+
const powerParts = [];
|
|
174
|
+
if (power && (power.cpuW != null || power.gpuW != null))
|
|
175
|
+
powerParts.push(`${icon("power")} ${(power.cpuW ?? 0).toFixed(1)}+${(power.gpuW ?? 0).toFixed(1)}W`);
|
|
176
|
+
else if (usePower)
|
|
177
|
+
powerParts.push(`${icon("power")} ${t("temp.waitingSudo")}`);
|
|
178
|
+
if (sensors?.fans?.length)
|
|
179
|
+
powerParts.push(`${icon("fan")} ${sensors.fans[0].rpm.toFixed(0)} rpm`);
|
|
180
|
+
const tempLine2 = powerParts.length ? powerParts.join(" · ") : t("temp.needsSudo");
|
|
181
|
+
return (_jsxs(Box, { flexDirection: "column", width: cols, children: [_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsx(Text, { color: pal.purple, bold: true, children: "\u25C7 lattice" }), _jsx(Text, { color: pal.comment, children: paused ? t("paused") : t("subtitle") }), _jsxs(Text, { color: pal.comment, children: [icon("clock"), " ", now] })] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("cpu")} ${t("panel.cpu")}`, color: pal.cyan, width: col2, children: [_jsxs(Text, { wrap: "truncate", children: [t("cpu.usage"), ": ", _jsxs(Text, { bold: true, children: [cpu.toFixed(0), "%"] }), " ", _jsx(Stat, { value: cpu, warn: 60, crit: 85, metric: "cpu" })] }), _jsxs(Text, { wrap: "truncate", children: [t("cpu.cores"), ": ", cores.length, " ", t("cpu.perCore"), ": ", cores.map(coreCell).join("")] }), _jsx(Text, { color: pal.cyan, wrap: "truncate", children: sparkline(hCpu, w2) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hCpu, (v) => `${v.toFixed(0)}%`) })] }), _jsxs(Panel, { title: `${icon("mem")} ${t("panel.mem")}`, color: pal.green, width: col2, children: [_jsxs(Text, { wrap: "truncate", children: [t("mem.ram"), ": ", _jsxs(Text, { bold: true, children: [memPct.toFixed(0), "%"] }), " ", _jsx(Stat, { value: memPct, warn: 75, crit: 90, metric: "mem" })] }), _jsx(Text, { wrap: "truncate", children: t("mem.used", {
|
|
182
|
+
used: humanBytes(sys?.memUsed),
|
|
183
|
+
total: humanBytes(sys?.memTotal),
|
|
184
|
+
swap: (sys?.swapPercent ?? 0).toFixed(0),
|
|
185
|
+
}) }), _jsx(Text, { color: pal.green, wrap: "truncate", children: sparkline(hMem, w2) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hMem, (v) => `${v.toFixed(0)}%`) })] })] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("temp")} ${t("panel.temp")}`, color: pal.orange, width: col3, children: [_jsx(Text, { wrap: "truncate", children: ct != null && gt != null ? (_jsxs(_Fragment, { children: [ct.toFixed(0), "\u00B0 / ", gt.toFixed(0), "\u00B0", " ", _jsx(Stat, { value: Math.max(ct, gt), warn: 65, crit: 80, metric: "temp" })] })) : ct != null || gt != null ? (_jsxs(_Fragment, { children: [(ct ?? gt ?? 0).toFixed(0), "\u00B0C", " ", _jsx(Stat, { value: ct ?? gt ?? 0, warn: 65, crit: 80, metric: "temp" })] })) : (_jsx(Text, { color: pal.comment, children: t("temp.unavailable") })) }), _jsx(Text, { wrap: "truncate", children: tempLine2 }), _jsx(Text, { color: pal.orange, wrap: "truncate", children: sparkline(hTemp, w3) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hTemp, (v) => `${v.toFixed(0)}°`) })] }), _jsxs(Panel, { title: `${icon("net")} ${t("panel.net")}`, color: pal.cyan, width: col3, children: [_jsxs(Text, { wrap: "truncate", children: ["\u2193 ", humanRate(sys?.netRecvBps)] }), _jsxs(Text, { wrap: "truncate", children: ["\u2191 ", humanRate(sys?.netSentBps)] }), _jsx(Text, { color: pal.cyan, wrap: "truncate", children: sparkline(hNet, w3) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hNet, (v) => humanRate(v * 1024 * 1024)) })] }), _jsxs(Panel, { title: `${icon("gpu")} ${t("panel.gpu")}`, color: pal.purple, width: col3, children: [_jsxs(Text, { wrap: "truncate", children: [t("gpu.usage"), ": ", _jsxs(Text, { bold: true, children: [util, "%"] }), " ", _jsx(Stat, { value: util, warn: 60, crit: 85, metric: "gpu" })] }), _jsx(Text, { wrap: "truncate", children: t("gpu.mem", { used: humanBytes(gpu?.memUsedBytes), alloc: humanBytes(gpu?.memAllocBytes) }) }), _jsx(Text, { color: pal.purple, wrap: "truncate", children: sparkline(hGpu, w3) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hGpu, (v) => `${v.toFixed(0)}%`) })] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.cyan, paddingX: 1, marginRight: 1, width: fullW, children: [_jsxs(Text, { color: pal.cyan, bold: true, children: [icon("disk"), " ", t("panel.disks")] }), _jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("disks.mount").padEnd(22), t("disks.read").padEnd(12), t("disks.write").padEnd(12), t("disks.usage")] }), diskRows.length === 0 ? (_jsx(Text, { color: pal.comment, children: t("spark.collecting") })) : (diskRows.map((d) => {
|
|
186
|
+
const lvl = statusLevel(d.usePercent, 80, 92);
|
|
187
|
+
return (_jsxs(Text, { wrap: "truncate", children: [d.mount.slice(0, 21).padEnd(22), humanRate(d.readBps).padEnd(12), humanRate(d.writeBps).padEnd(12), _jsxs(Text, { color: statColor(lvl), children: ["\u25CF ", d.usePercent.toFixed(0), "%"] }), " ", _jsxs(Text, { color: pal.comment, children: ["(", humanBytes(d.usedBytes), "/", humanBytes(d.sizeBytes), ")"] })] }, d.mount));
|
|
188
|
+
}))] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("tokens")} ${t("panel.tokens")}`, color: pal.pink, width: useVtex ? col2 : fullW, children: [_jsx(Text, { wrap: "truncate", children: t("tokens.spent", { cost: (tokens?.cost ?? 0).toFixed(2), messages: tokens?.messages ?? 0 }) }), _jsx(Text, { wrap: "truncate", children: t("tokens.tokens", {
|
|
189
|
+
total: fmtTok(tokTotal),
|
|
190
|
+
input: fmtTok(tokens?.input),
|
|
191
|
+
output: fmtTok(tokens?.output),
|
|
192
|
+
}) }), _jsx(Text, { wrap: "truncate", children: t("tokens.cache", { cw: fmtTok(tokens?.cacheW), cr: fmtTok(tokens?.cacheR) }) }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("tokens.byModel", { models }) })] }), useVtex && (_jsx(Panel, { title: `${icon("vtex")} ${t("panel.vtex")}`, color: pal.purple, width: col2, children: !vtex ? (_jsx(Text, { color: pal.comment, children: t("spark.collecting") })) : !vtex.installed ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.red, children: t("vtex.notInstalled") })] }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("vtex.install") })] })) : vtex.loggedIn ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.green, children: t("vtex.connected") })] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.account"), ": ", _jsx(Text, { bold: true, children: vtex.account })] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.user"), ": ", vtex.login || "—"] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.workspace"), ": ", vtex.workspace || "master"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.yellow, children: t("vtex.notConnected") })] }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("vtex.signin") })] })) }))] }), gitRepos.length > 0 && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.green, paddingX: 1, marginRight: 1, width: fullW, children: [_jsxs(Text, { color: pal.green, bold: true, wrap: "truncate", children: [icon("git"), " ", t("panel.git"), git?.truncated ? _jsx(Text, { color: pal.comment, children: " (+)" }) : null] }), _jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("git.repo").padEnd(20), t("git.branch").padEnd(22), t("git.state")] }), gitRepos.map((r) => {
|
|
193
|
+
const label = r.detached ? "(detached)" : r.branch;
|
|
194
|
+
return (_jsxs(Text, { wrap: "truncate", children: [r.name.slice(0, 19).padEnd(20), label.slice(0, 21).padEnd(22), _jsxs(Text, { color: r.dirty ? pal.yellow : pal.green, children: ["\u25CF ", r.dirty ? t("git.dirty") : t("git.clean")] }), r.ahead > 0 ? _jsxs(Text, { color: pal.comment, children: [" ", "\u2191", r.ahead] }) : null, r.behind > 0 ? _jsxs(Text, { color: pal.comment, children: [" ", "\u2193", r.behind] }) : null] }, r.name));
|
|
195
|
+
})] })), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.comment, paddingX: 1, marginRight: 1, width: fullW, children: [_jsxs(Text, { color: pal.comment, bold: true, children: [icon("proc"), " ", t("panel.procs")] }), _jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("proc.cpu").padEnd(6), t("proc.mem").padEnd(9), t("proc.pid").padEnd(8), t("proc.name")] }), (sys?.procs ?? []).map((p) => (_jsxs(Text, { wrap: "truncate", children: [`${p.cpu.toFixed(0)}%`.padEnd(6), humanBytes(p.rss).padEnd(9), String(p.pid).padEnd(8), p.name.slice(0, 30)] }, p.pid)))] }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: pal.comment, children: [_jsx(Text, { color: pal.pink, children: "q" }), " ", t("key.quit"), " \u00B7 ", _jsx(Text, { color: pal.pink, children: "p" }), " ", t("key.pause"), " \u00B7", " ", _jsx(Text, { color: pal.pink, children: "+/-" }), " ", t("key.faster"), "/", t("key.slower"), " \u00B7 ", interval.toFixed(2), "s"] }) })] }));
|
|
196
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
import meow from "meow";
|
|
6
|
+
import { execa } from "execa";
|
|
7
|
+
import { App } from "./app.js";
|
|
8
|
+
import { LanguageSelect } from "./components/LanguageSelect.js";
|
|
9
|
+
import { loadConfig, saveConfig } from "./config.js";
|
|
10
|
+
import { detectLang, isLang, makeT } from "./i18n/index.js";
|
|
11
|
+
import { isVariant, palette } from "./theme.js";
|
|
12
|
+
import { isIconMode, makeIcons } from "./icons.js";
|
|
13
|
+
const cli = meow(`
|
|
14
|
+
${"lattice"} — real-time terminal dashboard for macOS Apple Silicon
|
|
15
|
+
|
|
16
|
+
Usage
|
|
17
|
+
$ lattice [options]
|
|
18
|
+
|
|
19
|
+
Options
|
|
20
|
+
--no-power skip powermetrics/sudo (CPU/GPU/RAM/disk/net only)
|
|
21
|
+
--no-vtex hide the VTEX panel (for non-VTEX users)
|
|
22
|
+
--repos folder of git repos to show branches for
|
|
23
|
+
(default: the parent of the current directory)
|
|
24
|
+
--interval, -i refresh interval in seconds (default 1)
|
|
25
|
+
--procs, -n number of top processes to show (default 8)
|
|
26
|
+
--icons icon style: nerd | emoji | none (default nerd)
|
|
27
|
+
--lang language: en | es | pt-BR (asked on first run)
|
|
28
|
+
--theme pro | blade | buffy | lincoln | morbius | van-helsing
|
|
29
|
+
--version, -v
|
|
30
|
+
--help
|
|
31
|
+
|
|
32
|
+
Examples
|
|
33
|
+
$ lattice
|
|
34
|
+
$ lattice --no-power --icons emoji
|
|
35
|
+
$ lattice --lang es --theme blade
|
|
36
|
+
`, {
|
|
37
|
+
importMeta: import.meta,
|
|
38
|
+
description: false,
|
|
39
|
+
flags: {
|
|
40
|
+
power: { type: "boolean", default: true },
|
|
41
|
+
vtex: { type: "boolean", default: true },
|
|
42
|
+
repos: { type: "string", default: "" },
|
|
43
|
+
interval: { type: "number", shortFlag: "i", default: 1 },
|
|
44
|
+
procs: { type: "number", shortFlag: "n", default: 8 },
|
|
45
|
+
icons: { type: "string", default: "" },
|
|
46
|
+
lang: { type: "string", default: "" },
|
|
47
|
+
theme: { type: "string", default: "" },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
async function resolveLang() {
|
|
51
|
+
const cfg = loadConfig();
|
|
52
|
+
const flag = cli.flags.lang;
|
|
53
|
+
if (flag && isLang(flag)) {
|
|
54
|
+
saveConfig({ lang: flag });
|
|
55
|
+
return flag;
|
|
56
|
+
}
|
|
57
|
+
if (cfg.lang)
|
|
58
|
+
return cfg.lang;
|
|
59
|
+
// First run: ask, unless stdin isn't interactive.
|
|
60
|
+
if (!process.stdin.isTTY) {
|
|
61
|
+
const detected = detectLang();
|
|
62
|
+
saveConfig({ lang: detected });
|
|
63
|
+
return detected;
|
|
64
|
+
}
|
|
65
|
+
const variant = resolveTheme();
|
|
66
|
+
const chosen = await new Promise((resolve) => {
|
|
67
|
+
const app = render(_jsx(LanguageSelect, { pal: palette(variant), onSelect: (l) => {
|
|
68
|
+
app.unmount();
|
|
69
|
+
resolve(l);
|
|
70
|
+
} }));
|
|
71
|
+
});
|
|
72
|
+
saveConfig({ lang: chosen });
|
|
73
|
+
return chosen;
|
|
74
|
+
}
|
|
75
|
+
function resolveTheme() {
|
|
76
|
+
const flag = cli.flags.theme;
|
|
77
|
+
if (flag && isVariant(flag)) {
|
|
78
|
+
saveConfig({ theme: flag });
|
|
79
|
+
return flag;
|
|
80
|
+
}
|
|
81
|
+
return loadConfig().theme ?? "pro";
|
|
82
|
+
}
|
|
83
|
+
function resolveIcons() {
|
|
84
|
+
const flag = cli.flags.icons;
|
|
85
|
+
if (flag && isIconMode(flag)) {
|
|
86
|
+
saveConfig({ icons: flag });
|
|
87
|
+
return flag;
|
|
88
|
+
}
|
|
89
|
+
return loadConfig().icons ?? "nerd";
|
|
90
|
+
}
|
|
91
|
+
async function main() {
|
|
92
|
+
const lang = await resolveLang();
|
|
93
|
+
const variant = resolveTheme();
|
|
94
|
+
const iconMode = resolveIcons();
|
|
95
|
+
const t = makeT(lang);
|
|
96
|
+
const pal = palette(variant);
|
|
97
|
+
const icon = makeIcons(iconMode);
|
|
98
|
+
let usePower = cli.flags.power;
|
|
99
|
+
// Pre-authenticate sudo BEFORE the TUI takes over the terminal.
|
|
100
|
+
if (usePower) {
|
|
101
|
+
process.stdout.write(t("cli.sudoNeed") + "\n");
|
|
102
|
+
try {
|
|
103
|
+
await execa("sudo", ["-v"], { stdio: "inherit" });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
process.stdout.write(t("cli.sudoFail") + "\n");
|
|
107
|
+
usePower = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const { waitUntilExit } = render(_jsx(App, { t: t, pal: pal, icon: icon, lang: lang, usePower: usePower, useVtex: cli.flags.vtex, gitDir: cli.flags.repos || dirname(process.cwd()), interval: cli.flags.interval, topN: cli.flags.procs }));
|
|
111
|
+
await waitUntilExit();
|
|
112
|
+
}
|
|
113
|
+
main().catch((e) => {
|
|
114
|
+
process.stderr.write(String(e) + "\n");
|
|
115
|
+
process.exit(1);
|
|
116
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-disk activity & usage (no sudo).
|
|
3
|
+
*
|
|
4
|
+
* Lists each "real" mount — `/` and everything under `/Volumes/*` — with its
|
|
5
|
+
* space usage (from `systeminformation`) and live read/write throughput.
|
|
6
|
+
*
|
|
7
|
+
* Throughput comes from IOKit's cumulative byte counters, read via `ioreg`
|
|
8
|
+
* (same approach as gpu.ts — no sudo, non-blocking). Each APFS volume's mount
|
|
9
|
+
* device (e.g. `/dev/disk7s1`) maps to a whole disk (`disk7`); IOKit reports a
|
|
10
|
+
* `Bytes (Read)`/`Bytes (Write)` counter for that whole disk, including the
|
|
11
|
+
* synthesized APFS container disks. We diff successive samples to get bytes/sec,
|
|
12
|
+
* so the first sample reads as zero and later ones are accurate at the refresh
|
|
13
|
+
* interval.
|
|
14
|
+
*/
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import si from "systeminformation";
|
|
18
|
+
const run = promisify(execFile);
|
|
19
|
+
export class DisksCollector {
|
|
20
|
+
/** Previous cumulative counters per whole disk, for rate computation. */
|
|
21
|
+
prev = new Map();
|
|
22
|
+
async read() {
|
|
23
|
+
try {
|
|
24
|
+
const [sizes, counters] = await Promise.all([
|
|
25
|
+
si.fsSize().catch(() => []),
|
|
26
|
+
readDiskCounters().catch(() => ({})),
|
|
27
|
+
]);
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const mounts = (sizes ?? []).filter((s) => s.fs?.startsWith("/dev/disk") && (s.mount === "/" || s.mount.startsWith("/Volumes/")));
|
|
30
|
+
const disks = mounts.map((s) => {
|
|
31
|
+
const device = wholeDisk(s.fs);
|
|
32
|
+
const cur = counters[device];
|
|
33
|
+
const prev = this.prev.get(device);
|
|
34
|
+
let readBps = 0;
|
|
35
|
+
let writeBps = 0;
|
|
36
|
+
if (cur && prev) {
|
|
37
|
+
const dt = (now - prev.t) / 1000;
|
|
38
|
+
if (dt > 0) {
|
|
39
|
+
readBps = Math.max(0, (cur.r - prev.r) / dt);
|
|
40
|
+
writeBps = Math.max(0, (cur.w - prev.w) / dt);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
mount: s.mount,
|
|
45
|
+
device: device || s.fs,
|
|
46
|
+
readBps,
|
|
47
|
+
writeBps,
|
|
48
|
+
usedBytes: s.used ?? 0,
|
|
49
|
+
sizeBytes: s.size ?? 0,
|
|
50
|
+
usePercent: s.use ?? 0,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
// Remember the latest counters for the next delta.
|
|
54
|
+
for (const [dev, c] of Object.entries(counters))
|
|
55
|
+
this.prev.set(dev, { r: c.r, w: c.w, t: now });
|
|
56
|
+
// Root first, then /Volumes alphabetically.
|
|
57
|
+
disks.sort((a, b) => a.mount === "/" ? -1 : b.mount === "/" ? 1 : a.mount.localeCompare(b.mount));
|
|
58
|
+
return { disks };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { disks: [] };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** `/dev/disk7s1` → `disk7` (the whole disk IOKit reports counters for). */
|
|
66
|
+
function wholeDisk(fs) {
|
|
67
|
+
const m = (fs ?? "").replace("/dev/", "").match(/^disk\d+/);
|
|
68
|
+
return m ? m[0] : "";
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Cumulative read/write bytes per whole disk, from IOKit's
|
|
72
|
+
* IOBlockStorageDriver "Statistics". The block-driver counter uses the keys
|
|
73
|
+
* `Bytes (Read)`/`Bytes (Write)`; APFS filesystem-level stats use different
|
|
74
|
+
* keys and are ignored. The whole-disk BSD name follows its driver's stats.
|
|
75
|
+
*/
|
|
76
|
+
async function readDiskCounters() {
|
|
77
|
+
const { stdout } = await run("ioreg", ["-r", "-c", "IOBlockStorageDriver", "-w", "0", "-l"], {
|
|
78
|
+
maxBuffer: 1 << 24,
|
|
79
|
+
});
|
|
80
|
+
const out = {};
|
|
81
|
+
let pending = null;
|
|
82
|
+
for (const ln of stdout.split("\n")) {
|
|
83
|
+
const sm = ln.match(/"Statistics" = \{[^}]*"Bytes \(Read\)"=(\d+)/);
|
|
84
|
+
if (sm) {
|
|
85
|
+
const wm = ln.match(/"Bytes \(Write\)"=(\d+)/);
|
|
86
|
+
pending = { r: Number(sm[1]), w: wm ? Number(wm[1]) : 0 };
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const bm = ln.match(/"BSD Name" = "(disk\d+)"\s*$/);
|
|
90
|
+
if (bm && pending) {
|
|
91
|
+
out[bm[1]] = pending;
|
|
92
|
+
pending = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|