shraga 0.1.80 → 0.1.82
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/dist/client/assets/{index-h0IKxLlQ.js → index-yDX1hxfO.js} +223 -223
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/client/components/MachineStats.tsx +34 -2
- package/src/client/lib/ws.ts +1 -1
- package/src/server/background-jobs.ts +51 -1
- package/src/server/claude.ts +1 -1
- package/src/server/stats.ts +102 -0
- package/src/shared/disk.ts +23 -0
package/dist/client/index.html
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
14
14
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
15
15
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-yDX1hxfO.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-J2NH6FvE.css">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.82",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import type { AgentSocket, ServerEvent } from '@/lib/ws';
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
4
|
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
|
5
|
+
import { DISK_WARN_PCT, DISK_CRIT_PCT, formatBytes } from '../../shared/disk';
|
|
5
6
|
|
|
6
7
|
type Sample = Extract<ServerEvent, { type: 'stats' }>['sample'];
|
|
7
8
|
|
|
@@ -69,6 +70,12 @@ export function MachineStats({ socket, getToken }: Props) {
|
|
|
69
70
|
<div className="w-full flex flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-[10px] text-muted-foreground/60">
|
|
70
71
|
<Metric label="cpu" value={latest.cpu} series={samples.map(s => s.cpu)} />
|
|
71
72
|
<Metric label="mem" value={latest.mem} series={samples.map(s => s.mem)} />
|
|
73
|
+
{latest.disk >= 0 && (
|
|
74
|
+
// Gauge, not sparkline: the server refreshes disk once a minute, so a 10-min trend line would
|
|
75
|
+
// be a near-flat 10-point stair that reads as broken. A current-value bar is the honest shape.
|
|
76
|
+
<Metric label="disk" value={latest.disk} severity={diskSeverity(latest.disk)}
|
|
77
|
+
title={diskTip(latest)} />
|
|
78
|
+
)}
|
|
72
79
|
<ClaudeUsageMetric getToken={getToken} />
|
|
73
80
|
</div>
|
|
74
81
|
);
|
|
@@ -302,8 +309,20 @@ export function untilLabel(iso: string | null): string | null {
|
|
|
302
309
|
/** Only severities we have actually SEEN mean something here. The vendor's vocabulary is not
|
|
303
310
|
* documented and not fully observed, so an unrecognised value falls through to the percentage
|
|
304
311
|
* thresholds — treating "anything that isn't normal" as elevated would paint the widget a
|
|
305
|
-
* permanent amber the first time the endpoint adds a benign new word.
|
|
306
|
-
|
|
312
|
+
* permanent amber the first time the endpoint adds a benign new word. 'ok' is ours, not the
|
|
313
|
+
* vendor's: it lets a metric with its OWN bands (disk) say "fine" instead of inheriting 75/90. */
|
|
314
|
+
// Thresholds come from the one shared module the server reads too (src/shared/disk.ts) — re-exported
|
|
315
|
+
// here so existing importers of this component keep their import site. Not re-declared: a second
|
|
316
|
+
// literal is a drift waiting to happen.
|
|
317
|
+
export { DISK_WARN_PCT, DISK_CRIT_PCT };
|
|
318
|
+
|
|
319
|
+
/** Disk has its own bands: 80% full is unremarkable for a disk, so it must NOT inherit the generic
|
|
320
|
+
* 75/90 percent colouring — hence an explicit 'ok' rather than falling through to those. */
|
|
321
|
+
export function diskSeverity(pct: number): string {
|
|
322
|
+
return pct >= DISK_CRIT_PCT ? 'critical' : pct >= DISK_WARN_PCT ? 'warning' : 'ok';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const ELEVATED: Record<string, string> = { critical: 'text-red-500', exceeded: 'text-red-500', warning: 'text-amber-500', warn: 'text-amber-500', ok: 'text-emerald-500' };
|
|
307
326
|
|
|
308
327
|
function level(v: number, severity?: string) {
|
|
309
328
|
const known = severity ? ELEVATED[severity] : undefined;
|
|
@@ -311,6 +330,19 @@ function level(v: number, severity?: string) {
|
|
|
311
330
|
return v >= 90 ? 'text-red-500' : v >= 75 ? 'text-amber-500' : 'text-emerald-500';
|
|
312
331
|
}
|
|
313
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Hover text. The percentage alone cannot tell you whether 93% is 3GB or 300GB left, which is the
|
|
335
|
+
* first thing anyone asks when they see it amber — so the tooltip carries the actual figures. Falls
|
|
336
|
+
* back to the bare percentage on a sample from a server too old to send the byte fields.
|
|
337
|
+
*/
|
|
338
|
+
function diskTip(s: { disk: number; diskUsedBytes?: number; diskTotalBytes?: number }): string {
|
|
339
|
+
const bounds = `warn at ${DISK_WARN_PCT}%, critical at ${DISK_CRIT_PCT}%`;
|
|
340
|
+
if (s.diskUsedBytes == null || s.diskTotalBytes == null) return `disk ${s.disk}% used — ${bounds}`;
|
|
341
|
+
const free = Math.max(0, s.diskTotalBytes - s.diskUsedBytes);
|
|
342
|
+
return `disk ${s.disk}% used — ${formatBytes(s.diskUsedBytes)} of ${formatBytes(s.diskTotalBytes)}, `
|
|
343
|
+
+ `${formatBytes(free)} free — ${bounds}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
314
346
|
function Metric({ label, value, series, title, severity }: { label: string; value: number; series?: number[]; title?: string; severity?: string }) {
|
|
315
347
|
const tone = level(value, severity);
|
|
316
348
|
// Plain-text fallback only where nothing richer exists (cpu/mem). The usage metric passes no title:
|
package/src/client/lib/ws.ts
CHANGED
|
@@ -48,7 +48,7 @@ export type ServerEvent =
|
|
|
48
48
|
| { type: 'unread_cleared'; sessionId: string }
|
|
49
49
|
| { type: 'pty_list_changed'; sessionId: string }
|
|
50
50
|
| { type: 'workspace_layout_changed' }
|
|
51
|
-
| { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number } };
|
|
51
|
+
| { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number; disk: number; diskUsedBytes?: number; diskTotalBytes?: number } };
|
|
52
52
|
|
|
53
53
|
type Listener = (event: ServerEvent) => void;
|
|
54
54
|
|
|
@@ -231,7 +231,15 @@ export async function startJob(owner: JobOwner, command: string): Promise<string
|
|
|
231
231
|
// stdin is /dev/null so a child can never block on (or steal) input; stdout+stderr go straight
|
|
232
232
|
// to the fd — nothing is buffered in this process, so a chatty job costs disk, not memory.
|
|
233
233
|
const proc = spawn('/bin/sh', ['-c', buildShell(id, cmd)], {
|
|
234
|
-
cwd: owner.cwd,
|
|
234
|
+
cwd: owner.cwd,
|
|
235
|
+
env: {
|
|
236
|
+
...childEnv(),
|
|
237
|
+
...owner.env,
|
|
238
|
+
// A long-running launcher can gate on these: only a REGISTERED job gets a completion wake,
|
|
239
|
+
// so one started in the foreground orphans silently when its 60s tool call is killed.
|
|
240
|
+
SHRAGA_JOB_ID: id,
|
|
241
|
+
SHRAGA_BG_JOB: '1',
|
|
242
|
+
},
|
|
235
243
|
detached: true, stdio: ['ignore', fd, fd],
|
|
236
244
|
});
|
|
237
245
|
j.pid = proc.pid;
|
|
@@ -251,6 +259,47 @@ export async function startJob(owner: JobOwner, command: string): Promise<string
|
|
|
251
259
|
return id;
|
|
252
260
|
}
|
|
253
261
|
|
|
262
|
+
/** The little of a spawned child this module needs to adopt one (node's ChildProcess satisfies it). */
|
|
263
|
+
type AdoptableProcess = {
|
|
264
|
+
pid?: number | null;
|
|
265
|
+
stdout?: { on(ev: 'data', cb: (chunk: unknown) => void): void } | null;
|
|
266
|
+
stderr?: { on(ev: 'data', cb: (chunk: unknown) => void): void } | null;
|
|
267
|
+
on(ev: 'close' | 'error', cb: (arg: never) => void): void;
|
|
268
|
+
unref?: () => void;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Register an ALREADY-RUNNING child as a background job. The Shell tool hands a foreground command
|
|
273
|
+
* over at its deadline instead of killing it: the work continues, and from here it is an ordinary job
|
|
274
|
+
* — polled with ShellOutput, and reported back to the session when it ends. Without this the deadline
|
|
275
|
+
* stays a kill, which is what abandoned a scheduled run midway.
|
|
276
|
+
*/
|
|
277
|
+
export function adoptJob(owner: JobOwner, command: string, proc: AdoptableProcess, seed = ''): string {
|
|
278
|
+
const id = `job-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
279
|
+
const j: JobRecord = {
|
|
280
|
+
id, sessionId: owner.sessionId, uid: owner.uid, userEmail: owner.userEmail,
|
|
281
|
+
command: command.trim(), cwd: owner.cwd, startedAt: Date.now(), status: 'running',
|
|
282
|
+
pid: proc.pid ?? undefined,
|
|
283
|
+
};
|
|
284
|
+
// The child was spawned with pipes by the caller, so output is appended here rather than dup'd to
|
|
285
|
+
// an fd the way a job we spawn ourselves does it.
|
|
286
|
+
const append = (chunk: unknown) => {
|
|
287
|
+
const text = typeof chunk === 'string' ? chunk : String(chunk ?? '');
|
|
288
|
+
if (!text) return;
|
|
289
|
+
try { writeFileSync(logFile(id), text, { flag: 'a' }); } catch { /* the job outlives its log */ }
|
|
290
|
+
};
|
|
291
|
+
if (seed) append(seed);
|
|
292
|
+
proc.stdout?.on('data', append);
|
|
293
|
+
proc.stderr?.on('data', append);
|
|
294
|
+
proc.on('error', ((err: Error) => finish(id, 'error', undefined, `adopted job error: ${err?.message ?? err}`)) as never);
|
|
295
|
+
proc.on('close', ((code: number | null) => finish(id, 'exited', code ?? undefined)) as never);
|
|
296
|
+
proc.unref?.();
|
|
297
|
+
records.set(id, j);
|
|
298
|
+
save(j);
|
|
299
|
+
console.log(`${PREFIX} adopted ${id} pid=${j.pid} session=${j.sessionId.slice(0, 8)} cmd=${j.command.slice(0, 120)}`);
|
|
300
|
+
return id;
|
|
301
|
+
}
|
|
302
|
+
|
|
254
303
|
/** Record a terminal state exactly once, then schedule the follow-up. */
|
|
255
304
|
function finish(id: string, status: JobStatus, exitCode?: number, note?: string): void {
|
|
256
305
|
const j = records.get(id);
|
|
@@ -479,6 +528,7 @@ export function sessionJobRegistry(owner: JobOwner) {
|
|
|
479
528
|
};
|
|
480
529
|
return {
|
|
481
530
|
start: (command: string) => startJob(owner, command),
|
|
531
|
+
adopt: (command: string, proc: AdoptableProcess, seed?: string) => adoptJob(owner, command, proc, seed),
|
|
482
532
|
output(id: string): string | null {
|
|
483
533
|
const j = mine(id);
|
|
484
534
|
if (!j) return null;
|
package/src/server/claude.ts
CHANGED
|
@@ -83,7 +83,7 @@ export type WsEvent =
|
|
|
83
83
|
| { type: 'done'; sessionId: string; stopReason?: 'end_turn' | 'max_turns_reached' | (string & {}); builtinHandled?: boolean }
|
|
84
84
|
| { type: 'model_resolved'; sessionId: string; model: string }
|
|
85
85
|
| { type: 'error'; message: string }
|
|
86
|
-
| { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number } };
|
|
86
|
+
| { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number; disk: number; diskUsedBytes?: number; diskTotalBytes?: number } };
|
|
87
87
|
// Add-on engines/features emit their OWN events (e.g. a duplex voice brain's `duplex_*`) through the
|
|
88
88
|
// object-typed `emitToSession()` bus (session-bus.ts) — NOT this union. So the core names none of them
|
|
89
89
|
// here, yet forwards them verbatim to clients. Keep this union the closed set of core-owned events.
|
package/src/server/stats.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// they seed from getStats() and then receive live points over WS. N users = 1 sampler.
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { statfs } from 'node:fs/promises';
|
|
6
7
|
import { execFile } from 'node:child_process';
|
|
7
8
|
import { promisify } from 'node:util';
|
|
8
9
|
|
|
@@ -13,13 +14,27 @@ export interface StatSample {
|
|
|
13
14
|
cpu: number; // 0-100, host CPU utilization since last sample
|
|
14
15
|
mem: number; // 0-100, used / total memory
|
|
15
16
|
load: number; // 1-min load average
|
|
17
|
+
disk: number; // 0-100, used of the root filesystem (cached, refreshed slowly — see refreshDisk)
|
|
18
|
+
/** Bytes used / total, for the hover tooltip. Omitted until the first successful refresh. */
|
|
19
|
+
diskUsedBytes?: number;
|
|
20
|
+
diskTotalBytes?: number;
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
/** Hourly ceiling on the disk-failure warning, so a permanently broken mount cannot flood the log. */
|
|
24
|
+
const DISK_FAIL_LOG_INTERVAL_MS = 60 * 60 * 1000;
|
|
25
|
+
|
|
18
26
|
export class StatsSamplerOptions {
|
|
19
27
|
intervalMs = 5000;
|
|
20
28
|
window = 120; // ring-buffer length (120 × 5s = 10 min trend)
|
|
29
|
+
// Disk moves in minutes, not seconds, and statfs() stats the whole filesystem — no reason to pay
|
|
30
|
+
// for it on every 5s tick. Refreshed on its own slow cadence into a cache the sample just reads.
|
|
31
|
+
diskIntervalMs = 60_000;
|
|
21
32
|
}
|
|
22
33
|
|
|
34
|
+
// Single source in ../shared/disk.ts, imported by the client widget too — re-exported here so
|
|
35
|
+
// server-side callers (and the tests) keep the same import site.
|
|
36
|
+
export { DISK_WARN_PCT, DISK_CRIT_PCT } from '../shared/disk';
|
|
37
|
+
|
|
23
38
|
export class StatsSampler {
|
|
24
39
|
public options: StatsSamplerOptions;
|
|
25
40
|
private buffer: StatSample[] = [];
|
|
@@ -30,6 +45,17 @@ export class StatsSampler {
|
|
|
30
45
|
// ~1-2s (longer under load) every 5s — stalling /api/version long enough to trip the health watchdog
|
|
31
46
|
// into false-restarting a live server. Seed from the cheap os.freemem fallback until the first refresh.
|
|
32
47
|
private memPct = 100 * (1 - os.freemem() / os.totalmem());
|
|
48
|
+
// Cached disk %, refreshed on the slow diskIntervalMs cadence. -1 until the first refresh lands, so
|
|
49
|
+
// an unread disk renders as "unknown" rather than a confident 0% (an empty disk) — and a failed
|
|
50
|
+
// refresh keeps the LAST known value rather than resetting it.
|
|
51
|
+
private diskPct = -1;
|
|
52
|
+
private diskUsedBytes: number | undefined;
|
|
53
|
+
private diskTotalBytes: number | undefined;
|
|
54
|
+
/** One refresh at a time — see refreshDisk. */
|
|
55
|
+
private diskInFlight = false;
|
|
56
|
+
/** Epoch ms of the last logged disk failure; 0 = none since the last success. */
|
|
57
|
+
private diskFailLoggedAt = 0;
|
|
58
|
+
private diskTimer: ReturnType<typeof setInterval> | null = null;
|
|
33
59
|
|
|
34
60
|
public constructor(options?: Partial<StatsSamplerOptions>) {
|
|
35
61
|
this.options = { ...new StatsSamplerOptions(), ...options };
|
|
@@ -38,6 +64,9 @@ export class StatsSampler {
|
|
|
38
64
|
start(broadcast: (data: object) => void) {
|
|
39
65
|
if (this.timer) return; // singleton — already running
|
|
40
66
|
void this.refreshMem(); // prime the cache; each tick refreshes it async for the NEXT sample
|
|
67
|
+
void this.refreshDisk();
|
|
68
|
+
this.diskTimer = setInterval(() => { void this.refreshDisk(); }, this.options.diskIntervalMs);
|
|
69
|
+
if (typeof this.diskTimer.unref === 'function') this.diskTimer.unref();
|
|
41
70
|
this.timer = setInterval(() => {
|
|
42
71
|
void this.refreshMem(); // async, non-blocking — updates this.memPct without stalling the loop
|
|
43
72
|
const sample = this.sample();
|
|
@@ -51,6 +80,7 @@ export class StatsSampler {
|
|
|
51
80
|
|
|
52
81
|
stop() {
|
|
53
82
|
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
83
|
+
if (this.diskTimer) { clearInterval(this.diskTimer); this.diskTimer = null; }
|
|
54
84
|
}
|
|
55
85
|
|
|
56
86
|
getStats(): StatSample[] {
|
|
@@ -68,12 +98,46 @@ export class StatsSampler {
|
|
|
68
98
|
cpu: Math.round(cpu),
|
|
69
99
|
mem: Math.round(this.memPct), // cached; refreshed async each tick (never blocks the loop)
|
|
70
100
|
load: Math.round(os.loadavg()[0] * 100) / 100,
|
|
101
|
+
disk: this.diskPct, // cached; refreshed every diskIntervalMs — already an integer (see diskUsedPct)
|
|
102
|
+
diskUsedBytes: this.diskUsedBytes,
|
|
103
|
+
diskTotalBytes: this.diskTotalBytes,
|
|
71
104
|
};
|
|
72
105
|
}
|
|
73
106
|
|
|
74
107
|
// Refresh the cached memory %. os.freemem() counts reclaimable memory (page cache on Linux,
|
|
75
108
|
// inactive/purgeable pages on macOS) as "used", so it reads ~95-99% even when healthy — use each OS's
|
|
76
109
|
// notion of *available* (free + reclaimable) instead. Async + bounded so it can NEVER stall the loop.
|
|
110
|
+
// Refresh the cached disk %. statfs() is the node-native stat — no `df` subprocess, same call on
|
|
111
|
+
// macOS and Linux. Any failure (unreadable mount, permission) leaves the previous value in place.
|
|
112
|
+
private async refreshDisk(): Promise<void> {
|
|
113
|
+
// A hung filesystem must not let refreshes PILE UP: without this guard a never-resolving statfs
|
|
114
|
+
// accumulates one stuck threadpool task per tick (measured: 5 in-flight at a 50ms cadence),
|
|
115
|
+
// which starves Bun's fs threadpool for every other async fs consumer.
|
|
116
|
+
if (this.diskInFlight) return;
|
|
117
|
+
this.diskInFlight = true;
|
|
118
|
+
try {
|
|
119
|
+
const fs = await statfs('/');
|
|
120
|
+
const pct = diskUsedPct(fs, process.platform);
|
|
121
|
+
if (pct != null) this.diskPct = pct;
|
|
122
|
+
const bytes = diskBytes(fs, process.platform);
|
|
123
|
+
if (bytes) { this.diskUsedBytes = bytes.used; this.diskTotalBytes = bytes.total; }
|
|
124
|
+
this.diskFailLoggedAt = 0; // a success re-arms the log, so a NEW outage is still reported
|
|
125
|
+
} catch (err) {
|
|
126
|
+
// Keep the last known value — a stale number beats a wrong 0. Never swallow silently...
|
|
127
|
+
// ...but never at one line per tick either: a permission-denied or vanished mount would emit
|
|
128
|
+
// 1440 identical warnings a day and bury everything else. Log the first failure, then at most
|
|
129
|
+
// hourly while it persists. (`fs.statfs` also does not exist before Bun 1.2.x, and this
|
|
130
|
+
// package's engines still allow >=1.0.0 — on such a host this would warn forever from boot.)
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
if (now - this.diskFailLoggedAt >= DISK_FAIL_LOG_INTERVAL_MS) {
|
|
133
|
+
this.diskFailLoggedAt = now;
|
|
134
|
+
console.warn('[stats] disk refresh failed, keeping last value', err);
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
this.diskInFlight = false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
77
141
|
private async refreshMem(): Promise<void> {
|
|
78
142
|
try {
|
|
79
143
|
if (process.platform === 'linux') {
|
|
@@ -94,6 +158,44 @@ export class StatsSampler {
|
|
|
94
158
|
}
|
|
95
159
|
}
|
|
96
160
|
|
|
161
|
+
/** Percent USED of a filesystem, from a statfs() result. Exported (and platform passed in) so BOTH
|
|
162
|
+
* branches are testable from either OS — the Linux one cannot otherwise be exercised in dev.
|
|
163
|
+
* Per-platform on purpose, like refreshMem: each OS's honest notion of "how full is it".
|
|
164
|
+
* Linux — exactly `df`'s formula, used/(used+available), so this meter and the box's df-parsing
|
|
165
|
+
* watchdog report the same number (both exclude the root-reserved margin from both sides).
|
|
166
|
+
* macOS — APFS volumes SHARE one container, so `df /`'s per-volume "used" reads ~25% on a disk that
|
|
167
|
+
* is genuinely 92% full; only free-against-container-total tells the truth there.
|
|
168
|
+
* Rounding is CEIL, not nearest, because `df` ceils its capacity column: exact 24.6515 prints as
|
|
169
|
+
* `25%`, exact 3.2258 prints as `4%`. Nearest would put the meter one point BELOW df on ~half of all
|
|
170
|
+
* values, so a root at exact 91.2% would show 91% in emerald "fine" while the df-parsing watchdog
|
|
171
|
+
* (which tests df's own `92`) is already paging Slack. Ceiling on the Linux path is what makes the
|
|
172
|
+
* two literally the same integer. Darwin ceils too: its number is a container reading that has no df
|
|
173
|
+
* column to match, but rounding the same way keeps one rule for both platforms and errs toward
|
|
174
|
+
* reporting a disk as fuller, never emptier, than it is.
|
|
175
|
+
* Returns null for a nonsense stat, so the caller keeps its last known value. */
|
|
176
|
+
export function diskUsedPct(fs: { blocks: bigint | number; bfree: bigint | number; bavail: bigint | number }, platform: string): number | null {
|
|
177
|
+
const blocks = Number(fs.blocks), bfree = Number(fs.bfree), bavail = Number(fs.bavail);
|
|
178
|
+
if (!(blocks > 0)) return null;
|
|
179
|
+
const pct = platform === 'darwin'
|
|
180
|
+
? 100 * (1 - bavail / blocks)
|
|
181
|
+
: 100 * ((blocks - bfree) / Math.max(1, blocks - bfree + bavail));
|
|
182
|
+
return Math.ceil(Math.max(0, Math.min(100, pct)));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The same per-platform definition as `diskUsedPct`, in BYTES, for the hover tooltip. Kept adjacent
|
|
187
|
+
* and derived from the identical numerator/denominator so the percentage and the "X of Y" can never
|
|
188
|
+
* describe different quantities — on darwin the denominator is the APFS container, on linux it is
|
|
189
|
+
* df's `used + available` (which excludes root-reserved blocks).
|
|
190
|
+
*/
|
|
191
|
+
export function diskBytes(fs: { bsize: bigint | number; blocks: bigint | number; bfree: bigint | number; bavail: bigint | number }, platform: string): { used: number; total: number } | null {
|
|
192
|
+
const bsize = Number(fs.bsize), blocks = Number(fs.blocks), bfree = Number(fs.bfree), bavail = Number(fs.bavail);
|
|
193
|
+
if (!(blocks > 0) || !(bsize > 0)) return null;
|
|
194
|
+
const used = platform === 'darwin' ? blocks - bavail : blocks - bfree;
|
|
195
|
+
const total = platform === 'darwin' ? blocks : blocks - bfree + bavail;
|
|
196
|
+
return { used: used * bsize, total: total * bsize };
|
|
197
|
+
}
|
|
198
|
+
|
|
97
199
|
function cpuTotals() {
|
|
98
200
|
let idle = 0, total = 0;
|
|
99
201
|
for (const c of os.cpus()) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Disk-meter values that BOTH halves need. Dependency-free on purpose (no node:*, no react) so the
|
|
2
|
+
// server can import it and vite can bundle it into the client — the only way a threshold change is
|
|
3
|
+
// physically one edit instead of two that can drift.
|
|
4
|
+
|
|
5
|
+
// Percent-USED thresholds for the disk meter. Kept identical to the box's Slack alerting
|
|
6
|
+
// (tools/ec2/box-disk-watchdog.sh in shraga-circles, BOX_DISK_WARN_PCT/BOX_DISK_CRIT_PCT) so the
|
|
7
|
+
// widget and the pager agree on what "in trouble" means — 92% is where that box's pushes started
|
|
8
|
+
// timing out. This file is the single source: the server re-exports it, the client imports it.
|
|
9
|
+
export const DISK_WARN_PCT = 92;
|
|
10
|
+
export const DISK_CRIT_PCT = 96;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Human byte size for the tooltip. Dependency-free and deliberately identical in both halves so the
|
|
14
|
+
* hover text and any server-side log read the same. Binary units, because that is what `df -h`
|
|
15
|
+
* prints and the whole point of this meter is agreeing with `df`.
|
|
16
|
+
*/
|
|
17
|
+
export function formatBytes(n: number): string {
|
|
18
|
+
if (!Number.isFinite(n) || n < 0) return '?';
|
|
19
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
20
|
+
let i = 0;
|
|
21
|
+
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
|
22
|
+
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
|
|
23
|
+
}
|