Add capacity overview with host resource history
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import type { CapacityQuery } from '../../src/lib/capacity.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
const epoch = Date.UTC(2026, 9, 5, 12);
|
||||
const GiB = 1024 ** 3;
|
||||
const query: CapacityQuery = { range: '1h', hostId: null, game: 'all' };
|
||||
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
|
||||
|
||||
async function host(id: string, createdAt = epoch - 7 * 86_400_000) {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
|
||||
ensureHostSchema(db());
|
||||
db().prepare('INSERT INTO ec_hosts (id,name,created_at) VALUES (?,?,?)').run(id, id, new Date(createdAt).toISOString());
|
||||
}
|
||||
|
||||
async function sample(id: string, at: number, cpu = 25, memory = 4 * GiB, cores = 8) {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare(`INSERT INTO ec_host_resource_samples
|
||||
(host_id,sampled_at,cpu_count,cpu_usage_percent,memory_total,memory_available) VALUES (?,?,?,?,?,?)`)
|
||||
.run(id, new Date(at).toISOString(), cores, cpu, 16 * GiB, memory);
|
||||
}
|
||||
|
||||
describe('capacity history', () => {
|
||||
it('averages each host before summing fleet headroom and exposes missing observations as gaps', async () => {
|
||||
await host('one'); await host('two');
|
||||
await sample('one', epoch - 55_000, 0, 8 * GiB);
|
||||
await sample('one', epoch - 30_000, 50, 4 * GiB);
|
||||
await sample('two', epoch - 45_000, 50, 2 * GiB, 4);
|
||||
await sample('one', epoch - 90_000);
|
||||
const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js');
|
||||
const history = readCapacityHistory(query, epoch);
|
||||
expect(history.points).toHaveLength(60);
|
||||
expect(history.points.at(-1)).toMatchObject({ at: epoch - 60_000,
|
||||
memoryBytesAvailable: 8 * GiB - 1_000_000_000,
|
||||
reportingHosts: 2, expectedHosts: 2 });
|
||||
expect(history.points.at(-1)?.cpuCoresAvailable).toBeCloseTo(7.4);
|
||||
expect(history.points.at(-2)).toMatchObject({ reportingHosts: 1, expectedHosts: 2,
|
||||
cpuCoresAvailable: null, memoryBytesAvailable: null });
|
||||
expect(history.points[0]).toMatchObject({ reportingHosts: 0, expectedHosts: 2,
|
||||
cpuCoresAvailable: null, memoryBytesAvailable: null });
|
||||
});
|
||||
|
||||
it('scopes history to the selected registered host and does not add hosts before they existed', async () => {
|
||||
await host('one'); await host('two', epoch - 30_000); await host('removed');
|
||||
await sample('one', epoch - 90_000);
|
||||
await sample('one', epoch - 15_000);
|
||||
await sample('two', epoch - 15_000);
|
||||
await sample('removed', epoch - 15_000);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare('UPDATE ec_hosts SET deleted_at=? WHERE id=?').run(new Date(epoch).toISOString(), 'removed');
|
||||
const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js');
|
||||
const fleet = readCapacityHistory(query, epoch);
|
||||
expect(fleet.points.at(-2)).toMatchObject({ reportingHosts: 1, expectedHosts: 1 });
|
||||
expect(fleet.points.at(-1)).toMatchObject({ reportingHosts: 2, expectedHosts: 2 });
|
||||
const selected = readCapacityHistory({ ...query, hostId: 'one', game: 't6' }, epoch);
|
||||
expect(selected.points.at(-1)).toMatchObject({ reportingHosts: 1, expectedHosts: 1,
|
||||
memoryBytesAvailable: 4 * GiB - 500_000_000 });
|
||||
const missing = readCapacityHistory({ ...query, hostId: 'unknown' }, epoch);
|
||||
expect(missing.points.every(point => point.expectedHosts === 0 && point.cpuCoresAvailable === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes invalid telemetry and keeps measured exhaustion as zero rather than a gap', async () => {
|
||||
await host('one');
|
||||
await sample('one', epoch - 45_000, 100, 100_000_000);
|
||||
await sample('one', epoch - 30_000, -1);
|
||||
await sample('one', epoch - 15_000, 10, 17 * GiB);
|
||||
await sample('one', epoch - 90_000, 101);
|
||||
const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js');
|
||||
const history = readCapacityHistory(query, epoch);
|
||||
expect(history.points.at(-1)).toMatchObject({ cpuCoresAvailable: 0, memoryBytesAvailable: 0, reportingHosts: 1 });
|
||||
expect(history.points.at(-2)).toMatchObject({ cpuCoresAvailable: null, memoryBytesAvailable: null, reportingHosts: 0 });
|
||||
});
|
||||
|
||||
it('bounds long ranges and clips samples outside the interval including future telemetry', async () => {
|
||||
await host('one');
|
||||
await sample('one', epoch - 7 * 86_400_000 - 1);
|
||||
await sample('one', epoch - 7 * 86_400_000, 0);
|
||||
await sample('one', epoch);
|
||||
await sample('one', epoch + 1, 100);
|
||||
const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js');
|
||||
const history = readCapacityHistory({ ...query, range: '7d' }, epoch);
|
||||
expect(history.points).toHaveLength(168);
|
||||
expect(history.bucketMs).toBe(3_600_000);
|
||||
expect(history.points[0].cpuCoresAvailable).toBeCloseTo(7.6);
|
||||
expect(history.points.at(-1)?.cpuCoresAvailable).toBeCloseTo(5.6);
|
||||
expect(readCapacityHistory({ ...query, range: '24h' }, epoch).points).toHaveLength(144);
|
||||
});
|
||||
|
||||
it('reads history on a separate worker and refreshes its bounded cache after 30 seconds', async () => {
|
||||
await host('one'); await sample('one', epoch - 15_000);
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(epoch);
|
||||
const { capacityHistoryReport } = await import('../../src/lib/server/reports/index.js');
|
||||
const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js');
|
||||
const first = capacityHistoryReport(query);
|
||||
const winner = await Promise.race([first.then(() => 'report'),
|
||||
new Promise<string>(resolve => setTimeout(() => resolve('timer'), 0))]);
|
||||
expect(winner).toBe('timer');
|
||||
expect(await first).toEqual(readCapacityHistory(query, epoch));
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare('UPDATE ec_host_resource_samples SET cpu_usage_percent=100').run();
|
||||
expect(await capacityHistoryReport(query)).toEqual(await first);
|
||||
now.mockReturnValue(epoch + 30_000);
|
||||
expect((await capacityHistoryReport(query)).points.at(-1)?.cpuCoresAvailable).toBe(0);
|
||||
});
|
||||
|
||||
it('opens the history worker on a fresh database before any host has enrolled', async () => {
|
||||
const { capacityHistoryReport } = await import('../../src/lib/server/reports/index.js');
|
||||
const history = await capacityHistoryReport(query);
|
||||
expect(history.points).toHaveLength(60);
|
||||
expect(history.points.every(point => point.expectedHosts === 0 && point.cpuCoresAvailable === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user