@termix-ssh/plugin-sdk 1.0.20 → 1.0.21

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/PLUGIN-GUIDE.md +5 -2
  3. package/dist/host/{PluginsTab-ZSMTJ5C5.js → PluginsTab-HQGSDSHS.js} +7 -5
  4. package/dist/host/{chunk-J3ZQPBDK.js → chunk-A5CULDWV.js} +10 -5
  5. package/dist/host/{chunk-2MU6WNSX.js → chunk-FGX27MAA.js} +1 -1
  6. package/dist/host/{chunk-EYONBBRJ.js → chunk-GTHINW3L.js} +1 -1
  7. package/dist/host/{chunk-7LEJ3HYF.js → chunk-JFQGI4OD.js} +5 -3
  8. package/dist/host/{chunk-CITUENSU.js → chunk-WWFRBU2E.js} +3 -2
  9. package/dist/host/{linked-server-OUF3POT7.js → linked-server-UDSFY3N7.js} +1 -1
  10. package/dist/host/{rbac-api-LZVENREG.js → rbac-api-AAAPMF52.js} +1 -1
  11. package/dist/host/testing-host.js +4 -4
  12. package/dist/host/types/src/ui/api/look-api.d.ts +2 -0
  13. package/dist/host/types/src/ui/api/plugins-api.d.ts +1 -1
  14. package/dist/host/types/src/ui/lib/app-return.d.ts +13 -0
  15. package/dist/host/types/src/ui/plugins/plugin-model.d.ts +1 -1
  16. package/dist/host/ui.js +67 -24
  17. package/dist/host/{use-permissions-BYCDS5PD.js → use-permissions-VAND5GEG.js} +1 -1
  18. package/dist/host-commands.d.ts +2 -6
  19. package/dist/host-commands.js +3 -6
  20. package/dist/host-scripts/docker.d.ts +137 -0
  21. package/dist/host-scripts/docker.js +345 -0
  22. package/dist/host-scripts/index.d.ts +15 -0
  23. package/dist/host-scripts/index.js +15 -0
  24. package/dist/host-scripts/journal.d.ts +45 -0
  25. package/dist/host-scripts/journal.js +194 -0
  26. package/dist/host-scripts/metrics.d.ts +134 -0
  27. package/dist/host-scripts/metrics.js +320 -0
  28. package/dist/host-scripts/openrc.d.ts +6 -0
  29. package/dist/host-scripts/openrc.js +90 -0
  30. package/dist/host-scripts/shell.d.ts +43 -0
  31. package/dist/host-scripts/shell.js +95 -0
  32. package/dist/host-scripts/systemd.d.ts +73 -0
  33. package/dist/host-scripts/systemd.js +320 -0
  34. package/dist/host-scripts/text.d.ts +23 -0
  35. package/dist/host-scripts/text.js +111 -0
  36. package/package.json +5 -1
