90 lines
4.2 KiB
TypeScript
90 lines
4.2 KiB
TypeScript
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<string, { game: string; map: string }>();
|
|
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()] };
|
|
}
|