Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
24 changes: 24 additions & 0 deletions packages/cli/src/features/machine-usage/machine-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import os from 'node:os';
import { test } from 'node:test';

import { getCpuLoad, readDiskSpace } from './machine-usage';

test('the cpu load is the busy share of the time between two readings', () => {
assert.equal(getCpuLoad({ idle: 1000, total: 2000 }, { idle: 1300, total: 3000 }), 0.7);
});

test('the cpu load is unknown when no time passed between two readings', () => {
assert.equal(getCpuLoad({ idle: 1000, total: 2000 }, { idle: 1000, total: 2000 }), null);
});

test('the disk space of a folder has free bytes within the total', () => {
const disk = readDiskSpace(os.tmpdir());
assert.ok(disk !== null);
assert.ok(disk.totalBytes > 0);
assert.ok(disk.freeBytes >= 0 && disk.freeBytes <= disk.totalBytes);
});

test('the disk space of a missing folder is unknown', () => {
assert.equal(readDiskSpace('/no/such/kombinat/folder'), null);
});
54 changes: 54 additions & 0 deletions packages/cli/src/features/machine-usage/machine-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import fs from 'node:fs';
import os from 'node:os';

import type { DiskSpace } from 'domains/daemon/types';

const CPU_SAMPLE_MS = 5_000;

export interface CpuTimes {
idle: number;
total: number;
}

let cpuLoad: number | null = null;

function readCpuTimes(): CpuTimes {
return os.cpus().reduce(
(sum, { times }) => ({
idle: sum.idle + times.idle,
total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq,
}),
{ idle: 0, total: 0 },
);
}

export function getCpuLoad(previous: CpuTimes, next: CpuTimes): number | null {
const total = next.total - previous.total;
if (total <= 0) return null;
return Math.min(1, Math.max(0, 1 - (next.idle - previous.idle) / total));
}

// CPU times only count up, so the load is the busy share between two readings.
Comment thread
Danilqa marked this conversation as resolved.
Resolved
export function startCpuSampling(): () => void {
let previous = readCpuTimes();
const timer = setInterval(() => {
const next = readCpuTimes();
cpuLoad = getCpuLoad(previous, next);
previous = next;
}, CPU_SAMPLE_MS);
timer.unref();
return () => clearInterval(timer);
}

export function readCpuLoad(): number | null {
return cpuLoad;
}

export function readDiskSpace(dir: string): DiskSpace | null {
try {
const { bavail, blocks, bsize } = fs.statfsSync(dir);
return { freeBytes: bavail * bsize, totalBytes: blocks * bsize };
} catch {
return null;
}
}
8 changes: 8 additions & 0 deletions packages/cli/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ import {
WEBHOOK_QUEUE_LIMIT,
} from './domains/webhook/queue';
import { receiveWebhook } from './domains/webhook/receive';
import {
readCpuLoad,
readDiskSpace,
startCpuSampling,
} from './features/machine-usage/machine-usage';

interface ApiResponse {
status: number;
Expand Down Expand Up @@ -104,6 +109,8 @@ function getStatus(startedAt: Date): DaemonStatus {
loadRoutines().filter(routine => checkIsStarting(routine.name)).length,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
now: new Date().toISOString(),
disk: readDiskSpace(config.stateDir),
cpuLoad: readCpuLoad(),
};
}

Expand Down Expand Up @@ -301,6 +308,7 @@ export function startServer(log: (line: string) => void): http.Server {
sendJson(res, fail(500, message));
});
});
server.on('close', startCpuSampling());
server.listen(config.port, '127.0.0.1', () => {
log(`ui at http://localhost:${config.port}`);
});
Expand Down
16 changes: 15 additions & 1 deletion packages/domains/daemon/components/daemon-status-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getPollInterval } from 'utils/polling';

import { getDaemonStatus } from '../requests';
import type { DaemonHealth, DaemonStatus } from '../types';
import { getDaemonHealth } from '../utils';
import { checkIsDiskLow, formatCpuLoad, formatDiskSpace, getDaemonHealth } from '../utils';

