pi-remote-control/extensions/remote-control/server/routes/health.ts

97 lines
2.7 KiB
TypeScript

/**
* S-12 — health endpoint.
*
* GET /health → { ok, sessions, bufferBytes, uptime, version }
*
* Also integrates the disk watchdog: on each health call we check free space
* and total buffer size, returning a warning if caps are near.
*
* Owner: T-1.7
*/
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { listSessions } from "../../tmux/manager.js";
import { sendJson } from "../util.js";
const execFileAsync = promisify(execFile);
const _startedAt = Date.now();
export interface HealthOptions {
stateDir?: string;
}
export async function handleHealth(
_req: IncomingMessage,
res: ServerResponse,
opts: HealthOptions = {},
): Promise<void> {
const stateDir =
opts.stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote");
const [sessions, bufferBytes, freeBytes] = await Promise.all([
listSessions().catch(() => []),
getTotalBufferBytes(stateDir),
getFreeBytes(stateDir),
]);
const freeGb = freeBytes / (1024 * 1024 * 1024);
const bufferMb = bufferBytes / (1024 * 1024);
sendJson(res, 200, {
ok: true,
uptime: Math.floor((Date.now() - _startedAt) / 1000),
sessions: sessions.length,
sessionIds: sessions.map((s) => s.id),
bufferMb: Math.round(bufferMb * 10) / 10,
diskFreeGb: Math.round(freeGb * 10) / 10,
warnings: buildWarnings(freeGb, bufferMb),
});
}
function buildWarnings(freeGb: number, bufferMb: number): string[] {
const warnings: string[] = [];
if (freeGb < 1) warnings.push(`Low disk space: ${freeGb.toFixed(1)} GB free`);
if (bufferMb > 900)
warnings.push(`Buffer near cap: ${bufferMb.toFixed(0)} MB used`);
return warnings;
}
async function getTotalBufferBytes(stateDir: string): Promise<number> {
const bufDir = path.join(stateDir, "buffers");
try {
const entries = await fs.readdir(bufDir, { withFileTypes: true });
let total = 0;
for (const e of entries) {
if (!e.name.endsWith(".buf")) continue;
try {
const stat = await fs.stat(path.join(bufDir, e.name));
total += stat.size;
} catch {
// skip
}
}
return total;
} catch {
return 0;
}
}
async function getFreeBytes(dir: string): Promise<number> {
try {
const { stdout } = await execFileAsync("df", ["-k", dir]);
const lines = stdout.trim().split("\n");
const last = lines[lines.length - 1];
const parts = last.split(/\s+/);
const availKb = parseInt(parts[3], 10);
return availKb * 1024;
} catch {
return Number.POSITIVE_INFINITY; // unknown — don't warn
}
}