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
+3 -3
View File
@@ -10,7 +10,7 @@ T5 game-wide policies cover the ten standard Zombies maps. Dead Ops Arcade is no
Choose **Add policy**, select the game and scope, and enter an explicit maximum. The initial editor selects Off, with a minimum of one running lobby, one empty spare, and a 15-minute empty interval. Counts must be between 1 and 20; the minimum and spare target cannot exceed the maximum. Choose **Add policy**, select the game and scope, and enter an explicit maximum. The initial editor selects Off, with a minimum of one running lobby, one empty spare, and a 15-minute empty interval. Counts must be between 1 and 20; the minimum and spare target cannot exceed the maximum.
Choose **Save policy** to apply changes directly. Saving checks the current fleet, policy revision, ownership conflicts, and host readiness. If saving is blocked, the form keeps your settings and shows the reason. Saved policies show their lobbies, key availability, current decision, and blockers. Choose **Save policy** to apply changes directly. Saving checks the current fleet, policy revision, ownership conflicts, and host readiness. If saving is blocked, the form keeps your settings and shows the reason. Saved policies show lobby counts, key availability, and the current status. An active job appears once with its latest stage and a **View job** link. Use **Lobbies** for per-map spare coverage and occupancy evidence, **Activity** for decision changes grouped by job, and **Metrics** for observed capacity and provisioning measurements.
| Autoscaling | Behavior | | Autoscaling | Behavior |
| --- | --- | | --- | --- |
@@ -33,7 +33,7 @@ Current positive occupancy from either telemetry or RCON vetoes an empty claim.
## Pause and cancellation ## Pause and cancellation
Use **Pause all** to stop new decisions globally, or **Edit or pause → Off** for one policy. A global pause retains each live policy's ownership; setting it Off releases ownership once its outstanding operation settles. Resuming the global switch preserves each policy's On or Off setting. Use **Pause all** to stop new decisions globally, or **Pause** on one policy. Use **Resume** to enable it again; the usual activation checks still apply. A global pause retains each live policy's ownership; setting it Off releases ownership once its outstanding operation settles. Resuming the global switch preserves each policy's On or Off setting.
Unsubmitted creates and queued removals are withdrawn where possible. Cancelling an autoscaling operation from Provisioning also turns its policy Off so it cannot immediately request a replacement. Unsubmitted creates and queued removals are withdrawn where possible. Cancelling an autoscaling operation from Provisioning also turns its policy Off so it cannot immediately request a replacement.
@@ -45,7 +45,7 @@ Turning a policy Off does not recreate lobbies it removed or remove lobbies it c
## Delete a policy ## Delete a policy
Choose **Delete policy** and confirm to remove its settings, decisions, and measurements. Existing lobbies, saved keys, and Provisioning history are kept. Deleting a live policy releases ownership without changing the lineup, and a new policy can reuse the same scope. Open the policy’s **More actions** menu, choose **Delete policy**, and confirm to remove its settings, decisions, and measurements. Existing lobbies, saved keys, and Provisioning history are kept. Deleting a live policy releases ownership without changing the lineup, and a new policy can reuse the same scope.
Deletion requires the current policy revision and no unsettled autoscaling operation. If work is outstanding, turn the policy Off and wait for its operation to settle before deleting. Delivered or uncertain host commands retain their policy and admission evidence until reconciliation finishes. Deletion requires the current policy revision and no unsettled autoscaling operation. If work is outstanding, turn the policy Off and wait for its operation to settle before deleting. Delivered or uncertain host commands retain their policy and admission evidence until reconciliation finishes.
+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 { page } from '$app/state';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.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 { zombieMaps, zombieMapBySlug } from '$lib/map-aliases.js';
import type { ScalingSettings, ScalingPolicy, ScalingReport } from '$lib/autoscaling.js'; import type { ScalingSettings, ScalingPolicy, ScalingReport } from '$lib/autoscaling.js';
let { data }: { data: import('./$types').PageData } = $props(); let { data }: { data: import('./$types').PageData } = $props();
@@ -18,7 +18,11 @@
let editorOpen = $state(false); let editorOpen = $state(false);
let busy = $state(false), actionError = $state(''), refreshError = $state(''), notice = $state(''); let busy = $state(false), actionError = $state(''), refreshError = $state(''), notice = $state('');
let generation = 0; 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) { async function refresh(signal?: AbortSignal) {
const version = ++generation; const version = ++generation;
try { try {
@@ -43,7 +47,7 @@
busy = true; actionError = ''; notice = ''; ++generation; busy = true; actionError = ''; notice = ''; ++generation;
try { try {
await post({ action: 'save', settings, id: editing?.id, revision: editing?.revision }); 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; } } catch (error) { actionError = (error as Error).message; }
finally { busy = false; } finally { busy = false; }
} }
@@ -60,9 +64,19 @@
} catch (error) { actionError = (error as Error).message; } } catch (error) { actionError = (error as Error).message; }
finally { busy = false; } 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() { async function pauseAll() {
busy = true; actionError = ''; ++generation; 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; } catch (error) { actionError = (error as Error).message; }
finally { busy = false; } finally { busy = false; }
} }
@@ -73,7 +87,7 @@
<SectionHeader title="Autoscaling" subtitle="Keep empty lobbies available for new groups, within the limits you choose."> <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} {#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> </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 refreshError}<p role="alert">{refreshError}</p>{/if}
{#if actionError}<p role="alert">{actionError}</p>{/if} {#if actionError}<p role="alert">{actionError}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if} {#if notice}<p role="status">{notice}</p>{/if}
@@ -100,36 +114,20 @@
{/if} {/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} {#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)} {#each report.policies as view (view.policy.id)}
<section class="panel" id={view.policy.id} aria-labelledby={`title-${view.policy.id}`}> <PolicyCard {view} globallyPaused={report.globalPaused} {canOperate} {busy} onedit={edit} ontoggle={togglePolicy} ondelete={remove} />
<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>
{/each} {/each}
</div> </div>
<style> <style>
.page { max-width: 1200px; margin: 0 auto; padding: 24px; } .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; } .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; } 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; } .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); } 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; } 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; } 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); } .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; } } @media (max-width: 500px) { .page { padding: 16px; } .panel { padding: 16px; } }
</style> </style>
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { render } from 'svelte/server';
import PolicyCard from '../../src/lib/ui/components/autoscaling/PolicyCard.svelte';
import { lobbyGroups, lobbyLabel, policyStatus, scalingActivity } from '../../src/lib/ui/autoscaling.js';
import type { ScalingHistory, ScalingView } from '../../src/lib/autoscaling.js';
const now = Date.parse('2026-10-07T20:00:00Z');
function view(): ScalingView {
return {
policy: { id: 'policy', game: 't5', mapSlug: '*', mode: 'create-destroy', minimum: 1, maximum: 3, spares: 1, emptyMinutes: 15, revision: 1, pausedReason: null, updatedAt: '' },
snapshot: { members: [
{ name: 't5_zombie_theater_1', mapSlug: 'theater', identity: 'one', state: 'empty', players: 0, observedAt: now, source: 'rcon', emptySince: now, blocker: null, operationId: null },
{ name: 't5_zombie_cosmodrome_1', mapSlug: 'cosmodrome', identity: 'two', state: 'creating', players: null, observedAt: null, source: null, emptySince: null, blocker: 'Provisioning job-1: Checking container readiness', operationId: 'job-1' }
], reservedNames: [], keyPool: { available: 13, total: 15 }, blockers: [] },
decision: { action: 'wait', reason: 'Provisioning job-1: Checking container readiness', server: null, occupied: 0, empty: 1, pending: 1, unknown: 0, deficit: 9, desired: 10 },
unsettled: [{ id: 'job-1', action: 'create', state: 'awaiting-health', stage: 'Checking container readiness' }],
blockers: [], ownsMap: true, history: [], lastEvaluatedAt: now,
totals: { observedMinutes: 12, emptyLobbyHours: 0.2, deficitMinutes: 5, unknownMinutes: 0 },
operations: { creates: 1, removals: 0, cancelled: 0, failed: 0, readinessSamples: 0, readinessMeanSeconds: null, readinessMaxSeconds: null }
};
}
function body(data = view(), canOperate = true, globallyPaused = false) {
return render(PolicyCard, { props: { view: data, canOperate, globallyPaused, busy: false, onedit: () => {}, ontoggle: () => {}, ondelete: () => {} } }).body;
}
function history(id: string, at: number, action: ScalingHistory['decision']['action'], reason: string, operationId: string | null = null): ScalingHistory {
return { id, at, lastSeenAt: at, decision: { ...view().decision, action, reason, server: action === 'create' ? 't5_zombie_cosmodrome_1' : null }, operationId, operationState: operationId ? 'done' : null };
}
describe('autoscaling presentation', () => {
it('shows a provisioning stage once and keeps diagnostic job links without routine deletion warnings', () => {
const html = body();
expect(html.split('Checking container readiness')).toHaveLength(2);
expect(html).toContain('Creating Ascension · Lobby 1');
expect(html).toContain('href="/provisioning/job-1"');
expect(html).not.toContain('Work is still settling');
expect(html).not.toContain('Provisioning job-1:');
expect(html).not.toContain('No current source');
expect(html).not.toContain('before deleting');
expect(html).not.toContain('<summary');
});
it('includes uncovered maps and preserves lobbies with unrecognized identities', () => {
const data = view();
data.snapshot.members.push({ ...data.snapshot.members[0], name: 'legacy-server', mapSlug: undefined });
const groups = lobbyGroups(data);
expect(groups.find(group => group.slug === 'theater')).toMatchObject({ ready: 1, target: 1 });
expect(groups.find(group => group.slug === 'moon')).toMatchObject({ ready: 0, target: 1, members: [] });
expect(groups.find(group => group.slug === 'unassigned')?.members[0].name).toBe('legacy-server');
expect(body()).toContain('1 / 10 maps meet spare target');
});
it('does not imply global readiness from one satisfied map', () => {
const data = view();
data.decision.reason = 'Kino Der Toten: Empty spare target is satisfied.';
expect(policyStatus(data, false).label).not.toBe('Spare targets satisfied');
data.decision.deficit = 0;
expect(policyStatus(data, false).label).toBe('Spare targets satisfied');
});
it('preserves attention and pause notices while a job is outstanding', () => {
const data = view();
data.policy.pausedReason = 'Uncertain host command. Review Provisioning recovery.';
data.unsettled[0].state = 'failed';
const html = body(data);
expect(html).toContain(data.policy.pausedReason);
expect(html).toContain('Review Ascension · Lobby 1 job');
expect(body(view(), true, true)).toContain('Globally paused');
data.policy.pausedReason = null;
data.policy.mode = 'off';
expect(body(data)).toContain('No new scaling requests. Outstanding jobs are still tracked.');
});
it('groups job wait decisions with their original request without swallowing unrelated blockers', () => {
const data = view();
data.history = [
history('wait-2', now + 30, 'wait', 'Provisioning job-1: Checking readiness'),
history('blocked', now + 20, 'wait', 'No available game keys.'),
history('wait-1', now + 10, 'wait', 'Provisioning job-1: Host command running'),
history('create', now, 'create', 'Replenish empty lobby capacity.', 'job-1')
];
const activity = scalingActivity(data);
expect(activity).toHaveLength(2);
expect(activity[0]).toMatchObject({ operationId: 'job-1', action: 'create', at: now, lastSeenAt: now + 30, evaluations: 3, operationState: 'done' });
expect(activity[1].reason).toBe('No available game keys.');
});
it('renders accessible tabs and hides mutation controls from viewers', () => {
const html = body(view(), false);
expect(html).toContain('role="tablist"');
expect(html).toContain('aria-labelledby="tab-policy-lobbies"');
expect(html).toContain('role="tabpanel"');
expect(html).not.toContain('Edit policy');
expect(html).not.toContain('More actions');
expect(lobbyLabel('legacy-server')).toBe('legacy-server');
});
});