Simplify autoscaling status and policy details

This commit is contained in:
2026-10-07 16:24:12 -04:00
parent 8229a23941
commit 40df99c115
5 changed files with 426 additions and 27 deletions
+81
View File
@@ -0,0 +1,81 @@
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);
}
@@ -0,0 +1,229 @@
<script lang="ts">
import type { ScalingPolicy, ScalingView, ScalingMember } from '$lib/autoscaling.js';
import { zombieMapBySlug } from '$lib/map-aliases.js';
import { lobbyGroups, lobbyLabel, policyStatus, scalingActivity, scalingReason } from '$ui/autoscaling.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
import Button from '../primitives/Button.svelte';
import Stat from '../primitives/Stat.svelte';
import StatusBadge from '../primitives/StatusBadge.svelte';
import Tabs from '../primitives/Tabs.svelte';
let { view, globallyPaused, canOperate, busy, onedit, ontoggle, ondelete }: {
view: ScalingView; globallyPaused: boolean; canOperate: boolean; busy: boolean;
onedit: (policy: ScalingPolicy) => void;
ontoggle: (policy: ScalingPolicy) => void;
ondelete: (policy: ScalingPolicy) => void;
} = $props();
let tab = $state('lobbies');
let menuOpen = $state(false);
let menuElement = $state<HTMLDivElement | null>(null);
const title = $derived(`${view.policy.mapSlug === '*' ? 'Every map' : zombieMapBySlug(view.policy.game, view.policy.mapSlug)?.friendly ?? view.policy.mapSlug} · ${view.policy.game.toUpperCase()}`);
const status = $derived(policyStatus(view, globallyPaused));
const groups = $derived(lobbyGroups(view));
const activity = $derived(scalingActivity(view));
const activeIds = $derived(new Set(view.unsettled.map(operation => operation.id)));
const covered = $derived(groups.filter(group => group.target > 0 && group.ready >= group.target).length);
const mapCount = $derived(groups.filter(group => group.target > 0).length);
const dates = (at: number) => $timeFormat.dateTime(new Date(at).toISOString());
const memberStatus = (state: ScalingMember['state']) => ({
occupied: 'Occupied', empty: 'Ready spare', creating: 'Starting', removing: 'Removing', stopped: 'Stopped', unknown: 'Unknown', protected: 'Protected'
})[state];
const memberTone = (state: ScalingMember['state']) => state === 'empty' ? 'online' : state === 'occupied' ? 'neutral' : state === 'creating' || state === 'removing' ? 'info' : 'warning';
function closeMenu(event: MouseEvent) {
if (menuOpen && event.target instanceof Node && !menuElement?.contains(event.target)) menuOpen = false;
}
</script>
<svelte:window onclick={closeMenu} onkeydown={event => { if (event.key === 'Escape' && menuOpen) { menuOpen = false; menuElement?.querySelector('button')?.focus(); } }} />
<section class="policy" id={view.policy.id} aria-labelledby={`title-${view.policy.id}`}>
<header class="heading">
<div class="identity">
<h2 id={`title-${view.policy.id}`}>{title}</h2>
<Badge variant={view.policy.mode === 'off' ? 'neutral' : 'info'}>{view.policy.mode === 'off' ? 'Paused' : 'Enabled'}</Badge>
</div>
{#if canOperate}
<div class="actions">
<Button size="sm" disabled={busy} onclick={() => onedit(view.policy)}>Edit policy</Button>
<Button size="sm" variant="ghost" disabled={busy} onclick={() => ontoggle(view.policy)}>{view.policy.mode === 'off' ? 'Resume' : 'Pause'}</Button>
<div class="more" bind:this={menuElement}>
<button class="more-toggle" type="button" aria-label={`More actions for ${title}`} aria-expanded={menuOpen} aria-controls={`actions-${view.policy.id}`} onclick={() => menuOpen = !menuOpen}>•••</button>
{#if menuOpen}
<div class="action-menu" id={`actions-${view.policy.id}`}>
<Button size="sm" variant="danger" disabled={busy || view.unsettled.length > 0} onclick={() => { menuOpen = false; ondelete(view.policy); }}>Delete policy</Button>
{#if view.unsettled.length}<p>Pause this policy and wait for outstanding jobs to finish before deleting it.</p>{/if}
</div>
{/if}
</div>
</div>
{/if}
</header>
<div class="counts" aria-label="Lobby counts across this policy">
<Stat label="Occupied" value={view.decision.occupied} tween={false} />
<Stat label="Ready spares" value={view.decision.empty} tone="online" tween={false} />
<Stat label="Pending" value={view.decision.pending} tone={view.decision.pending ? 'accent' : 'primary'} tween={false} />
<Stat label="Unknown / stopped" value={view.decision.unknown} tone={view.decision.unknown ? 'warning' : 'primary'} tween={false} />
</div>
<div class="settings">
<p>{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Min {view.policy.minimum} · Max {view.policy.maximum} · Spare target {view.policy.spares} · Remove after {view.policy.emptyMinutes} minutes empty</p>
<p class:warning={view.snapshot.keyPool.available === 0}>{view.snapshot.keyPool.available} / {view.snapshot.keyPool.total} game keys available <span class="muted">· Shared across {view.policy.game.toUpperCase()}</span></p>
</div>
{#if !view.unsettled.length || globallyPaused || view.policy.mode === 'off' || view.policy.pausedReason}
<div class="summary" class:attention={status.tone === 'warning'}>
<Badge variant={status.tone}>{status.label}</Badge>
{#if status.reason}<span>{status.reason}</span>{/if}
</div>
{/if}
{#each view.unsettled as operation (operation.id)}
{@const member = view.snapshot.members.find(member => member.operationId === operation.id)}
{@const needsAttention = ['failed', 'interrupted', 'manual', 'unknown', 'cancelled', 'warning'].includes(operation.state)}
<div class="job" class:attention={needsAttention}>
<span class="job-dot" class:working={!needsAttention} aria-hidden="true"></span>
<div class="job-copy">
<div class="job-title"><strong>{needsAttention ? 'Review' : operation.action === 'destroy' ? 'Removing' : 'Creating'} {member ? lobbyLabel(member.name) : 'lobby'}{needsAttention ? ' job' : ''}</strong><StatusBadge state={operation.state} /></div>
<p>{operation.stage || 'Waiting for the next provisioning update.'}</p>
</div>
<Button size="sm" variant="ghost" href={`/provisioning/${operation.id}`}>View job</Button>
</div>
{/each}
<div class="detail-nav">
<Tabs tabs={[{ id: 'lobbies', label: 'Lobbies', badge: view.snapshot.members.length }, { id: 'activity', label: 'Activity' }, { id: 'metrics', label: 'Metrics' }]} bind:value={tab} ariaLabel={`Details for ${title}`} panelId={`panel-${view.policy.id}`} idPrefix={`tab-${view.policy.id}`} size="sm" />
{#if tab === 'lobbies' && view.policy.mapSlug === '*'}<span class="muted">{covered} / {mapCount} maps meet spare target</span>{/if}
</div>
<div class="tab-panel" id={`panel-${view.policy.id}`} role="tabpanel" aria-labelledby={`tab-${view.policy.id}-${tab}`} tabindex="0">
{#if tab === 'lobbies'}
<div class="table-scroll">
<table>
<thead><tr><th>Map / lobby</th><th>State</th><th>Players</th><th>Latest observation</th></tr></thead>
{#each groups as group (group.slug)}
<tbody>
<tr class="map-row"><th colspan="4" scope="rowgroup"><span>{group.label}</span>{#if group.target}<span class="coverage" class:warning={group.ready < group.target}>{group.ready} / {group.target} ready spares</span>{/if}{#if !group.members.length}<span class="empty-map">No lobbies yet</span>{/if}</th></tr>
{#each group.members as member (member.name)}
{@const pending = member.state === 'creating' || member.state === 'removing'}
<tr>
<td><a class="lobby-link" href={`/servers/${encodeURIComponent(member.name)}`}>{lobbyLabel(member.name)}</a><span class="technical">{member.name}</span>
{#if member.blocker && !activeIds.has(member.operationId ?? '')}<span class="member-note">{scalingReason(member.blocker)}</span>{/if}
</td>
<td><Badge variant={memberTone(member.state)}>{memberStatus(member.state)}</Badge>{#if member.operationId}<a class="job-link" href={`/provisioning/${member.operationId}`}>View job</a>{/if}</td>
<td class="players">{member.players ?? '—'}</td>
<td class="observation">{#if member.observedAt}<time datetime={new Date(member.observedAt).toISOString()}>{dates(member.observedAt)}</time><span class="technical">{member.source ?? 'Source unavailable'}</span>{:else}<span class="muted">{pending ? 'Awaiting first observation' : 'No recent observation'}</span>{/if}</td>
</tr>
{/each}
</tbody>
{/each}
</table>
</div>
{#each view.snapshot.blockers.filter(blocker => scalingReason(blocker) !== status.reason && blocker !== 'Autoscaling is globally paused.') as blocker}<p class="blocker">{scalingReason(blocker)}</p>{/each}
{:else if tab === 'activity'}
<p class="section-note">Recent decision changes · Provisioning updates are grouped by job.</p>
<ol class="activity">
{#each activity as item (item.id)}
<li>
<div class="activity-heading"><strong>{item.action === 'create' ? 'Creation requested' : item.action === 'destroy' ? 'Removal requested' : item.operationId ? 'Provisioning in progress' : 'Scaling evaluated'}{item.server ? ` · ${lobbyLabel(item.server)}` : ''}</strong>{#if item.operationState}<StatusBadge state={item.operationState} />{/if}</div>
{#if !item.operationId || !activeIds.has(item.operationId)}<p>{item.reason}</p>{/if}
<div class="activity-meta"><time datetime={new Date(item.at).toISOString()}>{dates(item.at)}</time>{#if item.evaluations > 1}<span>{item.evaluations} decision changes</span>{/if}{#if item.operationId}<a href={`/provisioning/${item.operationId}`}>View job</a>{/if}</div>
</li>
{:else}<li class="muted">No scaling activity yet.</li>{/each}
</ol>
{:else}
<p class="section-note">Last 31 days · {view.totals.observedMinutes.toFixed(1)} minutes observed</p>
<div class="metrics">
<Stat label="Empty lobby-hours" value={view.totals.emptyLobbyHours.toFixed(2)} />
<Stat label="Below spare target" value={view.totals.deficitMinutes.toFixed(1)} unit="min" />
<Stat label="Unknown occupancy" value={view.totals.unknownMinutes.toFixed(1)} unit="min" />
<Stat label="Create requests" value={view.operations.creates} tween={false} />
<Stat label="Removal requests" value={view.operations.removals} tween={false} />
<Stat label="Cancelled / failed" value={`${view.operations.cancelled} / ${view.operations.failed}`} tone={view.operations.failed ? 'warning' : 'primary'} />
</div>
{#if view.operations.readinessSamples}
<p class="readiness">Request to verified readiness: <strong>{view.operations.readinessMeanSeconds?.toFixed(0)}s average</strong> · {view.operations.readinessMaxSeconds?.toFixed(0)}s maximum · {view.operations.readinessSamples} completed creates</p>
{/if}
<p class="muted metric-note">Observation gaps are excluded. Readiness includes queue time and does not confirm server browser visibility or successful joins.</p>
{/if}
</div>
<footer>Checks every 15 seconds <span>·</span> Last evaluated: {view.lastEvaluatedAt ? dates(view.lastEvaluatedAt) : 'Not evaluated yet'}</footer>
</section>
<style>
.policy { margin: 20px 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow-wrap: anywhere; }
.heading { padding: 20px 24px 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.identity, .actions, .job-title, .activity-heading { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 0; }
.more { position: relative; }
.more-toggle { border: 1px solid var(--border-subtle); background: transparent; color: var(--text-secondary); border-radius: var(--radius-sm); min-width: 34px; height: 32px; cursor: pointer; letter-spacing: 2px; }
.more-toggle:focus-visible, .tab-panel:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.action-menu { position: absolute; z-index: 5; right: 0; top: 40px; width: 240px; max-width: calc(100vw - 80px); padding: 12px; background: var(--surface-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); box-shadow: 0 8px 24px #0005; }
.action-menu p { font-size: var(--text-xs); line-height: 1.5; color: var(--text-secondary); margin: 10px 0 0; }
.counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; padding: 24px; }
.settings { padding: 0 24px; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
.settings p { margin: 0 0 4px; }
.muted { color: var(--text-tertiary); font-size: var(--text-xs); }
.warning { color: var(--state-warning); }
.summary, .job { margin: 18px 24px 0; display: flex; align-items: center; gap: 12px; font-size: var(--text-sm); }
.summary { flex-wrap: wrap; color: var(--text-secondary); }
.summary.attention { border-left: 2px solid var(--state-warning); padding-left: 12px; }
.job { background: var(--surface-raised); border-radius: var(--radius-md); padding: 12px 14px; }
.job-copy { flex: 1; min-width: 0; }
.job-copy p { margin: 4px 0 0; color: var(--text-secondary); line-height: 1.5; }
.job-dot { width: 7px; height: 7px; flex-shrink: 0; border-radius: 50%; background: var(--state-warning); }
.job-dot.working { background: var(--accent-primary); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent-primary) 12%, transparent); }
.job.attention { border: 1px solid var(--state-warning); }
.detail-nav { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 24px 24px 14px; }
.tab-panel { min-height: 120px; }
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
thead th { font-size: var(--text-xs); font-weight: 500; color: var(--text-tertiary); padding: 8px 16px 12px; }
th:first-child, td:first-child { padding-left: 24px; }
th:last-child, td:last-child { padding-right: 24px; }
td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }
tbody:last-child tr:last-child td { border-bottom: 0; }
.map-row th { padding-top: 9px; padding-bottom: 9px; background: color-mix(in srgb, var(--surface-raised) 60%, transparent); border-block: 1px solid var(--border-subtle); font-size: var(--text-xs); font-weight: 600; }
.coverage { display: inline-block; margin-left: 16px; color: var(--text-secondary); font-weight: 400; }
.coverage.warning { color: var(--state-warning); }
.lobby-link { font-weight: 500; text-decoration: none; }
.lobby-link:hover, .job-link:hover { text-decoration: underline; }
.technical { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-tertiary); }
.member-note { display: block; margin-top: 5px; color: var(--state-warning); font-size: var(--text-xs); }
.job-link { display: block; margin-top: 6px; font-size: var(--text-xs); text-decoration: none; }
.players { font-variant-numeric: tabular-nums; }
.observation { color: var(--text-secondary); font-size: var(--text-xs); }
.empty-map { display: inline-block; margin-left: 16px; font-weight: 400; color: var(--text-tertiary); font-size: var(--text-xs); }
.section-note { margin: 0; padding: 6px 24px 16px; font-size: var(--text-xs); color: var(--text-secondary); }
.activity { margin: 0; padding: 0 24px; list-style: none; }
.activity li { padding: 16px 0; border-top: 1px solid var(--border-subtle); font-size: var(--text-sm); }
.activity-heading strong { font-weight: 500; }
.activity p { color: var(--text-secondary); margin: 8px 0; line-height: 1.5; }
.activity-meta { margin-top: 8px; display: flex; gap: 12px; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-tertiary); }
.metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; padding: 8px 24px 24px; }
.readiness, .metric-note { margin: 0; padding: 0 24px 16px; line-height: 1.6; }
.readiness { font-size: var(--text-sm); color: var(--text-secondary); }
.blocker { margin: 12px 24px; color: var(--state-warning); font-size: var(--text-sm); }
footer { border-top: 1px solid var(--border-subtle); padding: 12px 24px; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
footer span { margin: 0 5px; }
@media (max-width: 640px) {
.heading { align-items: flex-start; padding: 16px 16px 0; }
.counts { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 20px 16px; }
.settings { padding-inline: 16px; }
.summary, .job { margin-inline: 16px; }
.job { flex-wrap: wrap; }
.detail-nav { padding: 20px 16px 12px; }
.table-scroll { overflow: visible; }
table, tbody { display: block; width: 100%; }
thead { display: none; }
tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); gap: 10px; }
td, th:first-child, td:first-child, th:last-child, td:last-child { padding: 0; border: 0; }
td:first-child, .observation { grid-column: 1 / -1; }
.players::before { content: 'Players: '; color: var(--text-tertiary); font-size: var(--text-xs); }
.map-row { display: block; padding: 0; border: 0; }
.map-row th { display: block; padding: 10px 16px; }
.coverage { margin-left: 10px; }
.empty-map { display: block; margin: 4px 0 0; }
.technical { overflow-wrap: anywhere; }
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); padding-inline: 16px; }
footer { padding-inline: 16px; }
}
</style>
+22 -24
View File
@@ -3,9 +3,9 @@
import { page } from '$app/state';
import { confirmAction } from '$ui/confirm.js';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import PolicyCard from '$ui/components/autoscaling/PolicyCard.svelte';
import { zombieMaps, zombieMapBySlug } from '$lib/map-aliases.js';
import type { ScalingSettings, ScalingPolicy, ScalingReport } from '$lib/autoscaling.js';
let { data }: { data: import('./$types').PageData } = $props();
@@ -18,7 +18,11 @@
let editorOpen = $state(false);
let busy = $state(false), actionError = $state(''), refreshError = $state(''), notice = $state('');
let generation = 0;
const modeLabel = (mode: string) => ({ off: 'Off', 'create-destroy': 'On' })[mode] ?? mode;
$effect(() => {
if (!notice) return;
const timer = setTimeout(() => notice = '', 6000);
return () => clearTimeout(timer);
});
async function refresh(signal?: AbortSignal) {
const version = ++generation;
try {
@@ -43,7 +47,7 @@
busy = true; actionError = ''; notice = ''; ++generation;
try {
await post({ action: 'save', settings, id: editing?.id, revision: editing?.revision });
notice = 'Policy saved. Evaluation runs every 15 seconds.'; editorOpen = false; await refresh();
notice = 'Policy saved.'; editorOpen = false; await refresh();
} catch (error) { actionError = (error as Error).message; }
finally { busy = false; }
}
@@ -60,9 +64,19 @@
} catch (error) { actionError = (error as Error).message; }
finally { busy = false; }
}
async function togglePolicy(policy: ScalingPolicy) {
busy = true; actionError = ''; notice = ''; ++generation;
try {
await post({ action: 'save', id: policy.id, revision: policy.revision, settings: { ...policy, mode: policy.mode === 'off' ? 'create-destroy' : 'off' } });
notice = policy.mode === 'off' ? 'Policy resumed.' : 'Policy paused. Outstanding jobs are still tracked.';
if (editing?.id === policy.id) { editing = null; editorOpen = false; }
await refresh();
} catch (error) { actionError = (error as Error).message; }
finally { busy = false; }
}
async function pauseAll() {
busy = true; actionError = ''; ++generation;
try { await post({ action: 'global-pause', paused: !report.globalPaused }); notice = report.globalPaused ? 'Autoscaling resumed.' : 'Autoscaling paused. Review any unsettled operations below.'; await refresh(); }
try { await post({ action: 'global-pause', paused: !report.globalPaused }); notice = report.globalPaused ? 'Autoscaling resumed.' : 'Autoscaling paused. Outstanding jobs are still tracked.'; await refresh(); }
catch (error) { actionError = (error as Error).message; }
finally { busy = false; }
}
@@ -73,7 +87,7 @@
<SectionHeader title="Autoscaling" subtitle="Keep empty lobbies available for new groups, within the limits you choose.">
{#snippet actions()}<Button onclick={() => refresh()}>Refresh</Button>{#if canOperate}<Button disabled={busy} onclick={pauseAll}>{report.globalPaused ? 'Resume autoscaling' : 'Pause all'}</Button><Button variant="primary" disabled={busy} onclick={() => edit(null)}>Add policy</Button>{/if}{/snippet}
</SectionHeader>
{#if report.globalPaused}<p class="banner">All autoscaling decisions are paused. Delivered operations may still need to settle.</p>{/if}
{#if report.globalPaused}<p class="banner">Autoscaling is paused globally. Outstanding jobs are still tracked and may finish.</p>{/if}
{#if refreshError}<p role="alert">{refreshError}</p>{/if}
{#if actionError}<p role="alert">{actionError}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
@@ -100,36 +114,20 @@
{/if}
{#if !report.policies.length}<p class="panel">No autoscaling policies. Add a policy to manage empty lobbies for a map or a whole game.</p>{/if}
{#each report.policies as view (view.policy.id)}
<section class="panel" id={view.policy.id} aria-labelledby={`title-${view.policy.id}`}>
<div class="heading"><h2 id={`title-${view.policy.id}`}>{view.policy.mapSlug === '*' ? 'Every map' : zombieMapBySlug(view.policy.game, view.policy.mapSlug)?.friendly ?? view.policy.mapSlug} · {view.policy.game.toUpperCase()}</h2><span>{modeLabel(view.policy.mode)}</span>{#if canOperate}<Button disabled={busy} onclick={() => edit(view.policy)}>Edit or pause</Button><Button variant="danger" disabled={busy || view.unsettled.length > 0} title={view.unsettled.length ? 'Pause the policy and settle outstanding work before deleting it.' : undefined} onclick={() => remove(view.policy)}>Delete policy</Button>{/if}</div>
<p class="counts">{view.decision.occupied} occupied · {view.decision.empty} empty and ready · {view.decision.pending} pending · {view.decision.unknown} unknown or stopped</p>
{#if view.unsettled.length}<div class="banner"><strong>Work is still settling.</strong><p>Pause this policy and settle its outstanding work before deleting it.</p>{#each view.unsettled as operation}<p><a href={`/provisioning/${operation.id}`}>{operation.action} · {operation.state}</a> — {operation.stage}</p>{/each}{#if view.ownsMap}<p>This policy retains ownership of its scope until its outstanding work settles.</p>{/if}</div>{/if}
<p>{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Minimum {view.policy.minimum} · Maximum {view.policy.maximum} · Spare target {view.policy.spares} · {view.snapshot.keyPool.available} / {view.snapshot.keyPool.total} game keys available</p>
<p><strong>Current decision: </strong>{view.decision.action}{view.decision.server ? ` ${view.decision.server}` : ''}. {view.decision.reason}</p>
<p class="muted">Last evaluated: {view.lastEvaluatedAt ? $timeFormat.dateTime(new Date(view.lastEvaluatedAt).toISOString()) : 'Not evaluated yet'}</p>
<details><summary>Lobbies and evidence</summary><ul>{#each view.snapshot.members as member}<li><a href={`/servers/${encodeURIComponent(member.name)}`}>{member.name}</a> · {member.state}{member.blocker ? ` · ${member.blocker}` : ''}<br />{member.source ?? 'No current source'} · {member.observedAt ? $timeFormat.dateTime(new Date(member.observedAt).toISOString()) : 'Unknown observation time'}{#if member.operationId} · <a href={`/provisioning/${member.operationId}`}>Provisioning</a>{/if}</li>{/each}</ul>{#if !view.snapshot.members.length}<p>No registered or pending lobbies.</p>{/if}{#each view.snapshot.blockers as blocker}<p>{blocker}</p>{/each}</details>
<details><summary>Recent decisions and measurements</summary>
<p>{view.totals.observedMinutes.toFixed(1)} observed minutes · {view.totals.emptyLobbyHours.toFixed(2)} empty lobby-hours · {view.totals.deficitMinutes.toFixed(1)} minutes below spare target · {view.totals.unknownMinutes.toFixed(1)} unknown minutes</p>
<p>Last 31 days: {view.operations.creates} create requests · {view.operations.removals} removal requests · {view.operations.cancelled} cancelled · {view.operations.failed} failed</p>
{#if view.operations.readinessSamples}<p>Request to verified readiness: mean {view.operations.readinessMeanSeconds?.toFixed(0)} seconds, maximum {view.operations.readinessMaxSeconds?.toFixed(0)} seconds across {view.operations.readinessSamples} completed creates. Includes queue time.</p>{/if}
<p class="muted">Up to 31 days of bounded observations; gaps are excluded. Readiness does not certify browser visibility or successful joins.</p>
<ol>{#each view.history as item}<li>{$timeFormat.dateTime(new Date(item.at).toISOString())} · {item.decision.action} {item.decision.server ?? ''}<br />{item.decision.reason}{#if item.operationId} · <a href={`/provisioning/${item.operationId}`}>{item.operationState ?? 'View operation'}</a>{/if}</li>{/each}</ol>
</details>
</section>
<PolicyCard {view} globallyPaused={report.globalPaused} {canOperate} {busy} onedit={edit} ontoggle={togglePolicy} ondelete={remove} />
{/each}
</div>
<style>
.page { max-width: 1200px; margin: 0 auto; padding: 24px; }
.panel { margin: 20px 0; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow-wrap: anywhere; }
.heading, .actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 0; }
.heading h2 { flex: 1 1 200px; } p, li { line-height: 1.6; } .muted { color: var(--text-secondary); font-size: var(--text-sm); }
p { line-height: 1.6; }
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 16px; margin-bottom: 20px; }
label { display: flex; flex-direction: column; gap: 8px; font-size: var(--text-sm); }
input, select { padding: 10px; min-width: 0; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); font: inherit; }
fieldset { border: 0; padding: 0; margin: 16px 0; min-width: 0; }
.banner { padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
details { margin-top: 16px; } summary { cursor: pointer; } li { margin: 10px 0; }
@media (max-width: 500px) { .page { padding: 16px; } .panel { padding: 16px; } }
</style>