interface DaemonHealthAppearance {
label: string;
Expand Down Expand Up @@ -70,6 +70,20 @@ function DaemonStatusDetails({ status }: DaemonStatusDetailsProps) {
</dd>
<dt className="text-fg-muted">Time zone</dt>
<dd className="font-mono">{status.timezone}</dd>
{status.disk && (
<>
<dt className="text-fg-muted">Disk</dt>
<dd className={cn('tabular-nums', checkIsDiskLow(status.disk) && 'text-warning')}>
{formatDiskSpace(status.disk)}
</dd>
</>
)}
{status.cpuLoad !== undefined && (
<>
<dt className="text-fg-muted">CPU load</dt>
<dd className="tabular-nums">{formatCpuLoad(status.cpuLoad)}</dd>
</>
)}
</dl>
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/domains/daemon/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const STALE_TICK_MS = 5 * 60_000;
export const LOW_DISK_BYTES = 10 * 1000 ** 3;
6 changes: 5 additions & 1 deletion packages/domains/daemon/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { z } from 'zod';

export const diskSpaceSchema = z.object({ freeBytes: z.number(), totalBytes: z.number() });

export const daemonStatusSchema = z.object({
// Older daemons do not send it, and tui --url may talk to one.
// Older daemons do not send version, disk and cpuLoad, and tui --url may talk to one.
version: z.string().optional(),
pid: z.number(),
startedAt: z.string(),
Expand All @@ -14,4 +16,6 @@ export const daemonStatusSchema = z.object({
running: z.number(),
timezone: z.string(),
now: z.string(),
disk: diskSpaceSchema.nullable().optional(),
cpuLoad: z.number().nullable().optional(),
});
4 changes: 3 additions & 1 deletion packages/domains/daemon/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { z } from 'zod';

import type { daemonStatusSchema } from './schemas';
import type { daemonStatusSchema, diskSpaceSchema } from './schemas';

export type DaemonStatus = z.infer<typeof daemonStatusSchema>;

export type DiskSpace = z.infer<typeof diskSpaceSchema>;

export type DaemonHealth = 'healthy' | 'stale' | 'unreachable';
30 changes: 30 additions & 0 deletions packages/domains/daemon/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { checkIsDiskLow, formatBytes, formatCpuLoad, formatDiskSpace } from './utils';

test('bytes read in the largest decimal unit, with a decimal only below ten', () => {
assert.equal(formatBytes(1_834_000_000), '1.8 GB');
assert.equal(formatBytes(494_380_000_000), '494 GB');
assert.equal(formatBytes(2_000_000_000_000), '2 TB');
assert.equal(formatBytes(512_000_000), '512 MB');
assert.equal(formatBytes(4_000_000_000_000_000), '4,000 TB');
assert.equal(formatBytes(0), '0 B');
});

test('disk space reads as free of total', () => {
assert.equal(
formatDiskSpace({ freeBytes: 182_400_000_000, totalBytes: 494_380_000_000 }),
'182 GB free of 494 GB',
);
});

test('a disk with less than 10 GB free is low', () => {
assert.equal(checkIsDiskLow({ freeBytes: 1_834_000_000, totalBytes: 494_380_000_000 }), true);
assert.equal(checkIsDiskLow({ freeBytes: 182_400_000_000, totalBytes: 494_380_000_000 }), false);
});

test('the cpu load reads as a whole percentage, or a dash before the first sample', () => {
assert.equal(formatCpuLoad(0.234), '23%');
assert.equal(formatCpuLoad(null), '-');
});
36 changes: 34 additions & 2 deletions packages/domains/daemon/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { STALE_TICK_MS } from './constants';
import type { DaemonHealth, DaemonStatus } from './types';
import { LOW_DISK_BYTES, STALE_TICK_MS } from './constants';
import type { DaemonHealth, DaemonStatus, DiskSpace } from './types';

const BYTE_UNITS = ['kilobyte', 'megabyte', 'gigabyte', 'terabyte'] as const;

const percentFormatter = new Intl.NumberFormat('en', {
style: 'percent',
maximumFractionDigits: 0,
});

export function checkIsTickStale(status: DaemonStatus): boolean {
if (status.lastTickAt === null) return true;
Expand All @@ -10,3 +17,28 @@ export function getDaemonHealth(status: DaemonStatus | undefined, hasError: bool
if (hasError || status === undefined) return 'unreachable';
return checkIsTickStale(status) ? 'stale' : 'healthy';
}

export function checkIsDiskLow(disk: DiskSpace): boolean {
return disk.freeBytes < LOW_DISK_BYTES;
}

// Decimal units, as Finder and Disk Utility count them.
export function formatBytes(bytes: number): string {
if (bytes < 1000) return `${Math.round(bytes)} B`;
const exponent = Math.min(BYTE_UNITS.length, Math.floor(Math.log10(bytes) / 3));
const value = bytes / 1000 ** exponent;
return new Intl.NumberFormat('en', {
style: 'unit',
unit: BYTE_UNITS[exponent - 1],
unitDisplay: 'short',
maximumFractionDigits: value < 10 ? 1 : 0,
}).format(value);
}

export function formatDiskSpace(disk: DiskSpace): string {
return `${formatBytes(disk.freeBytes)} free of ${formatBytes(disk.totalBytes)}`;
}

export function formatCpuLoad(load: number | null): string {
return load === null ? '-' : percentFormatter.format(load);
}
9 changes: 9 additions & 0 deletions packages/web-app/src/components/app-header.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,12 @@ test('shows when the daemon is offline', async ({ page }) => {
await status.click();
await expect(page.getByRole('dialog')).toContainText('Daemon is unreachable');
});

test('shows the free disk space and the cpu load of the machine', async ({ page }) => {
await page.goto('/');

await page.getByRole('button', { name: /daemon status/ }).click();
const details = page.getByRole('dialog');
await expect(details).toContainText(/Disk[\d.,]+ [kMGT]B free of [\d.,]+ [kMGT]B/);
await expect(details).toContainText(/CPU load(\d+%|-)/);
});
5 changes: 4 additions & 1 deletion packages/website/src/demo-api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export function createDemoApi(loadedAt = new Date()): DemoApi {
}

function getStatus(now: Date): DaemonStatus {
const running = history.listRuns(now).filter(isMatching({ status: 'running' })).length;
return {
pid: 48213,
startedAt: daemonStartedAt.toISOString(),
Expand All @@ -128,9 +129,11 @@ export function createDemoApi(loadedAt = new Date()): DemoApi {
claudeBin: `${HOME}/.local/bin/claude`,
codexBin: '/opt/homebrew/bin/codex',
maxConcurrentRuns: 2,
running: history.listRuns(now).filter(isMatching({ status: 'running' })).length,
running,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
now: now.toISOString(),
disk: { freeBytes: 182_400_000_000, totalBytes: 494_380_000_000 },
cpuLoad: running > 0 ? 0.46 : 0.07,
};
}

Expand Down
Loading