Compact autoscaling lobby rows and observation times
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { ScalingPolicy, ScalingView, ScalingMember } from '$lib/autoscaling.js';
|
||||
import { zombieMapBySlug } from '$lib/map-aliases.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';
|
||||
@@ -8,6 +9,7 @@
|
||||
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;
|
||||
@@ -16,6 +18,12 @@
|
||||
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()}`);
|
||||
@@ -25,6 +33,7 @@
|
||||
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'
|
||||
@@ -73,7 +82,7 @@
|
||||
|
||||
{#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>
|
||||
<Badge variant={status.tone}>{healthyCoverage ?? status.label}</Badge>
|
||||
{#if status.reason}<span>{status.reason}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -92,7 +101,7 @@
|
||||
|
||||
<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}
|
||||
{#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'}
|
||||
@@ -101,17 +110,23 @@
|
||||
<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)}
|
||||
{#each group.members as member, index (member.name)}
|
||||
{@const pending = member.state === 'creating' || member.state === 'removing'}
|
||||
{@const number = parseServerName(member.name)?.lobby}
|
||||
<tr>
|
||||
<td><a class="lobby-link" href={`/servers/${encodeURIComponent(member.name)}`}>{lobbyLabel(member.name)}</a><span class="technical">{member.name}</span>
|
||||
<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}<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>
|
||||
<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}
|
||||
@@ -179,19 +194,18 @@
|
||||
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; }
|
||||
td { padding: 10px 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; }
|
||||
.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; }
|
||||
.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); }
|
||||
.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); }
|
||||
@@ -218,11 +232,7 @@
|
||||
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; }
|
||||
.empty-map { grid-column: 1 / -1; }
|
||||
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); padding-inline: 16px; }
|
||||
footer { padding-inline: 16px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user