Files
electric-cherry/src/lib/metrics-insights.ts
T
2026-10-10 14:48:52 -04:00

135 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { MapMetrics } from './metrics.js';
import { metricDuration, metricPercent } from './ui/metrics.js';
/** Advisory thresholds for reviewing a policy, not demand forecasts or scaling commands. */
export const POLICY_SIGNAL_RULES = {
noEmptyShare: 0.02,
minimumEvidenceHours: 1,
minimumKnownShare: 0.8
} as const;
export interface MetricRecommendation {
game: string;
map: string;
kind: 'maximum' | 'spares' | 'policy' | 'reporting' | 'watch';
title: string;
reason: string;
evidence: string;
outcome: string;
tentative: boolean;
policySummary: string | null;
href: string | null;
action: string | null;
}
/** Compare captured availability with the current policy; historical settings are not recorded. */
export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] {
return maps
.map((map): MetricRecommendation => {
const availability = map.availabilityObserved + map.availabilityUnknown;
const knownShare = availability > 0 ? map.availabilityObserved / availability : 0;
const lobbyHours = map.observed + map.unknown;
const knownLobbyShare = lobbyHours > 0 ? map.observed / lobbyHours : 0;
const noEmptyShare =
map.availabilityObserved > 0 ? map.noEmpty / map.availabilityObserved : null;
const policy = map.policy;
const base = {
game: map.game,
map: map.map,
evidence:
`${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known lobby time (${metricPercent(map.active, map.observed)}). ` +
(map.unknown > 0
? `${metricDuration(map.unknown)} of lobby time has unknown occupancy. `
: '') +
(noEmptyShare === null
? 'Lobby availability is unknown.'
: `No empty lobby reported for ${metricDuration(map.noEmpty)} / ${metricDuration(map.availabilityObserved)} known map time (${metricPercent(map.noEmpty, map.availabilityObserved)}).`) +
(policy && noEmptyShare !== null
? ` At or above the current maximum (${policy.maximum}): ${metricDuration(map.noEmptyAtMaximum)}; below it: ${metricDuration(map.noEmptyBelowMaximum)}.`
: ''),
tentative:
map.availabilityObserved < POLICY_SIGNAL_RULES.minimumEvidenceHours ||
knownShare < POLICY_SIGNAL_RULES.minimumKnownShare ||
knownLobbyShare < POLICY_SIGNAL_RULES.minimumKnownShare,
policySummary: policy
? `Current policy: ${policy.minimum}–${policy.maximum} lobbies, ${policy.spares} spare${policy.spares === 1 ? '' : 's'}`
: null,
href: policy ? `/autoscaling#${encodeURIComponent(policy.id)}` : null,
action: policy ? 'Review policy' : null
};
if (noEmptyShare === null)
return {
...base,
kind: 'reporting',
title:
map.availabilityUnknown > 0 || map.unknown > 0
? 'Check occupancy reporting'
: 'Let observations accumulate',
reason:
map.availabilityUnknown > 0 || map.unknown > 0
? 'Captured occupancy is unknown. Check a lobby’s telemetry or RCON diagnostics before choosing a policy change.'
: 'This map has no elapsed availability observations in the selected window. Wait for another status check or try a wider range.',
outcome: 'Watch for known lobby availability in the next report.',
tentative: true
};
if (!policy)
return {
...base,
kind: 'policy',
title: 'Consider adding an autoscaling policy',
reason: 'This map has no current autoscaling policy. Set a minimum, maximum, and spare target to maintain an empty lobby as players arrive.',
outcome:
'Choose initial limits from observed lobby use, then compare empty-lobby availability over a comparable period.',
href: '/autoscaling',
action: 'Add policy'
};
if (policy.pausedReason || policy.mode === 'off')
return {
...base,
kind: 'policy',
title: policy.pausedReason
? 'Review and resume the autoscaling policy'
: 'Review and enable the autoscaling policy',
reason: policy.pausedReason
? `The current policy is paused: ${policy.pausedReason}`
: 'The current policy is off, so it cannot maintain the spare target.',
outcome:
'Review the policy and any blockers before enabling it, then observe availability before adjusting its limits.'
};
if (noEmptyShare >= POLICY_SIGNAL_RULES.noEmptyShare) {
if (map.noEmptyAtMaximum >= map.noEmptyBelowMaximum)
return {
...base,
kind: 'maximum',
title: 'Consider raising the policy maximum',
reason: `No empty lobby was reported for at least 2% of known map time. At least half of that time had a tracked lobby count at or above the current maximum of ${policy.maximum}; review whether that limit leaves room for the spare target.`,
outcome:
'Review host resources and create time, then compare empty-lobby availability after a maximum change.'
};
return {
...base,
kind: 'spares',
title: 'Review the spare target and create time',
reason: `No empty lobby was reported for at least 2% of known map time. Most of that time was below the current maximum of ${policy.maximum}; consider raising the spare target from ${policy.spares} and check how long new lobbies take to become ready.`,
outcome:
'Review creation delays and blockers, then compare empty-lobby availability after a spare-target change.'
};
}
return {
...base,
kind: 'watch',
title: 'Keep the policy and watch availability',
reason: 'No-empty-lobby time is below the policy review threshold. These observations do not suggest increasing the maximum or spare target.',
outcome: 'Watch for recurring periods without a reported empty lobby.'
};
})
.sort((a, b) => {
const priority = { maximum: 0, spares: 1, policy: 2, reporting: 3, watch: 4 };
return (
priority[a.kind] - priority[b.kind] ||
a.map.localeCompare(b.map) ||
a.game.localeCompare(b.game)
);
});
}