@zeluizr/lattice 1.1.0 → 2.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 +23 -9
- package/dist/app.js +29 -36
- package/dist/cli.js +24 -115
- package/dist/collectors/git.js +22 -7
- package/dist/i18n/en.js +3 -8
- package/dist/i18n/es.js +3 -8
- package/dist/i18n/pt-BR.js +3 -8
- package/package.json +2 -2
- package/dist/collectors/power.js +0 -105
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,7 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
|
|
5
5
|
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [2.0.0] — 2026-06-24
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
- **GIT panel reports the host of each repo.** A new `HOST` column tags every
|
|
@@ -13,17 +13,31 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
13
13
|
- **zgit server listing.** A footer line in the GIT panel lists the bare repos on
|
|
14
14
|
the self-hosted zgit Docker container (`docker exec -u git <container> ls
|
|
15
15
|
/repos`). Local-only, no network; fails soft (the line is hidden) when Docker
|
|
16
|
-
or the container is unavailable. Container name via
|
|
17
|
-
|
|
16
|
+
or the container is unavailable. Container name via config `zgitContainer`
|
|
17
|
+
(default `zgit`).
|
|
18
|
+
- **GIT report and PROCESSES sit side by side** on wide terminals (the GIT
|
|
19
|
+
report takes the larger share); they stack when the terminal is too narrow.
|
|
18
20
|
|
|
19
21
|
### Changed
|
|
20
|
-
- **The GIT panel is now cwd-independent.** It scans a fixed
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
- **The GIT panel is now cwd-independent.** It scans a fixed list of paths from
|
|
23
|
+
config (`repoRoots`) instead of the parent of the current directory, so the
|
|
24
|
+
report is identical in every terminal/tab. Each entry may be a repo itself or a
|
|
23
25
|
folder whose subdirectories are repos; entries are merged and de-duplicated.
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
- **The GIT panel lists only repos that need attention** — uncommitted changes,
|
|
27
|
+
or commits to push (ahead) / pull (behind) — most recently committed first,
|
|
28
|
+
capped at the top 10. Clean, in-sync repos are not listed; when none are
|
|
29
|
+
pending it shows `✓ all N repos up to date`. Uncommitted changes are now
|
|
30
|
+
labelled **uncommitted** (was "dirty"), with `↑`/`↓` for push/pull.
|
|
31
|
+
|
|
32
|
+
### Removed
|
|
33
|
+
- **Power monitoring and the sudo prompt.** lattice no longer runs
|
|
34
|
+
`powermetrics`, so it never asks for a password on launch. Temperatures and fan
|
|
35
|
+
speed are unaffected — they still come from the native SMC helper (no sudo).
|
|
36
|
+
- **All CLI flags.** lattice now takes no options: it always runs the full
|
|
37
|
+
dashboard using the settings saved in `~/.config/lattice/config.json` (language
|
|
38
|
+
is chosen on first run; theme, icons, repo list and zgit container are read
|
|
39
|
+
from the file). Removed `--no-power`, `--no-vtex`, `--repos`, `--zgit`,
|
|
40
|
+
`--interval`, `--procs`, `--icons`, `--lang` and `--theme`.
|
|
27
41
|
|
|
28
42
|
## [1.0.0] — 2026-06-24
|
|
29
43
|
|
package/dist/app.js
CHANGED
|
@@ -8,18 +8,19 @@ import { GitCollector } from "./collectors/git.js";
|
|
|
8
8
|
import { ZgitCollector } from "./collectors/zgit.js";
|
|
9
9
|
import { readGpu } from "./collectors/gpu.js";
|
|
10
10
|
import { readSensors } from "./collectors/sensors.js";
|
|
11
|
-
import { PowerCollector } from "./collectors/power.js";
|
|
12
11
|
import { TokenCollector } from "./collectors/tokens.js";
|
|
13
12
|
import { readVtex } from "./collectors/vtex.js";
|
|
14
13
|
import { coreCell, fmtTok, humanBytes, humanRate, sparkline, statusLevel } from "./format.js";
|
|
15
14
|
const MAX_HIST = 60;
|
|
16
15
|
const push = (h, v) => [...h, v].slice(-MAX_HIST);
|
|
16
|
+
const DEFAULT_INTERVAL = 1; // seconds between refreshes (adjust at runtime with +/-)
|
|
17
|
+
const PROCS = 12; // number of top processes to list
|
|
17
18
|
function timeStr() {
|
|
18
19
|
const d = new Date();
|
|
19
20
|
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
20
21
|
}
|
|
21
22
|
export function App(props) {
|
|
22
|
-
const { t, pal, icon,
|
|
23
|
+
const { t, pal, icon, repoRoots, zgitContainer } = props;
|
|
23
24
|
const { exit } = useApp();
|
|
24
25
|
const [sys, setSys] = useState(null);
|
|
25
26
|
const [disks, setDisks] = useState(null);
|
|
@@ -27,7 +28,6 @@ export function App(props) {
|
|
|
27
28
|
const [zgit, setZgit] = useState(null);
|
|
28
29
|
const [gpu, setGpu] = useState(null);
|
|
29
30
|
const [sensors, setSensors] = useState(null);
|
|
30
|
-
const [power, setPower] = useState(null);
|
|
31
31
|
const [tokens, setTokens] = useState(null);
|
|
32
32
|
const [vtex, setVtex] = useState(null);
|
|
33
33
|
const [hCpu, setHCpu] = useState([]);
|
|
@@ -36,7 +36,7 @@ export function App(props) {
|
|
|
36
36
|
const [hGpu, setHGpu] = useState([]);
|
|
37
37
|
const [hTemp, setHTemp] = useState([]);
|
|
38
38
|
const [paused, setPaused] = useState(false);
|
|
39
|
-
const [interval, setIntervalState] = useState(
|
|
39
|
+
const [interval, setIntervalState] = useState(DEFAULT_INTERVAL);
|
|
40
40
|
const [now, setNow] = useState(timeStr());
|
|
41
41
|
const [cols, setCols] = useState(process.stdout.columns || 100);
|
|
42
42
|
const sysRef = useRef(null);
|
|
@@ -44,7 +44,6 @@ export function App(props) {
|
|
|
44
44
|
const gitRef = useRef(null);
|
|
45
45
|
const zgitRef = useRef(null);
|
|
46
46
|
const tokRef = useRef(null);
|
|
47
|
-
const powerRef = useRef(null);
|
|
48
47
|
const pausedRef = useRef(false);
|
|
49
48
|
const mounted = useRef(true);
|
|
50
49
|
useEffect(() => {
|
|
@@ -53,15 +52,11 @@ export function App(props) {
|
|
|
53
52
|
// ----- init collectors + power subprocess + clock -------------------------
|
|
54
53
|
useEffect(() => {
|
|
55
54
|
mounted.current = true;
|
|
56
|
-
sysRef.current = new SystemCollector(
|
|
55
|
+
sysRef.current = new SystemCollector(PROCS);
|
|
57
56
|
disksRef.current = new DisksCollector();
|
|
58
57
|
gitRef.current = new GitCollector(repoRoots);
|
|
59
58
|
zgitRef.current = new ZgitCollector(zgitContainer);
|
|
60
59
|
tokRef.current = new TokenCollector();
|
|
61
|
-
if (usePower) {
|
|
62
|
-
powerRef.current = new PowerCollector(Math.round(props.interval * 1000));
|
|
63
|
-
powerRef.current.start();
|
|
64
|
-
}
|
|
65
60
|
const clock = setInterval(() => mounted.current && setNow(timeStr()), 1000);
|
|
66
61
|
const onResize = () => setCols(process.stdout.columns || 100);
|
|
67
62
|
process.stdout.on("resize", onResize);
|
|
@@ -69,7 +64,6 @@ export function App(props) {
|
|
|
69
64
|
mounted.current = false;
|
|
70
65
|
clearInterval(clock);
|
|
71
66
|
process.stdout.off("resize", onResize);
|
|
72
|
-
powerRef.current?.stop();
|
|
73
67
|
};
|
|
74
68
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
75
69
|
}, []);
|
|
@@ -83,14 +77,12 @@ export function App(props) {
|
|
|
83
77
|
readGpu(),
|
|
84
78
|
readSensors(),
|
|
85
79
|
]);
|
|
86
|
-
const p = powerRef.current ? powerRef.current.read() : null;
|
|
87
80
|
if (!mounted.current)
|
|
88
81
|
return;
|
|
89
82
|
setSys(s);
|
|
90
83
|
setDisks(d);
|
|
91
84
|
setGpu(g);
|
|
92
85
|
setSensors(st);
|
|
93
|
-
setPower(p);
|
|
94
86
|
setHCpu((h) => push(h, s.cpuTotal));
|
|
95
87
|
setHMem((h) => push(h, s.memPercent));
|
|
96
88
|
setHNet((h) => push(h, (s.netRecvBps + s.netSentBps) / 1024 / 1024));
|
|
@@ -104,7 +96,7 @@ export function App(props) {
|
|
|
104
96
|
return;
|
|
105
97
|
const [tk, vx, gt, zg] = await Promise.all([
|
|
106
98
|
tokRef.current.read(),
|
|
107
|
-
|
|
99
|
+
readVtex(),
|
|
108
100
|
gitRef.current ? gitRef.current.read() : Promise.resolve(null),
|
|
109
101
|
zgitRef.current ? zgitRef.current.read() : Promise.resolve(null),
|
|
110
102
|
]);
|
|
@@ -114,7 +106,7 @@ export function App(props) {
|
|
|
114
106
|
setVtex(vx);
|
|
115
107
|
setGit(gt);
|
|
116
108
|
setZgit(zg);
|
|
117
|
-
}, [
|
|
109
|
+
}, []);
|
|
118
110
|
useEffect(() => {
|
|
119
111
|
refreshData();
|
|
120
112
|
const id = setInterval(refreshData, Math.max(250, interval * 1000));
|
|
@@ -127,7 +119,6 @@ export function App(props) {
|
|
|
127
119
|
}, [refreshAux]);
|
|
128
120
|
useInput((input) => {
|
|
129
121
|
if (input === "q") {
|
|
130
|
-
powerRef.current?.stop();
|
|
131
122
|
exit();
|
|
132
123
|
}
|
|
133
124
|
else if (input === "p") {
|
|
@@ -148,6 +139,12 @@ export function App(props) {
|
|
|
148
139
|
const inner = (w) => Math.max(6, w - 4); // minus border (2) + padding (2)
|
|
149
140
|
const w2 = inner(col2);
|
|
150
141
|
const w3 = inner(col3);
|
|
142
|
+
// GIT + PROCESSES share a row on wide terminals; they stack when it's too
|
|
143
|
+
// narrow to split. GIT's columns are fixed-width, so it takes only what it
|
|
144
|
+
// needs (clamped) and PROCESSES inherits the slack — handy for long names.
|
|
145
|
+
const sideBySide = cols >= 92;
|
|
146
|
+
const gitW = Math.max(48, Math.min(58, Math.floor((cols - 2 * m) * 0.46)));
|
|
147
|
+
const procW = cols - 2 * m - gitW;
|
|
151
148
|
const statColor = (lvl) => lvl === "ok" ? pal.green : lvl === "warn" ? pal.yellow : pal.red;
|
|
152
149
|
const Stat = ({ value, warn, crit, metric }) => {
|
|
153
150
|
const lvl = statusLevel(value, warn, crit);
|
|
@@ -170,8 +167,9 @@ export function App(props) {
|
|
|
170
167
|
const util = gpu?.utilPct ?? 0;
|
|
171
168
|
const diskRows = disks?.disks ?? [];
|
|
172
169
|
const gitRepos = git?.repos ?? [];
|
|
170
|
+
const gitTotal = git?.total ?? 0;
|
|
173
171
|
const zgitServer = zgit?.available ? zgit : null;
|
|
174
|
-
const showGit =
|
|
172
|
+
const showGit = gitTotal > 0 || !!zgitServer;
|
|
175
173
|
const hostLabel = (r) => r.hostKind === "github"
|
|
176
174
|
? "GitHub"
|
|
177
175
|
: r.hostKind === "zgit"
|
|
@@ -192,14 +190,9 @@ export function App(props) {
|
|
|
192
190
|
const modelExtra = modelItems.length > 2 ? ` +${modelItems.length - 2}` : "";
|
|
193
191
|
const web = tokens && (tokens.webSearch || tokens.webFetch) ? ` · web ${tokens.webSearch}/${tokens.webFetch}` : "";
|
|
194
192
|
const models = modelParts.length ? modelParts.join(" · ") + modelExtra + web : t("tokens.none");
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
else if (usePower)
|
|
199
|
-
powerParts.push(`${icon("power")} ${t("temp.waitingSudo")}`);
|
|
200
|
-
if (sensors?.fans?.length)
|
|
201
|
-
powerParts.push(`${icon("fan")} ${sensors.fans[0].rpm.toFixed(0)} rpm`);
|
|
202
|
-
const tempLine2 = powerParts.length ? powerParts.join(" · ") : t("temp.needsSudo");
|
|
193
|
+
const tempLine2 = sensors?.fans?.length
|
|
194
|
+
? `${icon("fan")} ${sensors.fans[0].rpm.toFixed(0)} rpm`
|
|
195
|
+
: "";
|
|
203
196
|
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", {
|
|
204
197
|
used: humanBytes(sys?.memUsed),
|
|
205
198
|
total: humanBytes(sys?.memTotal),
|
|
@@ -207,18 +200,18 @@ export function App(props) {
|
|
|
207
200
|
}) }), _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) => {
|
|
208
201
|
const lvl = statusLevel(d.usePercent, 80, 92);
|
|
209
202
|
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));
|
|
210
|
-
}))] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("tokens")} ${t("panel.tokens")}`, color: pal.pink, width:
|
|
203
|
+
}))] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("tokens")} ${t("panel.tokens")}`, color: pal.pink, width: col2, 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", {
|
|
211
204
|
total: fmtTok(tokTotal),
|
|
212
205
|
input: fmtTok(tokens?.input),
|
|
213
206
|
output: fmtTok(tokens?.output),
|
|
214
|
-
}) }), _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 }) })] }),
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
207
|
+
}) }), _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 }) })] }), _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") })] })) })] }), _jsxs(Box, { flexDirection: sideBySide ? "row" : "column", alignItems: "flex-start", children: [showGit && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.green, paddingX: 1, marginRight: 1, width: sideBySide ? gitW : 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] }), gitRepos.length > 0 ? (_jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("git.repo").padEnd(18), t("git.branch").padEnd(12), t("git.host").padEnd(8), t("git.state")] })) : gitTotal > 0 ? (_jsx(Text, { color: pal.green, wrap: "truncate", children: t("git.allClear", { n: gitTotal }) })) : null, gitRepos.map((r) => {
|
|
208
|
+
const label = r.detached ? "(detached)" : r.branch;
|
|
209
|
+
return (_jsxs(Text, { wrap: "truncate", children: [r.name.slice(0, 17).padEnd(18), label.slice(0, 11).padEnd(12), _jsx(Text, { color: hostColor(r), children: hostLabel(r).slice(0, 7).padEnd(8) }), _jsx(Text, { color: r.dirty ? pal.yellow : pal.orange, children: "\u25CF" }), r.dirty ? _jsxs(Text, { color: pal.yellow, children: [" ", t("git.uncommitted")] }) : null, r.ahead > 0 ? _jsxs(Text, { color: pal.cyan, children: [" \u2191", r.ahead] }) : null, r.behind > 0 ? _jsxs(Text, { color: pal.orange, children: [" \u2193", r.behind] }) : null] }, r.path));
|
|
210
|
+
}), zgitServer && (_jsx(Text, { color: pal.comment, wrap: "truncate", children: zgitServer.repos.length
|
|
211
|
+
? t("git.server", {
|
|
212
|
+
container: zgitServer.container,
|
|
213
|
+
list: zgitServer.repos.join(", "),
|
|
214
|
+
n: zgitServer.repos.length,
|
|
215
|
+
})
|
|
216
|
+
: t("git.serverEmpty", { container: zgitServer.container }) }))] })), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.comment, paddingX: 1, marginRight: 1, width: sideBySide && showGit ? procW : 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(7), 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(7), p.name.slice(0, 40)] }, 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"] }) })] }));
|
|
224
217
|
}
|
package/dist/cli.js
CHANGED
|
@@ -3,72 +3,34 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { render } from "ink";
|
|
5
5
|
import meow from "meow";
|
|
6
|
-
import { execa } from "execa";
|
|
7
6
|
import { App } from "./app.js";
|
|
8
7
|
import { LanguageSelect } from "./components/LanguageSelect.js";
|
|
9
8
|
import { loadConfig, saveConfig } from "./config.js";
|
|
10
|
-
import { detectLang,
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
import { detectLang, makeT } from "./i18n/index.js";
|
|
10
|
+
import { palette } from "./theme.js";
|
|
11
|
+
import { makeIcons } from "./icons.js";
|
|
12
|
+
// lattice takes no options and needs no sudo: it runs the full dashboard using
|
|
13
|
+
// the settings saved in ~/.config/lattice/config.json. Only --help / --version
|
|
14
|
+
// are handled here; language is chosen on first run, the rest lives in config.
|
|
15
|
+
meow(`
|
|
16
|
+
lattice — real-time terminal dashboard for macOS Apple Silicon
|
|
15
17
|
|
|
16
18
|
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 comma-separated folders and/or repos the GIT panel always
|
|
23
|
-
shows, regardless of where lattice is launched. A path
|
|
24
|
-
that is itself a repo is shown directly; otherwise its
|
|
25
|
-
subdirectories are scanned. Saved to config once set.
|
|
26
|
-
(default: saved list, else the parent of the current dir)
|
|
27
|
-
--zgit Docker container of the self-hosted zgit server to list
|
|
28
|
-
(default: zgit; the line is hidden when unavailable)
|
|
29
|
-
--interval, -i refresh interval in seconds (default 1)
|
|
30
|
-
--procs, -n number of top processes to show (default 8)
|
|
31
|
-
--icons icon style: nerd | emoji | none (default nerd)
|
|
32
|
-
--lang language: en | es | pt-BR (asked on first run)
|
|
33
|
-
--theme pro | blade | buffy | lincoln | morbius | van-helsing
|
|
34
|
-
--version, -v
|
|
35
|
-
--help
|
|
36
|
-
|
|
37
|
-
Examples
|
|
38
19
|
$ lattice
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
zgit: { type: "string", default: "" },
|
|
49
|
-
interval: { type: "number", shortFlag: "i", default: 1 },
|
|
50
|
-
procs: { type: "number", shortFlag: "n", default: 8 },
|
|
51
|
-
icons: { type: "string", default: "" },
|
|
52
|
-
lang: { type: "string", default: "" },
|
|
53
|
-
theme: { type: "string", default: "" },
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
async function resolveLang() {
|
|
57
|
-
const cfg = loadConfig();
|
|
58
|
-
const flag = cli.flags.lang;
|
|
59
|
-
if (flag && isLang(flag)) {
|
|
60
|
-
saveConfig({ lang: flag });
|
|
61
|
-
return flag;
|
|
62
|
-
}
|
|
63
|
-
if (cfg.lang)
|
|
64
|
-
return cfg.lang;
|
|
65
|
-
// First run: ask, unless stdin isn't interactive.
|
|
20
|
+
|
|
21
|
+
lattice reads its language, theme, icons, repo list and zgit container from
|
|
22
|
+
~/.config/lattice/config.json (language is chosen on first run).
|
|
23
|
+
`, { importMeta: import.meta, flags: {} });
|
|
24
|
+
/** Saved language, or the first-run picker (env-detected when non-interactive). */
|
|
25
|
+
async function resolveLang(variant) {
|
|
26
|
+
const saved = loadConfig().lang;
|
|
27
|
+
if (saved)
|
|
28
|
+
return saved;
|
|
66
29
|
if (!process.stdin.isTTY) {
|
|
67
30
|
const detected = detectLang();
|
|
68
31
|
saveConfig({ lang: detected });
|
|
69
32
|
return detected;
|
|
70
33
|
}
|
|
71
|
-
const variant = resolveTheme();
|
|
72
34
|
const chosen = await new Promise((resolve) => {
|
|
73
35
|
const app = render(_jsx(LanguageSelect, { pal: palette(variant), onSelect: (l) => {
|
|
74
36
|
app.unmount();
|
|
@@ -78,69 +40,16 @@ async function resolveLang() {
|
|
|
78
40
|
saveConfig({ lang: chosen });
|
|
79
41
|
return chosen;
|
|
80
42
|
}
|
|
81
|
-
function resolveTheme() {
|
|
82
|
-
const flag = cli.flags.theme;
|
|
83
|
-
if (flag && isVariant(flag)) {
|
|
84
|
-
saveConfig({ theme: flag });
|
|
85
|
-
return flag;
|
|
86
|
-
}
|
|
87
|
-
return loadConfig().theme ?? "pro";
|
|
88
|
-
}
|
|
89
|
-
function resolveIcons() {
|
|
90
|
-
const flag = cli.flags.icons;
|
|
91
|
-
if (flag && isIconMode(flag)) {
|
|
92
|
-
saveConfig({ icons: flag });
|
|
93
|
-
return flag;
|
|
94
|
-
}
|
|
95
|
-
return loadConfig().icons ?? "nerd";
|
|
96
|
-
}
|
|
97
|
-
/** The fixed set of folders/repos the GIT panel scans — independent of cwd. */
|
|
98
|
-
function resolveRepoRoots() {
|
|
99
|
-
const flag = cli.flags.repos.trim();
|
|
100
|
-
if (flag) {
|
|
101
|
-
const roots = flag.split(",").map((s) => s.trim()).filter(Boolean);
|
|
102
|
-
if (roots.length) {
|
|
103
|
-
saveConfig({ repoRoots: roots });
|
|
104
|
-
return roots;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
const saved = loadConfig().repoRoots;
|
|
108
|
-
if (saved && saved.length)
|
|
109
|
-
return saved;
|
|
110
|
-
// First run / no config: fall back to the parent of the cwd so the tool still
|
|
111
|
-
// works out of the box. Pass --repos once to pin a stable, cwd-independent list.
|
|
112
|
-
return [dirname(process.cwd())];
|
|
113
|
-
}
|
|
114
|
-
function resolveZgitContainer() {
|
|
115
|
-
const flag = cli.flags.zgit.trim();
|
|
116
|
-
if (flag) {
|
|
117
|
-
saveConfig({ zgitContainer: flag });
|
|
118
|
-
return flag;
|
|
119
|
-
}
|
|
120
|
-
return loadConfig().zgitContainer ?? "zgit";
|
|
121
|
-
}
|
|
122
43
|
async function main() {
|
|
123
|
-
const
|
|
124
|
-
const variant =
|
|
125
|
-
const
|
|
126
|
-
const repoRoots = resolveRepoRoots();
|
|
127
|
-
const zgitContainer = resolveZgitContainer();
|
|
44
|
+
const cfg = loadConfig();
|
|
45
|
+
const variant = cfg.theme ?? "pro";
|
|
46
|
+
const lang = await resolveLang(variant);
|
|
128
47
|
const t = makeT(lang);
|
|
129
48
|
const pal = palette(variant);
|
|
130
|
-
const icon = makeIcons(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
process.stdout.write(t("cli.sudoNeed") + "\n");
|
|
135
|
-
try {
|
|
136
|
-
await execa("sudo", ["-v"], { stdio: "inherit" });
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
process.stdout.write(t("cli.sudoFail") + "\n");
|
|
140
|
-
usePower = false;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
const { waitUntilExit } = render(_jsx(App, { t: t, pal: pal, icon: icon, lang: lang, usePower: usePower, useVtex: cli.flags.vtex, repoRoots: repoRoots, zgitContainer: zgitContainer, interval: cli.flags.interval, topN: cli.flags.procs }));
|
|
49
|
+
const icon = makeIcons(cfg.icons ?? "nerd");
|
|
50
|
+
const repoRoots = cfg.repoRoots?.length ? cfg.repoRoots : [dirname(process.cwd())];
|
|
51
|
+
const zgitContainer = cfg.zgitContainer ?? "zgit";
|
|
52
|
+
const { waitUntilExit } = render(_jsx(App, { t: t, pal: pal, icon: icon, repoRoots: repoRoots, zgitContainer: zgitContainer }));
|
|
144
53
|
await waitUntilExit();
|
|
145
54
|
}
|
|
146
55
|
main().catch((e) => {
|
package/dist/collectors/git.js
CHANGED
|
@@ -20,7 +20,8 @@ import { readdir } from "node:fs/promises";
|
|
|
20
20
|
import { existsSync } from "node:fs";
|
|
21
21
|
import { join, basename } from "node:path";
|
|
22
22
|
const run = promisify(execFile);
|
|
23
|
-
const MAX_REPOS =
|
|
23
|
+
const MAX_REPOS = 48; // hard cap on how many repos we'll stat per refresh
|
|
24
|
+
const SHOW_REPOS = 10; // of those, only the most-recently-committed are reported
|
|
24
25
|
const CONCURRENCY = 8;
|
|
25
26
|
export class GitCollector {
|
|
26
27
|
roots;
|
|
@@ -28,7 +29,7 @@ export class GitCollector {
|
|
|
28
29
|
last;
|
|
29
30
|
constructor(roots) {
|
|
30
31
|
this.roots = roots;
|
|
31
|
-
this.last = { roots, repos: [], truncated: false };
|
|
32
|
+
this.last = { roots, repos: [], total: 0, truncated: false };
|
|
32
33
|
}
|
|
33
34
|
async read() {
|
|
34
35
|
if (this.inflight)
|
|
@@ -36,7 +37,6 @@ export class GitCollector {
|
|
|
36
37
|
this.inflight = true;
|
|
37
38
|
try {
|
|
38
39
|
const dirs = await this.discover();
|
|
39
|
-
const truncated = dirs.length > MAX_REPOS;
|
|
40
40
|
const pick = dirs.slice(0, MAX_REPOS);
|
|
41
41
|
const repos = [];
|
|
42
42
|
for (let i = 0; i < pick.length; i += CONCURRENCY) {
|
|
@@ -46,8 +46,19 @@ export class GitCollector {
|
|
|
46
46
|
if (r)
|
|
47
47
|
repos.push(r);
|
|
48
48
|
}
|
|
49
|
-
repos
|
|
50
|
-
|
|
49
|
+
// Only repos that need attention — uncommitted changes, or commits to
|
|
50
|
+
// push/pull — most recently committed first, capped at SHOW_REPOS. Repos
|
|
51
|
+
// that are clean and in sync are counted but not listed.
|
|
52
|
+
const pending = repos
|
|
53
|
+
.filter((r) => r.dirty || r.ahead > 0 || r.behind > 0)
|
|
54
|
+
.sort((a, b) => b.lastCommit - a.lastCommit || a.name.localeCompare(b.name));
|
|
55
|
+
const shown = pending.slice(0, SHOW_REPOS);
|
|
56
|
+
this.last = {
|
|
57
|
+
roots: this.roots,
|
|
58
|
+
repos: shown,
|
|
59
|
+
total: repos.length,
|
|
60
|
+
truncated: pending.length > shown.length,
|
|
61
|
+
};
|
|
51
62
|
return this.last;
|
|
52
63
|
}
|
|
53
64
|
catch {
|
|
@@ -87,11 +98,14 @@ export class GitCollector {
|
|
|
87
98
|
}
|
|
88
99
|
}
|
|
89
100
|
async function readRepo(dir) {
|
|
90
|
-
const [statusRes, urlRes] = await Promise.all([
|
|
101
|
+
const [statusRes, urlRes, dateRes] = await Promise.all([
|
|
91
102
|
run("git", ["-C", dir, "status", "--porcelain=v2", "--branch"], { maxBuffer: 1 << 20 }),
|
|
92
103
|
run("git", ["-C", dir, "remote", "get-url", "origin"], { maxBuffer: 1 << 16 }).catch(() => ({
|
|
93
104
|
stdout: "",
|
|
94
105
|
})),
|
|
106
|
+
run("git", ["-C", dir, "log", "-1", "--format=%ct"], { maxBuffer: 1 << 16 }).catch(() => ({
|
|
107
|
+
stdout: "",
|
|
108
|
+
})),
|
|
95
109
|
]);
|
|
96
110
|
let branch = "?";
|
|
97
111
|
let ahead = 0;
|
|
@@ -116,7 +130,8 @@ async function readRepo(dir) {
|
|
|
116
130
|
}
|
|
117
131
|
}
|
|
118
132
|
const { host, hostKind } = classifyHost(urlRes.stdout.trim());
|
|
119
|
-
|
|
133
|
+
const lastCommit = Number(dateRes.stdout.trim()) || 0;
|
|
134
|
+
return { name: basename(dir), path: dir, branch, ahead, behind, detached, dirty, host, hostKind, lastCommit };
|
|
120
135
|
}
|
|
121
136
|
/** Pull the hostname out of an origin URL and bucket it into a known host. */
|
|
122
137
|
export function classifyHost(url) {
|
package/dist/i18n/en.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** English (base locale). Every other locale must implement these exact keys. */
|
|
2
2
|
export const en = {
|
|
3
|
-
"subtitle": "system · gpu ·
|
|
3
|
+
"subtitle": "system · gpu · git",
|
|
4
4
|
"paused": "PAUSED",
|
|
5
5
|
"panel.cpu": "CPU",
|
|
6
6
|
"panel.mem": "MEMORY",
|
|
@@ -40,16 +40,14 @@ export const en = {
|
|
|
40
40
|
"git.branch": "BRANCH",
|
|
41
41
|
"git.state": "STATE",
|
|
42
42
|
"git.host": "HOST",
|
|
43
|
-
"git.
|
|
44
|
-
"git.
|
|
43
|
+
"git.uncommitted": "uncommitted",
|
|
44
|
+
"git.allClear": "✓ all {n} repos up to date",
|
|
45
45
|
"git.local": "local",
|
|
46
46
|
"git.server": "— zgit server ({container}): {list} ({n})",
|
|
47
47
|
"git.serverEmpty": "— zgit server ({container}): no repos",
|
|
48
48
|
"gpu.usage": "Usage",
|
|
49
49
|
"gpu.mem": "mem {used}/{alloc}",
|
|
50
50
|
"temp.unavailable": "sensors unavailable",
|
|
51
|
-
"temp.waitingSudo": "waiting for sudo",
|
|
52
|
-
"temp.needsSudo": "power: requires sudo",
|
|
53
51
|
"spark.collecting": "collecting…",
|
|
54
52
|
"spark.lastMin": "last min: {range}",
|
|
55
53
|
"tokens.spent": "Spent today: ${cost} · {messages} messages",
|
|
@@ -74,7 +72,4 @@ export const en = {
|
|
|
74
72
|
"key.pause": "Pause",
|
|
75
73
|
"key.faster": "Faster",
|
|
76
74
|
"key.slower": "Slower",
|
|
77
|
-
"cli.sudoNeed": "lattice needs sudo to read power (powermetrics).",
|
|
78
|
-
"cli.sudoFail": "sudo unavailable — continuing without power data.",
|
|
79
|
-
"cli.sudoCancel": "cancelled.",
|
|
80
75
|
};
|
package/dist/i18n/es.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const es = {
|
|
2
|
-
"subtitle": "sistema · gpu ·
|
|
2
|
+
"subtitle": "sistema · gpu · git",
|
|
3
3
|
"paused": "PAUSADO",
|
|
4
4
|
"panel.cpu": "CPU",
|
|
5
5
|
"panel.mem": "MEMORIA",
|
|
@@ -39,16 +39,14 @@ export const es = {
|
|
|
39
39
|
"git.branch": "BRANCH",
|
|
40
40
|
"git.state": "ESTADO",
|
|
41
41
|
"git.host": "HOST",
|
|
42
|
-
"git.
|
|
43
|
-
"git.
|
|
42
|
+
"git.uncommitted": "sin commit",
|
|
43
|
+
"git.allClear": "✓ {n} repos al día",
|
|
44
44
|
"git.local": "local",
|
|
45
45
|
"git.server": "— servidor zgit ({container}): {list} ({n})",
|
|
46
46
|
"git.serverEmpty": "— servidor zgit ({container}): sin repos",
|
|
47
47
|
"gpu.usage": "Uso",
|
|
48
48
|
"gpu.mem": "mem {used}/{alloc}",
|
|
49
49
|
"temp.unavailable": "sensores no disponibles",
|
|
50
|
-
"temp.waitingSudo": "esperando sudo",
|
|
51
|
-
"temp.needsSudo": "energía: requiere sudo",
|
|
52
50
|
"spark.collecting": "recolectando…",
|
|
53
51
|
"spark.lastMin": "último min: {range}",
|
|
54
52
|
"tokens.spent": "Gastado hoy: ${cost} · {messages} mensajes",
|
|
@@ -73,7 +71,4 @@ export const es = {
|
|
|
73
71
|
"key.pause": "Pausar",
|
|
74
72
|
"key.faster": "Más rápido",
|
|
75
73
|
"key.slower": "Más lento",
|
|
76
|
-
"cli.sudoNeed": "lattice necesita sudo para leer la energía (powermetrics).",
|
|
77
|
-
"cli.sudoFail": "sudo no disponible — siguiendo sin datos de energía.",
|
|
78
|
-
"cli.sudoCancel": "cancelado.",
|
|
79
74
|
};
|
package/dist/i18n/pt-BR.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const ptBR = {
|
|
2
|
-
"subtitle": "sistema · gpu ·
|
|
2
|
+
"subtitle": "sistema · gpu · git",
|
|
3
3
|
"paused": "PAUSADO",
|
|
4
4
|
"panel.cpu": "CPU",
|
|
5
5
|
"panel.mem": "MEMÓRIA",
|
|
@@ -39,16 +39,14 @@ export const ptBR = {
|
|
|
39
39
|
"git.branch": "BRANCH",
|
|
40
40
|
"git.state": "ESTADO",
|
|
41
41
|
"git.host": "HOST",
|
|
42
|
-
"git.
|
|
43
|
-
"git.
|
|
42
|
+
"git.uncommitted": "sem commit",
|
|
43
|
+
"git.allClear": "✓ todos os {n} repos em dia",
|
|
44
44
|
"git.local": "local",
|
|
45
45
|
"git.server": "— servidor zgit ({container}): {list} ({n})",
|
|
46
46
|
"git.serverEmpty": "— servidor zgit ({container}): sem repos",
|
|
47
47
|
"gpu.usage": "Uso",
|
|
48
48
|
"gpu.mem": "mem {used}/{alloc}",
|
|
49
49
|
"temp.unavailable": "sensores indisponíveis",
|
|
50
|
-
"temp.waitingSudo": "aguardando sudo",
|
|
51
|
-
"temp.needsSudo": "energia: requer sudo",
|
|
52
50
|
"spark.collecting": "coletando…",
|
|
53
51
|
"spark.lastMin": "último min: {range}",
|
|
54
52
|
"tokens.spent": "Gasto hoje: ${cost} · {messages} mensagens",
|
|
@@ -73,7 +71,4 @@ export const ptBR = {
|
|
|
73
71
|
"key.pause": "Pausar",
|
|
74
72
|
"key.faster": "Mais rápido",
|
|
75
73
|
"key.slower": "Mais lento",
|
|
76
|
-
"cli.sudoNeed": "lattice precisa de sudo para ler energia (powermetrics).",
|
|
77
|
-
"cli.sudoFail": "sudo indisponível — seguindo sem dados de energia.",
|
|
78
|
-
"cli.sudoCancel": "cancelado.",
|
|
79
74
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeluizr/lattice",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Real-time terminal dashboard for macOS Apple Silicon — GPU, power, temperatures/fans, per-disk I/O (incl. /Volumes), network, memory, processes, and AI token cost.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"terminal",
|
|
35
35
|
"dashboard",
|
|
36
36
|
"gpu",
|
|
37
|
-
"
|
|
37
|
+
"git",
|
|
38
38
|
"htop",
|
|
39
39
|
"ink",
|
|
40
40
|
"cli"
|
package/dist/collectors/power.js
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Power / frequency via `powermetrics` (REQUIRES sudo).
|
|
3
|
-
*
|
|
4
|
-
* powermetrics streams plist samples on stdout separated by a NUL byte (\x00).
|
|
5
|
-
* We run it as a long-lived subprocess and keep the latest parsed sample. Power
|
|
6
|
-
* fields arrive in milliwatts; we convert to watts.
|
|
7
|
-
*/
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
9
|
-
import * as plist from "plist";
|
|
10
|
-
import bplist from "bplist-parser";
|
|
11
|
-
function parseSample(raw) {
|
|
12
|
-
if (raw.length === 0)
|
|
13
|
-
return null;
|
|
14
|
-
try {
|
|
15
|
-
// binary plist?
|
|
16
|
-
if (raw.length >= 6 && raw.subarray(0, 6).toString("latin1") === "bplist") {
|
|
17
|
-
const arr = bplist.parseBuffer(raw);
|
|
18
|
-
return arr?.[0] ?? null;
|
|
19
|
-
}
|
|
20
|
-
// XML plist
|
|
21
|
-
const text = raw.toString("utf8");
|
|
22
|
-
const start = text.indexOf("<?xml");
|
|
23
|
-
if (start === -1)
|
|
24
|
-
return null;
|
|
25
|
-
return plist.parse(text.slice(start));
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
function mwToW(d, key) {
|
|
32
|
-
const v = d?.[key];
|
|
33
|
-
return typeof v === "number" ? v / 1000 : null;
|
|
34
|
-
}
|
|
35
|
-
export class PowerCollector {
|
|
36
|
-
intervalMs;
|
|
37
|
-
latest = null;
|
|
38
|
-
error = null;
|
|
39
|
-
proc = null;
|
|
40
|
-
buf = Buffer.alloc(0);
|
|
41
|
-
stopped = false;
|
|
42
|
-
constructor(intervalMs = 1000) {
|
|
43
|
-
this.intervalMs = intervalMs;
|
|
44
|
-
}
|
|
45
|
-
start() {
|
|
46
|
-
try {
|
|
47
|
-
this.proc = spawn("sudo", [
|
|
48
|
-
"powermetrics",
|
|
49
|
-
"--samplers",
|
|
50
|
-
"cpu_power,gpu_power,thermal",
|
|
51
|
-
"-i",
|
|
52
|
-
String(this.intervalMs),
|
|
53
|
-
"-f",
|
|
54
|
-
"plist",
|
|
55
|
-
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
56
|
-
}
|
|
57
|
-
catch (e) {
|
|
58
|
-
this.error = `failed to start powermetrics: ${e}`;
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
this.proc.stdout?.on("data", (chunk) => {
|
|
62
|
-
if (this.stopped)
|
|
63
|
-
return;
|
|
64
|
-
this.buf = Buffer.concat([this.buf, chunk]);
|
|
65
|
-
let nul;
|
|
66
|
-
while ((nul = this.buf.indexOf(0x00)) !== -1) {
|
|
67
|
-
const raw = this.buf.subarray(0, nul);
|
|
68
|
-
this.buf = this.buf.subarray(nul + 1);
|
|
69
|
-
const sample = parseSample(raw);
|
|
70
|
-
if (sample)
|
|
71
|
-
this.latest = sample;
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
this.proc.on("error", (e) => {
|
|
75
|
-
this.error = String(e);
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
read() {
|
|
79
|
-
const d = this.latest;
|
|
80
|
-
if (!d)
|
|
81
|
-
return null;
|
|
82
|
-
const proc = d.processor ?? {};
|
|
83
|
-
const gpu = d.gpu ?? {};
|
|
84
|
-
const freqHz = gpu.freq_hz;
|
|
85
|
-
return {
|
|
86
|
-
cpuW: mwToW(proc, "cpu_power"),
|
|
87
|
-
gpuW: mwToW(proc, "gpu_power"),
|
|
88
|
-
aneW: mwToW(proc, "ane_power"),
|
|
89
|
-
packageW: mwToW(proc, "combined_power") ?? mwToW(proc, "package_power"),
|
|
90
|
-
gpuFreqMhz: typeof freqHz === "number" ? freqHz / 1e6 : null,
|
|
91
|
-
thermal: d.thermal_pressure ?? null,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
stop() {
|
|
95
|
-
this.stopped = true;
|
|
96
|
-
if (this.proc) {
|
|
97
|
-
try {
|
|
98
|
-
this.proc.kill("SIGTERM");
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
// ignore
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|