@@ -0,0 +1,194 @@
1
+ /** journalctl scripts and parsers. */
2
+ import { isUnit } from "./systemd.js";
3
+ import { HostScriptError, shellQuote as q, utf8Decode } from "./text.js";
4
+ export const MAX_JOURNAL_LINES = 5000;
5
+ const FIELDS = "MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER,_PID,_COMM";
6
+ const RELATIVE_RE = /^-\d{1,5}(s|m|min|h|d|w)$/;
7
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/;
8
+ const WORDS = ["today", "yesterday", "now"];
9
+ export function isJournalTime(value) {
10
+ return (RELATIVE_RE.test(value) || DATE_RE.test(value) || WORDS.includes(value));
11
+ }
12
+ function str(value) {
13
+ return typeof value === "string" ? value.trim() : "";
14
+ }
15
+ /** Reads journal filters from a query string. Anything odd throws a HostScriptError. */
16
+ export function parseJournalQuery(input) {
17
+ const rawUnits = Array.isArray(input.unit) ? input.unit : [input.unit];
18
+ const units = rawUnits.map(str).filter(Boolean);
19
+ for (const unit of units) {
20
+ if (!isUnit(unit))
21
+ throw new HostScriptError("That is not a valid unit name");
22
+ }
23
+ if (units.length > 20)
24
+ throw new HostScriptError("Too many units");
25
+ const query = {
26
+ units,
27
+ lines: 300,
28
+ kernel: input.kernel === "true" || input.kernel === true,
29
+ };
30
+ const priority = str(input.priority);
31
+ if (priority) {
32
+ const value = Number(priority);
33
+ if (!Number.isInteger(value) || value < 0 || value > 7) {
34
+ throw new HostScriptError("priority must be 0 to 7");
35
+ }
36
+ query.priority = value;
37
+ }
38
+ for (const key of ["since", "until"]) {
39
+ const value = str(input[key]);
40
+ if (!value)
41
+ continue;
42
+ if (!isJournalTime(value))
43
+ throw new HostScriptError(`${key} is not a valid time`);
44
+ query[key] = value;
45
+ }
46
+ const boot = str(input.boot);
47
+ if (boot) {
48
+ if (!/^(-?\d{1,4}|[0-9a-f]{32})$/.test(boot))
49
+ throw new HostScriptError("boot is not valid");
50
+ query.boot = boot;
51
+ }
52
+ const grep = typeof input.grep === "string" ? input.grep : "";
53
+ if (grep) {
54
+ if (grep.length > 200 || /[\r\n\0]/.test(grep)) {
55
+ throw new HostScriptError("The search is too long");
56
+ }
57
+ query.grep = grep;
58
+ }
59
+ const lines = Number(str(input.lines) || 300);
60
+ if (Number.isFinite(lines) && lines > 0) {
61
+ query.lines = Math.min(MAX_JOURNAL_LINES, Math.round(lines));
62
+ }
63
+ return query;
64
+ }
65
+ /** journalctl arguments for a query, each one quoted. */
66
+ export function journalArgs(query, options = {}) {
67
+ const args = ["journalctl", "--no-pager", "-q", "-o", "json"];
68
+ args.push(`--output-fields=${FIELDS}`);
69
+ if (options.follow)
70
+ args.push("-f");
71
+ if (query.lines)
72
+ args.push("-n", String(Math.floor(query.lines)));
73
+ for (const unit of query.units) {
74
+ if (!isUnit(unit))
75
+ throw new HostScriptError("That is not a valid unit name");
76
+ args.push("-u", q(unit));
77
+ }
78
+ if (query.kernel)
79
+ args.push("-k");
80
+ if (query.priority !== undefined)
81
+ args.push("-p", String(Math.floor(query.priority)));
82
+ if (query.since)
83
+ args.push("--since", q(query.since));
84
+ if (query.until)
85
+ args.push("--until", q(query.until));
86
+ if (query.boot)
87
+ args.push("-b", q(query.boot));
88
+ if (query.grep && options.useGrep !== false) {
89
+ args.push("--case-sensitive=false", "-g", q(query.grep));
90
+ }
91
+ return args.join(" ");
92
+ }
93
+ export function journalScript(query) {
94
+ // Older journalctl builds have no -g. Then the search runs on our side instead.
95
+ const noGrep = journalArgs({ ...query, lines: query.grep ? MAX_JOURNAL_LINES : query.lines }, { useGrep: false });
96
+ return `
97
+ command -v journalctl >/dev/null 2>&1 || { echo "SCRIPT_ERR:This host has no journal"; exit 1; }
98
+ ${journalArgs(query)} 2>/tmp/termix-journal.$$
99
+ RC=$?
100
+ if [ $RC -ne 0 ] && grep -qi "pattern" /tmp/termix-journal.$$; then
101
+ rm -f /tmp/termix-journal.$$
102
+ echo "@@TERMIX_NOGREP@@"
103
+ ${noGrep}
104
+ exit 0
105
+ fi
106
+ cat /tmp/termix-journal.$$ >&2; rm -f /tmp/termix-journal.$$
107
+ exit $RC
108
+ `;
109
+ }
110
+ function messageOf(value) {
111
+ if (typeof value === "string")
112
+ return value;
113
+ if (Array.isArray(value)) {
114
+ return utf8Decode(value.filter((n) => typeof n === "number"));
115
+ }
116
+ return value == null ? "" : String(value);
117
+ }
118
+ /** One `journalctl -o json` line, or null when it is not an entry. */
119
+ export function parseEntry(line) {
120
+ const text = line.trim();
121
+ if (!text.startsWith("{"))
122
+ return null;
123
+ let raw;
124
+ try {
125
+ raw = JSON.parse(text);
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ const micros = Number(raw.__REALTIME_TIMESTAMP);
131
+ const priority = Number(raw.PRIORITY);
132
+ return {
133
+ time: Number.isFinite(micros) ? Math.floor(micros / 1000) : 0,
134
+ priority: Number.isInteger(priority) ? priority : 6,
135
+ unit: messageOf(raw._SYSTEMD_UNIT),
136
+ ident: messageOf(raw.SYSLOG_IDENTIFIER ?? raw._COMM),
137
+ pid: messageOf(raw._PID),
138
+ message: messageOf(raw.MESSAGE),
139
+ };
140
+ }
141
+ export function parseJournal(output, query) {
142
+ const fallback = output.includes("@@TERMIX_NOGREP@@");
143
+ const body = fallback ? output.split("@@TERMIX_NOGREP@@")[1] : output;
144
+ let entries = body
145
+ .split("\n")
146
+ .map(parseEntry)
147
+ .filter((entry) => entry !== null);
148
+ if (fallback && query.grep) {
149
+ const needle = query.grep.toLowerCase();
150
+ entries = entries
151
+ .filter((entry) => entry.message.toLowerCase().includes(needle))
152
+ .slice(-query.lines);
153
+ }
154
+ return entries;
155
+ }
156
+ export const BOOTS_SCRIPT = `journalctl --list-boots --no-pager 2>/dev/null | tail -n 50; exit 0\n`;
157
+ export function parseBoots(output) {
158
+ const boots = [];
159
+ for (const line of output.split("\n")) {
160
+ const match = /^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\w{3} \S+ \S+ \S+)\s*.?\s*(\w{3} \S+ \S+ \S+)?/.exec(line);
161
+ if (!match)
162
+ continue;
163
+ boots.push({
164
+ offset: Number(match[1]),
165
+ id: match[2],
166
+ first: match[3],
167
+ last: match[4] ?? "",
168
+ });
169
+ }
170
+ return boots.reverse();
171
+ }
172
+ export const USAGE_SCRIPT = `journalctl --disk-usage 2>&1; exit 0\n`;
173
+ export function parseUsage(output) {
174
+ return /take up (\S+)/.exec(output)?.[1] ?? "";
175
+ }
176
+ const SIZE_RE = /^\d{1,6}(K|M|G)$/;
177
+ const AGE_RE = /^\d{1,4}(d|w|month|months|year|years)$/;
178
+ export function vacuumScript(body) {
179
+ const size = str(body.size);
180
+ const time = str(body.time);
181
+ if (!size && !time)
182
+ throw new HostScriptError("Give a size or an age");
183
+ if (size && !SIZE_RE.test(size))
184
+ throw new HostScriptError("Size looks like 500M or 2G");
185
+ if (time && !AGE_RE.test(time))
186
+ throw new HostScriptError("Age looks like 7d or 4w");
187
+ const flags = [
188
+ size ? `--vacuum-size=${size}` : "",
189
+ time ? `--vacuum-time=${time}` : "",
190
+ ]
191
+ .filter(Boolean)
192
+ .join(" ");
193
+ return `journalctl ${flags} 2>&1 || { echo "SCRIPT_ERR:journalctl vacuum failed"; exit 1; }\n`;
194
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Linux metrics: one awk sample of /proc for CPU, memory, load, network and
3
+ * processes, plus df parsing for disks.
4
+ */
5
+ /** The live sample command. The page size never changes on a host, so pass it back once known. */
6
+ export declare const liveCmd: (pageBytes?: number) => string;
7
+ export interface LiveSample {
8
+ /** The admin minimum between samples; the client never polls faster. */
9
+ minIntervalS?: number;
10
+ cpu: number | null;
11
+ cpuMix?: {
12
+ user: number;
13
+ system: number;
14
+ iowait: number;
15
+ steal: number;
16
+ };
17
+ cores: Array<number | null>;
18
+ coreIds: string[];
19
+ memTotalKb: number;
20
+ memCachedKb: number;
21
+ memFreeKb: number;
22
+ memUsedKb: number;
23
+ swapTotalKb: number;
24
+ swapUsedKb: number;
25
+ load: [number, number, number];
26
+ net: Array<{
27
+ name: string;
28
+ rxBps: number | null;
29
+ txBps: number | null;
30
+ }>;
31
+ count: number;
32
+ running: number;
33
+ procs: Array<{
34
+ pid: number;
35
+ name: string;
36
+ cpu: number;
37
+ memKb: number;
38
+ state: string;
39
+ ppid: number;
40
+ }>;
41
+ /** Seconds since boot. */
42
+ uptime?: number;
43
+ /** Total bytes received and sent on every interface but lo, since boot. */
44
+ netTotals?: {
45
+ rx: number;
46
+ tx: number;
47
+ };
48
+ }
49
+ /** What the next call needs from this one. */
50
+ export interface LivePrev {
51
+ at: number;
52
+ uptime: number;
53
+ pageBytes: number;
54
+ cpu: Map<string, {
55
+ total: number;
56
+ idle: number;
57
+ }>;
58
+ /** Aggregate user nice system idle iowait irq softirq steal ticks, for the breakdown. */
59
+ cpuRaw?: number[];
60
+ /** Keyed by pid:starttime so a reused pid never inherits another process's ticks. */
61
+ ticks: Map<string, number>;
62
+ net: Map<string, {
63
+ rx: number;
64
+ tx: number;
65
+ }>;
66
+ }
67
+ /** Total and idle ticks from a /proc/stat `cpu` line. */
68
+ export declare function parseCpuLine(cpuLine: string): {
69
+ total: number;
70
+ idle: number;
71
+ } | undefined;
72
+ /** Parses `liveCmd()` output. Rates need the `next` of the previous call. */
73
+ export declare function parseLive(output: string, prev: LivePrev | undefined, at?: number): {
74
+ sample: LiveSample;
75
+ next: LivePrev;
76
+ };
77
+ /** GNU df sizes in KiB, without the header. */
78
+ export declare const DF_BYTES_COMMAND = "df -TkP | tail -n +2";
79
+ /** GNU df sizes as short human text, without the header. */
80
+ export declare const DF_HUMAN_COMMAND = "df -hT -P | tail -n +2";
81
+ export interface DfRow {
82
+ filesystem: string;
83
+ type: string;
84
+ mount: string;
85
+ parts: string[];
86
+ }
87
+ export interface DiskFilesystem {
88
+ filesystem: string;
89
+ type: string;
90
+ mount: string;
91
+ percent: number | null;
92
+ usedHuman: string | null;
93
+ totalHuman: string | null;
94
+ availableHuman: string | null;
95
+ usedBytes: number | null;
96
+ totalBytes: number | null;
97
+ availableBytes: number | null;
98
+ label?: string;
99
+ }
100
+ export interface MonitoredMount {
101
+ path: string;
102
+ label?: string;
103
+ }
104
+ /**
105
+ * Parses `df -T -P` style output: Filesystem, Type, then the size columns,
106
+ * with Mounted-on last. Pseudo filesystems are left out.
107
+ */
108
+ export declare function parseDfLines(output: string): DfRow[];
109
+ /** The most used real filesystem in a `df -TkP` row set. */
110
+ export declare function findWorstMountIndex(bytesRows: DfRow[]): {
111
+ index: number;
112
+ usedBytes: number;
113
+ totalBytes: number;
114
+ };
115
+ /**
116
+ * Merges `df -TkP` size rows (parts[2..4] in `blockSizeBytes` units) and
117
+ * `df -hT -P` human rows into one list. Human rows only supply the display
118
+ * text, matched by mount point so a mismatched row count can't shift them.
119
+ */
120
+ export declare function buildFilesystemList(bytesRows: DfRow[], humanRows: DfRow[], blockSizeBytes?: number): DiskFilesystem[];
121
+ /**
122
+ * The headline disk is the root filesystem. On macOS "/" is the small sealed
123
+ * system volume, so the data volume wins there. With no root mount the most
124
+ * used one is shown.
125
+ */
126
+ export declare function selectPrimaryFilesystem(filesystems: DiskFilesystem[]): DiskFilesystem | null;
127
+ /**
128
+ * Drops mounts by exact path ("/mnt/nas") or by filesystem type substring
129
+ * ("nfs" matches nfs and nfs4), so network shares can be hidden.
130
+ */
131
+ export declare function filterExcludedFilesystems(filesystems: DiskFilesystem[], excludedMounts?: string[] | null): DiskFilesystem[];
132
+ export declare function mergeMonitoredFilesystems(filesystems: DiskFilesystem[], monitored: MonitoredMount[], customFilesystems: Array<DiskFilesystem | null>): DiskFilesystem[];
133
+ /** Host name, OS, kernel and arch in one exec, as key=value lines. */
134
+ export declare const SYSTEM_INFO_COMMAND = "echo \"hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)\"; echo \"os=$( . /etc/os-release 2>/dev/null; echo \"$PRETTY_NAME\")\"; echo \"kernel=$(uname -r 2>/dev/null)\"; echo \"arch=$(uname -m 2>/dev/null)\"";
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Linux metrics: one awk sample of /proc for CPU, memory, load, network and
3
+ * processes, plus df parsing for disks.
4
+ */
5
+ // One awk reads every /proc file itself, so a sample is one exec of awk instead
6
+ // of a shell plus six tools. Output sections are split by @@; each process line
7
+ // is pid, utime+stime ticks, rss pages, starttime, state, ppid, comm.
8
+ const AWK = String.raw `BEGIN{f="/proc/stat";while((getline l<f)>0)if(l~/^cpu/)print l;close(f);print "@@";f="/proc/meminfo";while((getline l<f)>0)if(l~/^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):/)print l;close(f);print "@@";f="/proc/loadavg";getline l<f;print l;close(f);f="/proc/uptime";getline l<f;print l;close(f);print "@@";f="/proc/net/dev";while((getline l<f)>0)print l;close(f);print "@@";print pg;print "@@";for(i=1;i<ARGC;i++){f=ARGV[i];if((getline s<f)>0){a=index(s,"(");if(a&&match(s,/\)[^)]*$/)){split(substr(s,RSTART+2),x," ");print substr(s,1,a-2)" "(x[12]+x[13])" "x[22]"\t"x[20]"\t"x[1]" "x[2]" "substr(s,a+1,RSTART-a-1)}}close(f)}}`;
9
+ /** The live sample command. The page size never changes on a host, so pass it back once known. */
10
+ export const liveCmd = (pageBytes) => "awk -v pg=" +
11
+ (pageBytes ?? '"$(getconf PAGESIZE)"') +
12
+ " '" +
13
+ AWK +
14
+ "' /proc/[0-9]*/stat";
15
+ /** Total and idle ticks from a /proc/stat `cpu` line. */
16
+ export function parseCpuLine(cpuLine) {
17
+ const parts = cpuLine.trim().split(/\s+/);
18
+ if (parts[0] !== "cpu")
19
+ return undefined;
20
+ const nums = parts
21
+ .slice(1)
22
+ .map((n) => Number(n))
23
+ .filter((n) => Number.isFinite(n));
24
+ if (nums.length < 4)
25
+ return undefined;
26
+ const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
27
+ const total = nums.reduce((a, b) => a + b, 0);
28
+ return { total, idle };
29
+ }
30
+ const TOP_N = 60;
31
+ /** Parses `liveCmd()` output. Rates need the `next` of the previous call. */
32
+ export function parseLive(output, prev, at = Date.now()) {
33
+ const [cpuS = "", memS = "", loadS = "", netS = "", pageS = "", procS = ""] = output.split("@@\n");
34
+ // Only the first 8 fields: guest and guest_nice are already inside user and nice.
35
+ const cpu = new Map();
36
+ let cpuRaw;
37
+ for (const l of cpuS.split("\n")) {
38
+ const name = l.split(/\s+/)[0];
39
+ if (!name.startsWith("cpu"))
40
+ continue;
41
+ if (name === "cpu")
42
+ cpuRaw = l.split(/\s+/).slice(1, 9).map(Number);
43
+ const c = parseCpuLine(l
44
+ .replace(/^cpu\d*/, "cpu")
45
+ .split(/\s+/)
46
+ .slice(0, 9)
47
+ .join(" "));
48
+ if (c)
49
+ cpu.set(name, c);
50
+ }
51
+ // Matched by name, so an offline core does not shift the ones after it.
52
+ const pct = (name) => {
53
+ const p = prev?.cpu.get(name);
54
+ const c = cpu.get(name);
55
+ if (!p || !c || c.total <= p.total)
56
+ return null;
57
+ const used = c.total - p.total - (c.idle - p.idle);
58
+ return Math.max(0, Math.min(100, (used / (c.total - p.total)) * 100));
59
+ };
60
+ // Where the busy time went since the last sample, as percent of all ticks.
61
+ let cpuMix;
62
+ if (prev?.cpuRaw && cpuRaw && cpuRaw.length === 8) {
63
+ const d = cpuRaw.map((v, i) => v - (prev.cpuRaw?.[i] ?? 0));
64
+ const total = d.reduce((a, b) => a + b, 0);
65
+ if (total > 0 && d.every((v) => v >= 0)) {
66
+ const p = (n) => (n / total) * 100;
67
+ cpuMix = {
68
+ user: p(d[0] + d[1]),
69
+ system: p(d[2] + d[5] + d[6]),
70
+ iowait: p(d[4]),
71
+ steal: p(d[7]),
72
+ };
73
+ }
74
+ }
75
+ const mem = {};
76
+ for (const l of memS.split("\n")) {
77
+ const m = l.match(/^(\w+):\s+(\d+)/);
78
+ if (m)
79
+ mem[m[1]] = Number(m[2]);
80
+ }
81
+ const [la = "", up = ""] = loadS.trim().split("\n");
82
+ const laParts = la.split(/\s+/).map(Number);
83
+ const uptime = Number(up.split(/\s+/)[0]) || 0;
84
+ const nets = new Map();
85
+ for (const l of netS.split("\n")) {
86
+ const [name, rest] = l.split(":");
87
+ if (!rest || name.trim() === "lo")
88
+ continue;
89
+ const f = rest.trim().split(/\s+/);
90
+ nets.set(name.trim(), { rx: Number(f[0]) || 0, tx: Number(f[8]) || 0 });
91
+ }
92
+ // A reboot makes uptime go backwards, so rates start again.
93
+ const dt = prev && uptime > prev.uptime ? uptime - prev.uptime : 0;
94
+ const rate = (now, before) => dt > 0 && before !== undefined && now >= before
95
+ ? (now - before) / dt
96
+ : null;
97
+ const pageBytes = Number(pageS.trim()) || prev?.pageBytes || 4096;
98
+ const pageKb = pageBytes / 1024;
99
+ const aggDelta = prev && cpu.has("cpu") && prev.cpu.has("cpu")
100
+ ? cpu.get("cpu").total - prev.cpu.get("cpu").total
101
+ : 0;
102
+ const cores = [...cpu.keys()].filter((k) => k !== "cpu");
103
+ const ncpu = Math.max(1, cores.length);
104
+ const ticks = new Map();
105
+ const procs = [];
106
+ for (const l of procS.split("\n")) {
107
+ const [pid, t, rss, start, state, ppid, ...rest] = l.split("\t");
108
+ const name = rest.join("\t");
109
+ if (!name)
110
+ continue;
111
+ const key = pid + ":" + start;
112
+ const tk = Number(t) || 0;
113
+ ticks.set(key, tk);
114
+ const before = prev?.ticks.get(key);
115
+ procs.push({
116
+ pid: Number(pid),
117
+ name,
118
+ cpu: before !== undefined && aggDelta > 0 && tk >= before
119
+ ? ((tk - before) / aggDelta) * ncpu * 100
120
+ : 0,
121
+ memKb: (Number(rss) || 0) * pageKb,
122
+ state: state || "?",
123
+ ppid: Number(ppid) || 0,
124
+ });
125
+ }
126
+ procs.sort((a, b) => b.cpu - a.cpu || b.memKb - a.memKb);
127
+ // Awk exits 0 when /proc is missing (macOS, restricted containers); that is not a sample.
128
+ if (!cpu.has("cpu") || !mem.MemTotal || !uptime) {
129
+ throw new Error("live sample unavailable: /proc data missing");
130
+ }
131
+ const memTotalKb = mem.MemTotal;
132
+ const swapTotalKb = mem.SwapTotal ?? 0;
133
+ let rxTotal = 0;
134
+ let txTotal = 0;
135
+ for (const c of nets.values()) {
136
+ rxTotal += c.rx;
137
+ txTotal += c.tx;
138
+ }
139
+ return {
140
+ sample: {
141
+ cpu: pct("cpu"),
142
+ cores: cores.map(pct),
143
+ coreIds: cores.map((k) => k.slice(3)),
144
+ cpuMix,
145
+ memTotalKb,
146
+ memCachedKb: (mem.Buffers ?? 0) + (mem.Cached ?? 0),
147
+ memFreeKb: mem.MemFree ?? 0,
148
+ memUsedKb: memTotalKb - (mem.MemAvailable ?? 0),
149
+ swapTotalKb,
150
+ swapUsedKb: swapTotalKb - (mem.SwapFree ?? 0),
151
+ load: [laParts[0] || 0, laParts[1] || 0, laParts[2] || 0],
152
+ net: [...nets].map(([name, c]) => ({
153
+ name,
154
+ rxBps: rate(c.rx, prev?.net.get(name)?.rx),
155
+ txBps: rate(c.tx, prev?.net.get(name)?.tx),
156
+ })),
157
+ count: procs.length,
158
+ running: procs.filter((p) => p.state === "R").length,
159
+ procs: procs.slice(0, TOP_N),
160
+ uptime,
161
+ netTotals: { rx: rxTotal, tx: txTotal },
162
+ },
163
+ next: { at, uptime, pageBytes, cpu, cpuRaw, ticks, net: nets },
164
+ };
165
+ }
166
+ // Disks
167
+ const DARWIN_DATA_MOUNT = "/System/Volumes/Data";
168
+ const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
169
+ /** GNU df sizes in KiB, without the header. */
170
+ export const DF_BYTES_COMMAND = "df -TkP | tail -n +2";
171
+ /** GNU df sizes as short human text, without the header. */
172
+ export const DF_HUMAN_COMMAND = "df -hT -P | tail -n +2";
173
+ function toFixedNum(n, digits = 2) {
174
+ if (typeof n !== "number" || !Number.isFinite(n))
175
+ return null;
176
+ return Number(n.toFixed(digits));
177
+ }
178
+ /**
179
+ * Parses `df -T -P` style output: Filesystem, Type, then the size columns,
180
+ * with Mounted-on last. Pseudo filesystems are left out.
181
+ */
182
+ export function parseDfLines(output) {
183
+ return output
184
+ .split("\n")
185
+ .map((l) => l.trim())
186
+ .filter(Boolean)
187
+ .map((line) => {
188
+ const parts = line.split(/\s+/);
189
+ return {
190
+ filesystem: parts[0] || "",
191
+ type: parts[1] || "",
192
+ mount: parts[6] || "",
193
+ parts,
194
+ };
195
+ })
196
+ .filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
197
+ }
198
+ /** The most used real filesystem in a `df -TkP` row set. */
199
+ export function findWorstMountIndex(bytesRows) {
200
+ let worstIndex = -1;
201
+ let worstUsedBytes = -1;
202
+ let worstTotalBytes = 0;
203
+ bytesRows.forEach((row, index) => {
204
+ const totalBytes = Number(row.parts[2]);
205
+ const usedBytes = Number(row.parts[3]);
206
+ if (!Number.isFinite(totalBytes) ||
207
+ !Number.isFinite(usedBytes) ||
208
+ totalBytes <= 0) {
209
+ return;
210
+ }
211
+ const usedRatio = usedBytes / totalBytes;
212
+ const worstRatio = worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
213
+ if (usedRatio > worstRatio) {
214
+ worstIndex = index;
215
+ worstUsedBytes = usedBytes;
216
+ worstTotalBytes = totalBytes;
217
+ }
218
+ });
219
+ return {
220
+ index: worstIndex,
221
+ usedBytes: worstUsedBytes,
222
+ totalBytes: worstTotalBytes,
223
+ };
224
+ }
225
+ /**
226
+ * Merges `df -TkP` size rows (parts[2..4] in `blockSizeBytes` units) and
227
+ * `df -hT -P` human rows into one list. Human rows only supply the display
228
+ * text, matched by mount point so a mismatched row count can't shift them.
229
+ */
230
+ export function buildFilesystemList(bytesRows, humanRows, blockSizeBytes = 1) {
231
+ const aligned = humanRows.length === bytesRows.length;
232
+ return bytesRows
233
+ .map((row, index) => {
234
+ const totalBytes = Number(row.parts[2]) * blockSizeBytes;
235
+ const usedBytes = Number(row.parts[3]) * blockSizeBytes;
236
+ const availableBytes = Number(row.parts[4]) * blockSizeBytes;
237
+ if (!Number.isFinite(totalBytes) || totalBytes <= 0)
238
+ return null;
239
+ const humanRow = aligned
240
+ ? humanRows[index]
241
+ : humanRows.find((h) => h.mount === row.mount);
242
+ const percent = Number.isFinite(usedBytes)
243
+ ? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
244
+ : null;
245
+ return {
246
+ filesystem: row.filesystem,
247
+ type: row.type,
248
+ mount: row.mount,
249
+ percent: toFixedNum(percent, 0),
250
+ usedHuman: humanRow?.parts[3] || null,
251
+ totalHuman: humanRow?.parts[2] || null,
252
+ availableHuman: humanRow?.parts[4] || null,
253
+ usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
254
+ totalBytes,
255
+ availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
256
+ };
257
+ })
258
+ .filter((fs) => fs !== null);
259
+ }
260
+ /**
261
+ * The headline disk is the root filesystem. On macOS "/" is the small sealed
262
+ * system volume, so the data volume wins there. With no root mount the most
263
+ * used one is shown.
264
+ */
265
+ export function selectPrimaryFilesystem(filesystems) {
266
+ if (filesystems.length === 0)
267
+ return null;
268
+ const root = filesystems.find((fs) => fs.mount === DARWIN_DATA_MOUNT) ??
269
+ filesystems.find((fs) => fs.mount === "/");
270
+ if (root)
271
+ return root;
272
+ let best = filesystems[0];
273
+ for (const fs of filesystems) {
274
+ const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
275
+ const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
276
+ if (ratio > bestRatio)
277
+ best = fs;
278
+ }
279
+ return best;
280
+ }
281
+ /**
282
+ * Drops mounts by exact path ("/mnt/nas") or by filesystem type substring
283
+ * ("nfs" matches nfs and nfs4), so network shares can be hidden.
284
+ */
285
+ export function filterExcludedFilesystems(filesystems, excludedMounts) {
286
+ if (!excludedMounts || excludedMounts.length === 0)
287
+ return filesystems;
288
+ const normalized = excludedMounts
289
+ .map((entry) => entry.trim().toLowerCase())
290
+ .filter(Boolean);
291
+ if (normalized.length === 0)
292
+ return filesystems;
293
+ return filesystems.filter((fs) => {
294
+ const mount = fs.mount.toLowerCase();
295
+ const type = fs.type.toLowerCase();
296
+ return !normalized.some((entry) => mount === entry || (type && type.includes(entry)));
297
+ });
298
+ }
299
+ export function mergeMonitoredFilesystems(filesystems, monitored, customFilesystems) {
300
+ const result = [...filesystems];
301
+ monitored.forEach((entry, index) => {
302
+ const path = entry.path.trim();
303
+ const custom = customFilesystems[index];
304
+ if (!path || !custom)
305
+ return;
306
+ const existing = result.find((fs) => fs.mount === path);
307
+ if (existing) {
308
+ existing.label = entry.label?.trim() || undefined;
309
+ return;
310
+ }
311
+ result.push({
312
+ ...custom,
313
+ mount: path,
314
+ label: entry.label?.trim() || undefined,
315
+ });
316
+ });
317
+ return result;
318
+ }
319
+ /** Host name, OS, kernel and arch in one exec, as key=value lines. */
320
+ export const SYSTEM_INFO_COMMAND = `echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"; echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"; echo "kernel=$(uname -r 2>/dev/null)"; echo "arch=$(uname -m 2>/dev/null)"`;
@@ -0,0 +1,6 @@
1
+ /** OpenRC scripts, with states mapped onto the words systemd uses. */
2
+ import { type UnitAction, type UnitSummary } from "./systemd.js";
3
+ export declare const OPENRC_LIST_SCRIPT = "\nrc-status --servicelist 2>/dev/null\necho \"@@TERMIX_RUNLEVELS@@\"\nrc-update show -v 2>/dev/null\nexit 0\n";
4
+ export declare function parseOpenRcList(output: string): UnitSummary[];
5
+ export declare function openRcActionScript(name: string, action: UnitAction): string;
6
+ export declare function openRcStatusScript(name: string): string;