import { db } from '../db/index.js'; import { parseServerName, zombieMaps } from '../../map-aliases.js'; import type { AutoscalingMetrics, MetricsScope } from '../../metrics.js'; /** Provisioning sources survive policy/decision deletion and server removal. */ export function readAutoscalingMetrics(scope: MetricsScope, since: number, now: number) { // Group in SQLite to avoid transferring every operation. IN deduplicates source // links, so one operation always counts once. Keep older groups for scope options. const groups = db() .prepare( `WITH operations AS ( SELECT server, action, state, finished_at >= ? AS in_window, ROUND((julianday(finished_at)-julianday(started_at))*86400000)/1000.0 AS seconds FROM server_deployments WHERE id IN (SELECT operation_id FROM provisioning_sources WHERE kind='autoscaling') AND finished_at <= ? AND state IN ('done','warning','failed','cancelled') ), measured AS ( SELECT *, CASE WHEN in_window AND action='create' AND state IN ('done','warning') AND seconds >= 0 THEN seconds END AS readiness FROM operations ) SELECT server, SUM(in_window AND action='create' AND state IN ('done','warning')) AS added, SUM(in_window AND action='destroy' AND state IN ('done','warning')) AS removed, SUM(in_window AND state='failed') AS failed, SUM(in_window AND state='cancelled') AS cancelled, COUNT(readiness) AS readinessSamples, COALESCE(SUM(readiness),0) AS readinessSeconds, MAX(readiness) AS readinessMaxSeconds FROM measured GROUP BY server` ) .all(new Date(since).toISOString(), new Date(now).toISOString()) as (Omit< AutoscalingMetrics, 'readinessMeanSeconds' > & { server: string; readinessSeconds: number })[]; const summary: AutoscalingMetrics = { added: 0, removed: 0, failed: 0, cancelled: 0, readinessSamples: 0, readinessMeanSeconds: null, readinessMaxSeconds: null }; const maps = new Map(); let readinessSeconds = 0; for (const group of groups) { // Autoscaling names encode the selected variant. Never use today's server // registration or the policy scope (which may cover every map in a game). const parsed = parseServerName(group.server); const catalog = zombieMaps(parsed?.game ?? null); const entry = catalog.find( (map) => map.slug === parsed?.mapSlug || map.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === parsed?.mapSlug ); if (parsed && entry) { const pair = { game: parsed.game, map: entry.friendly }; maps.set(JSON.stringify(pair), pair); } if (scope.game !== null && parsed?.game !== scope.game) continue; if (scope.map !== null) { const label = scope.map.toLowerCase(); // A canonical T6 engine map can describe several variants; do not // silently combine Town, Farm, Bus Depot and TranZit for that label. const matches = catalog.filter( (map) => map.friendly.toLowerCase() === label || map.slug === label || map.canonical === label ); if (!entry || matches.length !== 1 || matches[0].slug !== entry.slug) continue; } summary.added += group.added; summary.removed += group.removed; summary.failed += group.failed; summary.cancelled += group.cancelled; summary.readinessSamples += group.readinessSamples; readinessSeconds += group.readinessSeconds; if (group.readinessMaxSeconds !== null) summary.readinessMaxSeconds = Math.max( summary.readinessMaxSeconds ?? 0, group.readinessMaxSeconds ); } if (summary.readinessSamples > 0) summary.readinessMeanSeconds = readinessSeconds / summary.readinessSamples; return { summary, maps: [...maps.values()] }; }