Add capacity overview with host resource history

This commit is contained in:
2026-10-05 12:06:29 -04:00
parent 7c76851252
commit 09ef8c6ec8
19 changed files with 845 additions and 10 deletions
+66
View File
@@ -0,0 +1,66 @@
import { db } from '../db/index.js';
import { ensureHostSchema } from './schema.js';
import type { CapacityHistory, CapacityQuery, CapacityRange } from '../../capacity.js';
const windows: Record<CapacityRange, { duration: number; bucketMs: number }> = {
'1h': { duration: 3_600_000, bucketMs: 60_000 },
'24h': { duration: 86_400_000, bucketMs: 600_000 },
'7d': { duration: 7 * 86_400_000, bucketMs: 3_600_000 }
};
/** Read on the report worker: heartbeat history can contain millions of rows. */
export function readCapacityHistory(query: CapacityQuery, now = Date.now()): CapacityHistory {
const handle = db();
ensureHostSchema(handle);
const { duration, bucketMs } = windows[query.range];
const startAt = now - duration;
const count = duration / bucketMs;
const hosts = handle.prepare(`SELECT id,created_at FROM ec_hosts
WHERE deleted_at IS NULL ${query.hostId ? 'AND id=?' : ''} ORDER BY id`)
.all(...(query.hostId ? [query.hostId] : [])) as { id: string; created_at: string }[];
// The primary key bounds each host scan to the selected interval. Aggregate
// before crossing the worker boundary; do not return raw heartbeat rows.
const samples = handle.prepare(`SELECT
MIN(?, CAST(((CAST(strftime('%s',sampled_at) AS INTEGER)*1000
+ CAST(substr(strftime('%f',sampled_at),4,3) AS INTEGER)) - ?) / ? AS INTEGER)) AS bucket,
AVG(MAX(0,cpu_count * (0.95 - cpu_usage_percent / 100.0))) AS cpu,
AVG(MAX(0,memory_available - 500000000)) AS memory
FROM ec_host_resource_samples
WHERE host_id=? AND sampled_at>=? AND sampled_at<=?
AND cpu_count>0 AND cpu_usage_percent BETWEEN 0 AND 100
AND memory_total>0 AND memory_available BETWEEN 0 AND memory_total
GROUP BY bucket`);
const points = Array.from({ length: count }, (_, index) => ({
at: startAt + index * bucketMs,
cpuCoresAvailable: null as number | null,
memoryBytesAvailable: null as number | null,
reportingHosts: 0,
expectedHosts: 0
}));
for (const host of hosts) {
const createdAt = Date.parse(host.created_at);
for (const point of points) {
if (createdAt <= Math.min(now, point.at + bucketMs)) point.expectedHosts++;
}
const buckets = samples.all(count - 1, startAt, bucketMs, host.id,
new Date(startAt).toISOString(), new Date(now).toISOString()) as { bucket: number; cpu: number; memory: number }[];
for (const bucket of buckets) {
const point = points[bucket.bucket];
if (!point || createdAt > Math.min(now, point.at + bucketMs)) continue;
// Average each host first so one host reporting more frequently
// cannot change another host's contribution to fleet headroom.
point.reportingHosts++;
point.cpuCoresAvailable = (point.cpuCoresAvailable ?? 0) + bucket.cpu;
point.memoryBytesAvailable = (point.memoryBytesAvailable ?? 0) + bucket.memory;
}
}
for (const point of points) {
// A missing host is unknown, not zero available capacity. Keep the gap
// visible instead of presenting a partial sum as the fleet total.
if (point.reportingHosts !== point.expectedHosts) {
point.cpuCoresAvailable = null;
point.memoryBytesAvailable = null;
}
}
return { range: query.range, startAt, endAt: now, bucketMs, points };
}