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(); 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(); 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 }; }