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
+69
View File
@@ -0,0 +1,69 @@
import { setImmediate as yieldToController } from 'node:timers/promises';
import { config } from '../config.js';
import { newestReadyLibraryId } from '../game-files/index.js';
import { HEARTBEAT_MAX_AGE_MS } from './index.js';
import { freePorts, readAvailabilitySnapshot } from './availability.js';
import type { CapacityCurrent, CapacityHost, CapacityQuery } from '../../capacity.js';
/** The page and collection preview share heartbeat/lifecycle capacity snapshots. */
export async function readCapacityCurrent(query: CapacityQuery): Promise<CapacityCurrent> {
let snapshot = readAvailabilitySnapshot();
if (!snapshot) {
// The initial snapshot is populated after the request yields, without
// historical scans, placement, or a request to any host.
await yieldToController();
snapshot = readAvailabilitySnapshot();
}
if (!snapshot) return { updatedAt: null, hosts: [], hostOptions: [], warnings: ['Capacity measurements are updating.'],
summary: { estimatedServers: null, cpuCoresAvailable: null, memoryBytesAvailable: null, portsAvailable: null,
eligibleHosts: 0, totalHosts: 0, startupSlots: null, startupLimit: null, activeStartups: null, queued: null } };
const libraries = { t5: newestReadyLibraryId('t5'), t6: newestReadyLibraryId('t6') };
const requestedGames = query.game === 'all' ? ['t5', 't6'] as const : [query.game];
const now = Date.now();
const hosts: CapacityHost[] = [...snapshot.hosts.values()].filter(({ host }) => !query.hostId || host.id === query.hostId).map(observation => {
const { host, calibration } = observation;
const fresh = !!host.lastHeartbeat && now - Date.parse(host.lastHeartbeat) <= HEARTBEAT_MAX_AGE_MS;
const connected = host.connected && fresh;
const games = (['t5', 't6'] as const).filter(game => libraries[game] && host.inventory.libraries?.some(
library => library.id === libraries[game] && ['ready', 'outdated'].includes(library.state)));
const reasons: string[] = [];
if (!connected) reasons.push(host.enrolled ? 'Waiting for a fresh host report.' : 'This host has not enrolled yet.');
if (host.mode !== 'active') reasons.push(host.mode === 'draining' ? 'Host is draining.' : 'Host is disabled.');
if (host.decommission) reasons.push('Host is being removed.');
if (!host.credentialActive && host.enrolled) reasons.push('Host credentials are inactive.');
if (host.readiness !== 'ready') reasons.push(...host.readinessReasons);
if (!host.inventory.runtimeImages?.includes(config.komodo.defaultImage)) reasons.push('The default runtime is not ready.');
if (!requestedGames.some(game => games.includes(game))) reasons.push('Latest game files for this selection are not ready.');
if ((host.inventory.storageAvailable ?? 0) < host.minimumFreeBytes + 1024 ** 3) reasons.push('Storage is below the launch reserve.');
const eligible = connected && host.readiness === 'ready' && reasons.length === 0;
const cpuCoresAvailable = fresh ? observation.freeCpu : null;
const memoryBytesAvailable = fresh ? observation.freeMemory : null;
const portsAvailable = freePorts(observation);
const limits = { cpu: cpuCoresAvailable === null ? null : Math.max(0, Math.floor(cpuCoresAvailable / calibration.running.cpuCores)),
memory: memoryBytesAvailable === null ? null : Math.max(0, Math.floor(memoryBytesAvailable / calibration.running.memoryBytes)),
ports: portsAvailable };
const estimatedServers = !eligible ? 0 : limits.cpu === null || limits.memory === null ? null : Math.min(limits.cpu, limits.memory, limits.ports);
const limitingResource: CapacityHost['limitingResource'] = estimatedServers === null || !eligible ? null
: limits.ports === estimatedServers ? 'Ports' : limits.memory === estimatedServers ? 'Memory' : 'CPU';
if (eligible && (limits.cpu === null || limits.memory === null)) reasons.push('Waiting for valid CPU and memory measurements.');
else if (eligible && estimatedServers === 0) reasons.push(`No additional servers currently fit: ${limitingResource?.toLowerCase()} capacity is full.`);
return { id: host.id, name: host.name, readiness: host.readiness, connected, updatedAt: host.lastHeartbeat,
eligible, games, reasons: [...new Set(reasons)], cpuCoresAvailable, memoryBytesAvailable,
portsAvailable, totalPorts: host.portMax - host.portMin + 1, estimatedServers, limits, limitingResource, calibration };
}).sort((first, second) => first.name.localeCompare(second.name));
const eligible = hosts.filter(host => host.eligible);
const sum = (key: 'cpuCoresAvailable' | 'memoryBytesAvailable' | 'estimatedServers') =>
eligible.some(host => host[key] === null) ? null : eligible.reduce((total, host) => total + (host[key] ?? 0), 0);
const warnings: string[] = [];
if (!hosts.length) warnings.push(query.hostId ? 'The selected host is unavailable.' : 'Add a host to start tracking launch capacity.');
else if (!eligible.length) warnings.push('No host is ready for this game selection. Review the host details below.');
else if (eligible.length < hosts.length) warnings.push(`${hosts.length - eligible.length} host${hosts.length - eligible.length === 1 ? ' is' : 's are'} excluded from the available totals.`);
if (eligible.some(host => host.estimatedServers === null)) warnings.push('Some resource measurements are missing; the total estimate is unknown.');
if (!requestedGames.some(game => libraries[game])) warnings.push('Upload game files before launching this selection.');
return { updatedAt: snapshot.updatedAt, hosts,
hostOptions: [...snapshot.hosts.values()].map(({ host }) => ({ id: host.id, name: host.name })).sort((a, b) => a.name.localeCompare(b.name)),
warnings, summary: { estimatedServers: sum('estimatedServers'), cpuCoresAvailable: sum('cpuCoresAvailable'),
memoryBytesAvailable: sum('memoryBytesAvailable'), portsAvailable: eligible.reduce((total, host) => total + host.portsAvailable, 0),
eligibleHosts: eligible.length, totalHosts: hosts.length, startupSlots: Math.max(0, snapshot.startupLimit - snapshot.active),
startupLimit: snapshot.startupLimit, activeStartups: snapshot.active, queued: snapshot.queued } };
}