Simplify autoscaling status and policy details
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user