Compact autoscaling lobby rows and observation times

This commit is contained in:
2026-10-07 16:47:35 -04:00
parent 40df99c115
commit 45e9bcd4ce
4 changed files with 84 additions and 18 deletions
+12
View File
@@ -1,3 +1,4 @@
import { freshStatusTimestamp } from '../server-freshness.js';
import type { ScalingHistory, ScalingMember, ScalingView } from '../autoscaling.js'; import type { ScalingHistory, ScalingMember, ScalingView } from '../autoscaling.js';
import { parseServerName, zombieMapBySlug, zombieMaps } from '../map-aliases.js'; import { parseServerName, zombieMapBySlug, zombieMaps } from '../map-aliases.js';
@@ -79,3 +80,14 @@ export function scalingActivity(view: ScalingView): ScalingActivity[] {
} }
return [...groups.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt); return [...groups.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
} }
/** Advance observation ages even when polling fails, using the fleet freshness window. */
export function observationAge(at: number, now: number): { label: string; stale: boolean } {
const age = now - at;
if (!Number.isFinite(at) || !Number.isFinite(age) || age < -1000) return { label: 'Check observation time', stale: true };
const seconds = Math.max(0, Math.floor(age / 1000));
const elapsed = seconds < 60 ? `${seconds}s` : seconds < 3600 ? `${Math.floor(seconds / 60)}m`
: seconds < 86400 ? `${Math.floor(seconds / 3600)}h` : `${Math.floor(seconds / 86400)}d`;
const stale = !freshStatusTimestamp(new Date(at).toISOString(), now);
return { label: `${stale ? 'Stale · ' : ''}${elapsed} ago`, stale };
}
@@ -0,0 +1,25 @@
<script lang="ts">
import { observationAge } from '$ui/autoscaling.js';
import { timeFormat } from '$ui/stores/time-zone.js';
let { at, source, now }: { at: number; source: string | null; now: number } = $props();
const id = $props.id();
const age = $derived(observationAge(at, now));
let dismissed = $state(false);
</script>
<button type="button" class="observation-time" class:stale={age.stale} class:dismissed
aria-label={`${age.label}; observation details`} aria-describedby={id}
onfocus={() => dismissed = false} onpointerenter={() => dismissed = false}
onclick={() => dismissed = false} onkeydown={event => { if (event.key === 'Escape') dismissed = true; }}>
<time datetime={new Date(at).toISOString()}>{age.label}</time>
<span class="timestamp-detail" role="tooltip" {id}>{$timeFormat.dateTime(at)} · {source ?? 'Source unavailable'}</span>
</button>
<style>
.observation-time { position: relative; padding: 4px 0; border: 0; border-radius: var(--radius-sm); background: none; color: var(--text-secondary); font: inherit; cursor: help; text-align: left; }
.observation-time.stale { color: var(--state-warning); }
.observation-time:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.timestamp-detail { visibility: hidden; position: absolute; z-index: 5; right: 0; bottom: 100%; width: max-content; max-width: min(280px, 75vw); padding: 8px 10px; color: var(--text-primary); background: var(--surface-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); box-shadow: 0 4px 12px #0004; line-height: 1.5; }
.observation-time:hover:not(.dismissed) .timestamp-detail, .observation-time:focus:not(.dismissed) .timestamp-detail { visibility: visible; }
@media (max-width: 640px) { .timestamp-detail { left: 0; right: auto; } }
</style>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import type { ScalingPolicy, ScalingView, ScalingMember } from '$lib/autoscaling.js'; 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 { lobbyGroups, lobbyLabel, policyStatus, scalingActivity, scalingReason } from '$ui/autoscaling.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte'; import Badge from '../primitives/Badge.svelte';
@@ -8,6 +9,7 @@
import Stat from '../primitives/Stat.svelte'; import Stat from '../primitives/Stat.svelte';
import StatusBadge from '../primitives/StatusBadge.svelte'; import StatusBadge from '../primitives/StatusBadge.svelte';
import Tabs from '../primitives/Tabs.svelte'; import Tabs from '../primitives/Tabs.svelte';
import ObservationTime from './ObservationTime.svelte';
let { view, globallyPaused, canOperate, busy, onedit, ontoggle, ondelete }: { let { view, globallyPaused, canOperate, busy, onedit, ontoggle, ondelete }: {
view: ScalingView; globallyPaused: boolean; canOperate: boolean; busy: boolean; view: ScalingView; globallyPaused: boolean; canOperate: boolean; busy: boolean;
@@ -16,6 +18,12 @@
ondelete: (policy: ScalingPolicy) => void; ondelete: (policy: ScalingPolicy) => void;
} = $props(); } = $props();
let tab = $state('lobbies'); 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 menuOpen = $state(false);
let menuElement = $state<HTMLDivElement | null>(null); 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 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 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 covered = $derived(groups.filter(group => group.target > 0 && group.ready >= group.target).length);
const mapCount = $derived(groups.filter(group => group.target > 0).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 dates = (at: number) => $timeFormat.dateTime(new Date(at).toISOString());
const memberStatus = (state: ScalingMember['state']) => ({ const memberStatus = (state: ScalingMember['state']) => ({
occupied: 'Occupied', empty: 'Ready spare', creating: 'Starting', removing: 'Removing', stopped: 'Stopped', unknown: 'Unknown', protected: 'Protected' 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} {#if !view.unsettled.length || globallyPaused || view.policy.mode === 'off' || view.policy.pausedReason}
<div class="summary" class:attention={status.tone === 'warning'}> <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} {#if status.reason}<span>{status.reason}</span>{/if}
</div> </div>
{/if} {/if}
@@ -92,7 +101,7 @@
<div class="detail-nav"> <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" /> <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>
<div class="tab-panel" id={`panel-${view.policy.id}`} role="tabpanel" aria-labelledby={`tab-${view.policy.id}-${tab}`} tabindex="0"> <div class="tab-panel" id={`panel-${view.policy.id}`} role="tabpanel" aria-labelledby={`tab-${view.policy.id}-${tab}`} tabindex="0">
{#if tab === 'lobbies'} {#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> <thead><tr><th>Map / lobby</th><th>State</th><th>Players</th><th>Latest observation</th></tr></thead>
{#each groups as group (group.slug)} {#each groups as group (group.slug)}
<tbody> <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, index (member.name)}
{#each group.members as member (member.name)}
{@const pending = member.state === 'creating' || member.state === 'removing'} {@const pending = member.state === 'creating' || member.state === 'removing'}
{@const number = parseServerName(member.name)?.lobby}
<tr> <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} {#if member.blocker && !activeIds.has(member.operationId ?? '')}<span class="member-note">{scalingReason(member.blocker)}</span>{/if}
</td> </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><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="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> </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} {/each}
</tbody> </tbody>
{/each} {/each}
@@ -179,19 +194,18 @@
thead th { font-size: var(--text-xs); font-weight: 500; color: var(--text-tertiary); padding: 8px 16px 12px; } 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:first-child, td:first-child { padding-left: 24px; }
th:last-child, td:last-child { padding-right: 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; } 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; } .lobby-identity { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 12px; }
.coverage { display: inline-block; margin-left: 16px; color: var(--text-secondary); font-weight: 400; } .coverage { color: var(--text-tertiary); font-size: var(--text-xs); white-space: nowrap; }
.coverage.warning { color: var(--state-warning); } .coverage.warning { color: var(--state-warning); }
.lobby-link { font-weight: 500; text-decoration: none; } .lobby-link { font-weight: 500; text-decoration: none; }
.lobby-link:hover, .job-link:hover { text-decoration: underline; } .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); } .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; } .job-link { display: block; margin-top: 6px; font-size: var(--text-xs); text-decoration: none; }
.players { font-variant-numeric: tabular-nums; } .players { font-variant-numeric: tabular-nums; }
.observation { color: var(--text-secondary); font-size: var(--text-xs); } .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); } .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 { 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 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, th:first-child, td:first-child, th:last-child, td:last-child { padding: 0; border: 0; }
td:first-child, .observation { grid-column: 1 / -1; } td:first-child, .observation { grid-column: 1 / -1; }
.players::before { content: 'Players: '; color: var(--text-tertiary); font-size: var(--text-xs); } .players::before { content: 'Players: '; color: var(--text-tertiary); font-size: var(--text-xs); }
.map-row { display: block; padding: 0; border: 0; } .empty-map { grid-column: 1 / -1; }
.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; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); padding-inline: 16px; }
footer { padding-inline: 16px; } footer { padding-inline: 16px; }
} }
+20 -1
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { render } from 'svelte/server'; import { render } from 'svelte/server';
import PolicyCard from '../../src/lib/ui/components/autoscaling/PolicyCard.svelte'; import PolicyCard from '../../src/lib/ui/components/autoscaling/PolicyCard.svelte';
import { lobbyGroups, lobbyLabel, policyStatus, scalingActivity } from '../../src/lib/ui/autoscaling.js'; import { lobbyGroups, lobbyLabel, policyStatus, scalingActivity, observationAge } from '../../src/lib/ui/autoscaling.js';
import { zombieMaps } from '../../src/lib/map-aliases.js';
import type { ScalingHistory, ScalingView } from '../../src/lib/autoscaling.js'; import type { ScalingHistory, ScalingView } from '../../src/lib/autoscaling.js';
const now = Date.parse('2026-10-07T20:00:00Z'); const now = Date.parse('2026-10-07T20:00:00Z');
@@ -79,6 +80,24 @@ describe('autoscaling presentation', () => {
expect(activity[0]).toMatchObject({ operationId: 'job-1', action: 'create', at: now, lastSeenAt: now + 30, evaluations: 3, operationState: 'done' }); 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.'); expect(activity[1].reason).toBe('No available game keys.');
}); });
it('consolidates a fully covered game into one healthy summary', () => {
const data = view();
data.unsettled = [];
data.snapshot.members = zombieMaps('t5').map(map => ({ ...data.snapshot.members[0], name: `t5_zombie_${map.slug}_1`, mapSlug: map.slug }));
data.decision = { ...data.decision, reason: 'Empty spare target is satisfied.', deficit: 0, empty: 10, pending: 0 };
const html = body(data);
expect(html).toContain('All 10 maps meet their spare target.');
expect(html).not.toContain('10 / 10 maps meet spare target');
expect(html).not.toContain('Spare targets satisfied');
expect(html.match(/<tr[ >]/g)).toHaveLength(11); // One header plus ten lobby rows.
});
it('ages observations into an explicit stale state even without fresh poll data', () => {
expect(observationAge(now - 5000, now)).toEqual({ label: '5s ago', stale: false });
expect(observationAge(now - 90000, now).stale).toBe(false);
expect(observationAge(now - 90001, now)).toEqual({ label: 'Stale · 1m ago', stale: true });
expect(observationAge(now, now + 3600000)).toEqual({ label: 'Stale · 1h ago', stale: true });
expect(observationAge(now + 5000, now)).toEqual({ label: 'Check observation time', stale: true });
});
it('renders accessible tabs and hides mutation controls from viewers', () => { it('renders accessible tabs and hides mutation controls from viewers', () => {
const html = body(view(), false); const html = body(view(), false);
expect(html).toContain('role="tablist"'); expect(html).toContain('role="tablist"');