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
@@ -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>