240 lines
19 KiB
Svelte
240 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import type { ScalingPolicy, ScalingView, ScalingMember } from '$lib/autoscaling.js';
|
|
import { parseServerName, 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';
|
|
import ObservationTime from './ObservationTime.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 now = $state(Date.now());
|
|
onMount(() => {
|
|
now = Date.now();
|
|
const timer = setInterval(() => now = Date.now(), 1000);
|
|
return () => clearInterval(timer);
|
|
});
|
|
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 healthyCoverage = $derived(status.tone === 'online' && mapCount > 0 && covered === mapCount && view.policy.mapSlug === '*' ? `All ${mapCount} maps meet their spare target.` : null);
|
|
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}>{healthyCoverage ?? 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 === '*' && !healthyCoverage}<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>
|
|
{#each group.members as member, index (member.name)}
|
|
{@const pending = member.state === 'creating' || member.state === 'removing'}
|
|
{@const number = parseServerName(member.name)?.lobby}
|
|
<tr>
|
|
<td>
|
|
<div class="lobby-identity">
|
|
<a class="lobby-link" href={`/servers/${encodeURIComponent(member.name)}`} aria-label={lobbyLabel(member.name)} title={member.name}>{`${index === 0 && group.slug !== 'unassigned' ? `${group.label} · ` : ''}${number == null ? member.name : `Lobby ${number}`}`}</a>
|
|
{#if index === 0 && group.target}<span class="coverage" class:warning={group.ready < group.target}>{group.ready} / {group.target} spares</span>{/if}
|
|
</div>
|
|
{#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 !== null}<ObservationTime at={member.observedAt} source={member.source} {now} />{:else}<span class:warning={!pending}>{pending ? 'Awaiting first observation' : 'No recent observation'}</span>{/if}</td>
|
|
</tr>
|
|
{:else}
|
|
<tr><td><div class="lobby-identity"><span>{group.label}</span>{#if group.target}<span class="coverage warning">0 / {group.target} spares</span>{/if}</div></td><td colspan="3" class="empty-map">No lobbies yet</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: 10px 16px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }
|
|
tbody:last-child tr:last-child td { border-bottom: 0; }
|
|
.lobby-identity { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 12px; }
|
|
.coverage { color: var(--text-tertiary); font-size: var(--text-xs); white-space: nowrap; }
|
|
.coverage.warning { color: var(--state-warning); }
|
|
.lobby-link { font-weight: 500; text-decoration: none; }
|
|
.lobby-link:hover, .job-link:hover { text-decoration: underline; }
|
|
.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 { 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); }
|
|
.empty-map { grid-column: 1 / -1; }
|
|
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); padding-inline: 16px; }
|
|
footer { padding-inline: 16px; }
|
|
}
|
|
</style>
|