diff --git a/src/lib/server/hosts/capacity-history.ts b/src/lib/server/hosts/capacity-history.ts index 9e78514..0fe3c40 100644 --- a/src/lib/server/hosts/capacity-history.ts +++ b/src/lib/server/hosts/capacity-history.ts @@ -1,5 +1,6 @@ import { db } from '../db/index.js'; import type { CapacityHistory, CapacityQuery, CapacityRange } from '../../capacity.js'; +import type { HostMode } from '../../hosts.js'; import { readTemperatureHistory } from './temperatures.js'; const windows: Record = { @@ -16,13 +17,14 @@ export function readCapacityHistory(query: CapacityQuery, now = Date.now()): Cap const count = duration / bucketMs; const hosts = handle .prepare( - `SELECT id,name,created_at FROM ec_hosts + `SELECT id,name,created_at,mode FROM ec_hosts WHERE deleted_at IS NULL ${query.hostId ? 'AND id=?' : ''} ORDER BY id` ) .all(...(query.hostId ? [query.hostId] : [])) as { id: string; name: string; created_at: string; + mode: HostMode; }[]; // The primary key bounds each host scan to the selected interval. Aggregate // before crossing the worker boundary; do not return raw heartbeat rows. @@ -44,6 +46,9 @@ export function readCapacityHistory(query: CapacityQuery, now = Date.now()): Cap expectedHosts: 0 })); for (const host of hosts) { + // Current mode determines available capacity; keep inactive hosts in + // the separate temperature history for diagnostics. + if (host.mode !== 'active') continue; const createdAt = Date.parse(host.created_at); for (const point of points) { if (createdAt <= Math.min(now, point.at + bucketMs)) point.expectedHosts++; diff --git a/src/lib/ui/components/capacity/CapacityChart.svelte b/src/lib/ui/components/capacity/CapacityChart.svelte index b93a0ee..f8e50bc 100644 --- a/src/lib/ui/components/capacity/CapacityChart.svelte +++ b/src/lib/ui/components/capacity/CapacityChart.svelte @@ -234,10 +234,11 @@
About this chart

- Available host resources after the operating-system reserve, averaged per period. Gaps - mean reporting is incomplete. Reporting-host coverage is shown for each selected period. - This history includes all workloads on the selected hosts; it does not reconstruct - historical launch eligibility, queued jobs, or port availability. + Available resources from currently active hosts after the operating-system reserve, + averaged per period. Disabled and draining hosts are excluded. Gaps mean reporting is + incomplete. Reporting-host coverage is shown for each selected period. This history + includes all workloads on the selected hosts; it does not reconstruct historical launch + eligibility, queued jobs, or port availability.

View history data ยท {$timeZone.replaceAll('_', ' ')} diff --git a/tests/unit/capacity-history.test.ts b/tests/unit/capacity-history.test.ts index 9f0c699..e79e767 100644 --- a/tests/unit/capacity-history.test.ts +++ b/tests/unit/capacity-history.test.ts @@ -161,6 +161,82 @@ describe('capacity history', () => { }); }); + 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);