Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist/
node_modules/
.npmcache/
52 changes: 51 additions & 1 deletion app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ import {
} from "./components/ui/select";
import { Button } from "./components/ui/button";
import type { rpcContract } from "./server";
import type { BatteryState } from "./lib/battery";

type Sample = {
ts: number; cpuPct: number; load1: number; load5: number; cpuCount: number;
memTotalMb: number; memUsedMb: number; memUsedFrac: number; pressureLevel: number;
swapUsedMb: number; diskTotalGb: number; diskUsedGb: number;
// Optional at every layer: undefined means the sampler could not tell, which
// is neither "no battery" ({ present: false }) nor any number. lib/battery.ts.
battery?: BatteryState;
};
type Current = {
sample: Sample | null;
Expand Down Expand Up @@ -77,6 +81,47 @@ function Tile(props: { label: string; value: string; sub: string; frac: number;
);
}

function batteryDetail(b: Extract<BatteryState, { present: true }>): string {
const parts = [b.charging ? "charging" : b.acConnected ? "on AC" : "on battery"];
if (b.minutesRemaining !== undefined) {
const h = Math.floor(b.minutesRemaining / 60);
parts.push(h > 0 ? `${h}h ${b.minutesRemaining % 60}m left` : `${b.minutesRemaining}m left`);
}
if (b.healthPct !== undefined) parts.push(`health ${b.healthPct}%`);
if (b.cycleCount !== undefined) parts.push(`${b.cycleCount} cycles`);
return parts.join(" · ");
}

// The three states render UNALIKE. A machine with no battery is not rendered by
// this component at all (SystemDetails omits the tile); a machine whose battery
// state could not be read says so in words. Neither ever draws a bar or a
// number, because an absent reading shown as 0% is a laptop about to die.
function BatteryTile({ battery }: { battery: BatteryState | undefined }) {
const label = <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Battery</div>;
if (battery === undefined) {
return (
<div className="rounded-lg border border-border bg-card p-4 space-y-2">
{label}
<div className="text-2xl font-semibold text-muted-foreground">Unknown</div>
<div className="text-xs text-muted-foreground">This machine did not report a battery state.</div>
</div>
);
}
if (!battery.present) return null;
const onBattery = !battery.charging && !battery.acConnected;
const tone = battery.pct === undefined ? "ok" : onBattery && battery.pct <= 20 ? "hot" : onBattery && battery.pct <= 40 ? "warn" : "ok";
return (
<div className="rounded-lg border border-border bg-card p-4 space-y-2">
{label}
<div className={`text-2xl font-semibold ${battery.pct === undefined ? "text-muted-foreground" : "text-foreground"}`}>
{battery.pct === undefined ? "Charge unknown" : `${battery.pct}%`}
</div>
{battery.pct === undefined ? <div className="h-1.5" /> : <Meter frac={battery.pct / 100} tone={tone} />}
<div className="text-xs text-muted-foreground">{batteryDetail(battery)}</div>
</div>
);
}

function Spark({ points, title, max }: { points: number[]; title: string; max: number }) {
if (points.length < 2) return null;
const w = 280, h = 48;
Expand Down Expand Up @@ -438,9 +483,13 @@ function SystemPanel() {
function SystemDetails({ current, samples }: { current: Current; samples: Sample[] }) {
const s = current.sample!;
const pressure = PRESSURE[s.pressureLevel] ?? String(s.pressureLevel);
// A machine that reported "no battery" gets no tile — a permanent empty
// battery card on a desktop is noise. UNKNOWN still gets one, so the two
// cases stay distinguishable on screen.
const showBattery = s.battery === undefined || s.battery.present;
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className={`grid grid-cols-1 gap-3 ${showBattery ? "sm:grid-cols-2 lg:grid-cols-4" : "sm:grid-cols-3"}`}>
<Tile label="CPU" value={`${Math.round(s.cpuPct)}%`} sub={`${s.cpuCount} cores · load ${s.load1.toFixed(2)}`} frac={s.cpuPct / 100} />
<Tile
label="Memory used"
Expand All @@ -450,6 +499,7 @@ function SystemDetails({ current, samples }: { current: Current; samples: Sample
hot={s.pressureLevel >= 2}
/>
<Tile label="Disk" value={`${s.diskUsedGb} GB`} sub={`of ${s.diskTotalGb} GB`} frac={s.diskUsedGb / (s.diskTotalGb || 1)} />
{showBattery ? <BatteryTile battery={s.battery} /> : null}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Spark title="CPU % — last hour" points={samples.map((x) => x.cpuPct)} max={100} />
Expand Down
123 changes: 123 additions & 0 deletions lib/battery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Battery state for the System plugin — modelled as THREE states, never two.
//
// PRESENT a battery was found (a laptop): render the reading.
// ABSENT the machine reported that it has no battery (a desktop): render
// nothing. NEVER 0%.
// UNKNOWN the sampler could not tell — `si.battery()` threw, the remote
// sampler emitted no battery line, an old persisted row predates
// the column. Say so. NEVER a number.
//
// The reason this is a separate module with no plugin imports: every function
// here is pure, so the ABSENT and UNKNOWN arms can be exercised from a fixture
// on a machine that has a battery. This machine is a laptop, so those two arms
// are not otherwise reachable, and they are the arms that matter — a laptop
// rendering wrongly is visible on screen, a desktop rendering as "0% — about to
// die" is not visible to anyone here at all.
//
// The trap this exists to avoid is `server.ts`'s remote-value reader,
// `const num = (key) => Number(values.get(key)) || 0`: a key the sampler never
// emitted coerces to 0, and `|| 0` collapses a genuine 0 into the same 0. A
// battery routed through it turns a desktop into a dying laptop. Nothing here
// uses it; absence stays `undefined` the whole way through.
import { z } from "zod";

export const batteryStateShape = z.discriminatedUnion("present", [
z.object({ present: z.literal(false) }),
z.object({
present: z.literal(true),
// Optional even in the PRESENT branch: "there is a battery but its charge
// did not parse" is a real reading, and is not the same as no battery.
pct: z.number().optional(),
charging: z.boolean(),
acConnected: z.boolean(),
minutesRemaining: z.number().optional(),
cycleCount: z.number().optional(),
healthPct: z.number().optional(),
}),
]);
export type BatteryState = z.infer<typeof batteryStateShape>;

const finite = (v: unknown): number | undefined =>
typeof v === "number" && Number.isFinite(v) ? v : undefined;

/** A percentage, or undefined — out-of-range readings are not clamped into range. */
export const asPct = (v: unknown): number | undefined => {
const n = finite(v);
return n === undefined || n < 0 || n > 100 ? undefined : Math.round(n);
};

// IOKit reports 65535 (0xFFFF) or -1 when it has no estimate, and
// systeminformation passes that straight through: this MacBook returns
// timeRemaining 65535 while plugged in (measured 2026-09-04). Rendered raw
// that is "45 days remaining". Anything outside a day is not an estimate.
const MAX_PLAUSIBLE_MINUTES = 24 * 60;
export const asMinutes = (v: unknown): number | undefined => {
const n = finite(v);
return n === undefined || n <= 0 || n >= MAX_PLAUSIBLE_MINUTES ? undefined : Math.round(n);
};

/** Map a `systeminformation` battery() result. `hasBattery` is the discriminator. */
export function batteryFromSi(raw: unknown): BatteryState | undefined {
if (!raw || typeof raw !== "object") return undefined;
const b = raw as Record<string, unknown>;
// Not a boolean means the reading itself is unusable — UNKNOWN, not "no
// battery". Guessing "desktop" here would silently hide a laptop's battery.
if (typeof b.hasBattery !== "boolean") return undefined;
if (!b.hasBattery) return { present: false };
const charging = b.isCharging === true;
const designed = finite(b.designedCapacity);
const max = finite(b.maxCapacity);
return {
present: true,
pct: asPct(b.percent),
charging,
acConnected: b.acConnected === true,
// A "time remaining" while charging is an estimate of the wrong thing.
minutesRemaining: charging ? undefined : asMinutes(b.timeRemaining),
cycleCount: finite(b.cycleCount),
healthPct:
designed !== undefined && max !== undefined && designed > 0
? Math.round((max / designed) * 100)
: undefined,
};
}

/**
* Map the remote sampler's `key=value` lines. A remote host may legitimately be
* a desktop, so an absent `battery_present` key is UNKNOWN and `battery_present=0`
* is ABSENT — two different answers, neither of them a number.
*/
export function batteryFromRemote(values: Map<string, string>): BatteryState | undefined {
const present = values.get("battery_present");
if (present === "0") return { present: false };
if (present !== "1") return undefined; // absent key, or anything unrecognised
const numeric = (key: string): number | undefined => {
const raw = values.get(key);
if (raw === undefined || raw.trim() === "") return undefined;
const n = Number(raw);
return Number.isFinite(n) ? n : undefined;
};
const charging = values.get("battery_charging") === "1";
return {
present: true,
pct: asPct(numeric("battery_pct")),
charging,
acConnected: values.get("battery_ac") === "1",
minutesRemaining: charging ? undefined : asMinutes(numeric("battery_minutes")),
};
}

/** NULL in the column means UNKNOWN, which is what every pre-migration row is. */
export function encodeBattery(state: BatteryState | undefined): string | null {
return state === undefined ? null : JSON.stringify(state);
}

export function decodeBattery(raw: unknown): BatteryState | undefined {
if (typeof raw !== "string" || raw.trim() === "") return undefined;
try {
const parsed = batteryStateShape.safeParse(JSON.parse(raw));
return parsed.success ? parsed.data : undefined;
} catch {
return undefined; // a corrupt cell is UNKNOWN, never a reading
}
}
Loading
Loading