Files
electric-cherry/src/lib/ui/autoscaling.ts
T

163 lines
6.3 KiB
TypeScript

import { freshStatusTimestamp } from '../server-freshness.js';
import type { ScalingHistory, ScalingMember, ScalingView } from '../autoscaling.js';
import { parseServerName, zombieMapBySlug, zombieMaps } from '../map-aliases.js';
export function lobbyLabel(name: string): string {
const parsed = parseServerName(name);
if (!parsed) return name;
const map = zombieMapBySlug(parsed.game, parsed.mapSlug);
return map ? `${map.friendly}${parsed.lobby === null ? '' : ` · Lobby ${parsed.lobby}`}` : name;
}
// Job identity belongs in its URL, not in every status description.
export function scalingReason(reason: string): string {
return reason.replace(/Provisioning [\w-]+: /g, '');
}
export function policyStatus(view: ScalingView, globallyPaused: boolean) {
if (view.policy.pausedReason)
return {
label: 'Needs attention',
reason: scalingReason(view.policy.pausedReason),
tone: 'warning' as const
};
if (globallyPaused || view.policy.mode === 'off')
return {
label: globallyPaused ? 'Globally paused' : 'Paused',
reason: view.unsettled.length
? 'No new scaling requests. Outstanding jobs are still tracked.'
: 'No new scaling requests.',
tone: 'neutral' as const
};
if (view.decision.action === 'create')
return {
label: 'Adding capacity',
reason: scalingReason(view.decision.reason),
tone: 'info' as const
};
if (view.decision.action === 'destroy')
return {
label: 'Removing surplus',
reason: scalingReason(view.decision.reason),
tone: 'info' as const
};
if (
view.decision.reason.endsWith('Empty spare target is satisfied.') &&
view.decision.deficit === 0
)
return { label: 'Spare targets satisfied', reason: '', tone: 'online' as const };
if (view.decision.reason.endsWith('Waiting for a continuous empty interval.'))
return {
label: 'Waiting to remove surplus',
reason: `Lobbies must stay empty for ${view.policy.emptyMinutes} minutes.`,
tone: 'neutral' as const
};
return {
label: 'Scaling on hold',
reason: scalingReason(view.decision.reason),
tone: 'warning' as const
};
}
export function lobbyGroups(view: ScalingView) {
const maps =
view.policy.mapSlug === '*'
? zombieMaps(view.policy.game)
: zombieMaps(view.policy.game).filter((map) => map.slug === view.policy.mapSlug);
const assigned = new Set<ScalingMember>();
const groups = maps.map((map) => {
const members = view.snapshot.members.filter(
(member) => (member.mapSlug ?? parseServerName(member.name)?.mapSlug) === map.slug
);
members.forEach((member) => assigned.add(member));
return {
slug: map.slug,
label: map.friendly,
members,
ready: members.filter((member) => member.state === 'empty').length,
target: view.policy.spares
};
});
const unassigned = view.snapshot.members.filter((member) => !assigned.has(member));
if (unassigned.length)
groups.push({
slug: 'unassigned',
label: 'Other lobbies',
members: unassigned,
ready: unassigned.filter((member) => member.state === 'empty').length,
target: 0
});
return groups;
}
export interface ScalingActivity {
id: string;
at: number;
lastSeenAt: number;
operationId: string | null;
operationState: string | null;
action: ScalingHistory['decision']['action'];
server: string | null;
reason: string;
evaluations: number;
}
export function scalingActivity(view: ScalingView): ScalingActivity[] {
const operationIds = new Set(
[
...view.history.map((item) => item.operationId),
...view.unsettled.map((item) => item.id)
].filter((id): id is string => !!id)
);
const groups = new Map<string, ScalingActivity>();
for (const item of view.history) {
// Older wait decisions store the job reference in their reason rather than operationId.
const reference = item.decision.reason.match(/Provisioning ([\w-]+):/)?.[1];
const operationId =
item.operationId ?? (reference && operationIds.has(reference) ? reference : null);
const key = operationId ?? item.id;
const existing = groups.get(key);
if (existing) {
existing.at = Math.min(existing.at, item.at);
existing.lastSeenAt = Math.max(existing.lastSeenAt, item.lastSeenAt);
existing.evaluations++;
if (item.operationId) {
existing.action = item.decision.action;
existing.server = item.decision.server;
existing.operationState = item.operationState;
existing.reason = scalingReason(item.decision.reason);
}
} else
groups.set(key, {
id: key,
at: item.at,
lastSeenAt: item.lastSeenAt,
operationId,
operationState: item.operationState,
action: item.decision.action,
server: item.decision.server,
reason: scalingReason(item.decision.reason),
evaluations: 1
});
}
return [...groups.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
}
/** Advance observation ages even when polling fails, using the fleet freshness window. */
export function observationAge(at: number, now: number): { label: string; stale: boolean } {
const age = now - at;
if (!Number.isFinite(at) || !Number.isFinite(age) || age < -1000)
return { label: 'Check observation time', stale: true };
const seconds = Math.max(0, Math.floor(age / 1000));
const elapsed =
seconds < 60
? `${seconds}s`
: seconds < 3600
? `${Math.floor(seconds / 60)}m`
: seconds < 86400
? `${Math.floor(seconds / 3600)}h`
: `${Math.floor(seconds / 86400)}d`;
const stale = !freshStatusTimestamp(new Date(at).toISOString(), now);
return { label: `${stale ? 'Stale · ' : ''}${elapsed} ago`, stale };
}