340 lines
14 KiB
TypeScript
340 lines
14 KiB
TypeScript
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');
|
|
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('keeps separate CPU series, device drill-down, real zero and missing temperature gaps', async () => {
|
|
await host('one');
|
|
await host('two');
|
|
await host('unsupported');
|
|
await host('removed');
|
|
const { recordTemperatures } = await import('../../src/lib/server/hosts/temperatures.js');
|
|
const cpu = (celsius: number) => ({
|
|
id: 'package',
|
|
kind: 'cpu-package' as const,
|
|
device: 'coretemp',
|
|
label: 'Package id 0',
|
|
celsius
|
|
});
|
|
const drive = {
|
|
id: 'drive',
|
|
kind: 'nvme' as const,
|
|
device: 'nvme',
|
|
label: 'Composite',
|
|
celsius: 50.9
|
|
};
|
|
recordTemperatures(
|
|
'one',
|
|
{ temperatures: [cpu(65), drive] },
|
|
new Date(epoch - 55_000).toISOString()
|
|
);
|
|
recordTemperatures(
|
|
'one',
|
|
{ temperatures: [cpu(75)] },
|
|
new Date(epoch - 30_000).toISOString()
|
|
);
|
|
recordTemperatures(
|
|
'one',
|
|
{ temperatures: [cpu(0)] },
|
|
new Date(epoch - 180_000).toISOString()
|
|
);
|
|
recordTemperatures(
|
|
'two',
|
|
{ temperatures: [cpu(60)] },
|
|
new Date(epoch - 40_000).toISOString()
|
|
);
|
|
recordTemperatures(
|
|
'removed',
|
|
{ temperatures: [cpu(99)] },
|
|
new Date(epoch - 40_000).toISOString()
|
|
);
|
|
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).temperatures;
|
|
expect(fleet.map((s) => s.label)).toEqual(['one', 'two', 'unsupported']);
|
|
expect(fleet[0].points.slice(-3).map((p) => p.celsius)).toEqual([0, null, 75]);
|
|
expect(fleet[1].points.at(-1)?.celsius).toBe(60);
|
|
expect(fleet[2].points.every((p) => p.celsius === null)).toBe(true);
|
|
const selected = readCapacityHistory(
|
|
{ ...query, hostId: 'one', game: 't5' },
|
|
epoch
|
|
).temperatures;
|
|
expect(selected.map((s) => s.kind)).toEqual(['cpu', 'nvme']);
|
|
expect(selected[1].points.at(-1)?.celsius).toBe(50.9);
|
|
expect(
|
|
readCapacityHistory({ ...query, hostId: 'one', game: 't6' }, epoch).temperatures
|
|
).toEqual(selected);
|
|
expect(readCapacityHistory({ ...query, hostId: 'missing' }, epoch).temperatures).toEqual(
|
|
[]
|
|
);
|
|
});
|
|
|
|
it('preserves temperature peaks for long ranges and includes a host enrolled during the current minute', async () => {
|
|
await host('new', epoch - 20_000);
|
|
const { recordTemperatures } = await import('../../src/lib/server/hosts/temperatures.js');
|
|
recordTemperatures(
|
|
'new',
|
|
{
|
|
temperatures: [
|
|
{
|
|
id: 'pkg',
|
|
device: 'coretemp',
|
|
kind: 'cpu-package',
|
|
label: 'Package id 0',
|
|
celsius: 65
|
|
}
|
|
]
|
|
},
|
|
new Date(epoch - 10_000).toISOString()
|
|
);
|
|
const { readCapacityHistory } =
|
|
await import('../../src/lib/server/hosts/capacity-history.js');
|
|
expect(readCapacityHistory(query, epoch).temperatures[0].points.at(-1)?.celsius).toBe(65);
|
|
const long = readCapacityHistory({ ...query, range: '7d' }, epoch).temperatures[0];
|
|
expect(long.points).toHaveLength(168);
|
|
expect(long.points.at(-1)?.celsius).toBe(65);
|
|
});
|
|
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('excludes inactive hosts from resource totals and coverage while retaining their temperatures', async () => {
|
|
await host('active');
|
|
await host('disabled');
|
|
await host('draining');
|
|
await host('disabled-without-samples');
|
|
await sample('active', epoch - 45_000);
|
|
await sample('disabled', epoch - 45_000);
|
|
await sample('disabled', epoch - 90_000);
|
|
await sample('draining', epoch - 45_000);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db().prepare("UPDATE ec_hosts SET mode='disabled' WHERE id LIKE 'disabled%'").run();
|
|
db().prepare("UPDATE ec_hosts SET mode='draining' WHERE id='draining'").run();
|
|
const { recordTemperatures } = await import('../../src/lib/server/hosts/temperatures.js');
|
|
recordTemperatures(
|
|
'disabled',
|
|
{
|
|
temperatures: [
|
|
{
|
|
id: 'pkg',
|
|
device: 'coretemp',
|
|
kind: 'cpu-package',
|
|
label: 'Package id 0',
|
|
celsius: 65
|
|
}
|
|
]
|
|
},
|
|
new Date(epoch - 45_000).toISOString()
|
|
);
|
|
const { readCapacityHistory } =
|
|
await import('../../src/lib/server/hosts/capacity-history.js');
|
|
const history = readCapacityHistory(query, epoch);
|
|
expect(history.points.at(-1)).toMatchObject({
|
|
memoryBytesAvailable: 4 * GiB - 500_000_000,
|
|
reportingHosts: 1,
|
|
expectedHosts: 1
|
|
});
|
|
expect(history.points.at(-1)?.cpuCoresAvailable).toBeCloseTo(5.6);
|
|
expect(history.points.at(-2)).toMatchObject({
|
|
cpuCoresAvailable: null,
|
|
memoryBytesAvailable: null,
|
|
reportingHosts: 0,
|
|
expectedHosts: 1
|
|
});
|
|
expect(
|
|
history.temperatures.find((series) => series.hostId === 'disabled')?.points.at(-1)
|
|
).toMatchObject({ celsius: 65 });
|
|
});
|
|
|
|
it.each(['disabled', 'draining'])(
|
|
'omits a selected %s host until it is active again',
|
|
async (mode) => {
|
|
await host('one');
|
|
await sample('one', epoch - 45_000);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db().prepare('UPDATE ec_hosts SET mode=? WHERE id=?').run(mode, 'one');
|
|
const { readCapacityHistory } =
|
|
await import('../../src/lib/server/hosts/capacity-history.js');
|
|
for (const hostId of [null, 'one']) {
|
|
const history = readCapacityHistory({ ...query, hostId }, epoch);
|
|
expect(
|
|
history.points.every(
|
|
(point) =>
|
|
point.cpuCoresAvailable === null &&
|
|
point.memoryBytesAvailable === null &&
|
|
point.reportingHosts === 0 &&
|
|
point.expectedHosts === 0
|
|
)
|
|
).toBe(true);
|
|
}
|
|
db().prepare("UPDATE ec_hosts SET mode='active' WHERE id='one'").run();
|
|
const history = readCapacityHistory({ ...query, hostId: 'one' }, epoch);
|
|
expect(history.points.at(-1)?.cpuCoresAvailable).toBeCloseTo(5.6);
|
|
expect(history.points.at(-1)).toMatchObject({ reportingHosts: 1, expectedHosts: 1 });
|
|
}
|
|
);
|
|
|
|
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);
|
|
});
|
|
});
|