Improve metrics clarity, exploration, and refresh reliability
This commit is contained in:
+123
-84
@@ -1,109 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import { createMetricsRefresh } from '$ui/metrics-refresh.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Stat from '$ui/components/primitives/Stat.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import MetricsChart from '$ui/components/metrics/MetricsChart.svelte';
|
||||
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
|
||||
import type { MetricsData } from '$lib/metrics.js';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const percent = (value: number, total: number) => total > 0 ? `${(100 * value / total).toFixed(1)}%` : '—';
|
||||
const hours = (value: number) => `${value.toFixed(1)} h`;
|
||||
const peak = $derived(Math.max(1, ...data.points.map(p => p.active + p.idle + p.unknown)));
|
||||
const label = (at: number) => $timeFormat.period(at);
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => { if (!document.hidden) void invalidateAll(); }, 60_000);
|
||||
return () => clearInterval(timer);
|
||||
let snapshot = $state<MetricsData | null>(null);
|
||||
let refreshing = $state(false);
|
||||
let refreshError = $state<string | null>(null);
|
||||
const report = $derived(snapshot ?? data);
|
||||
const captured = $derived(report.summary.observed + report.summary.unknown);
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const refresh = createMetricsRefresh({
|
||||
getQuery: () => new URLSearchParams({ days: String(data.days) }).toString(),
|
||||
onData: value => { snapshot = value; },
|
||||
onError: value => { refreshError = value; },
|
||||
onLoading: value => { refreshing = value; }
|
||||
});
|
||||
$effect(() => {
|
||||
// Navigation owns the new report; an older background request cannot replace it.
|
||||
void data;
|
||||
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; });
|
||||
});
|
||||
onMount(() => {
|
||||
const stop = pollVisible(() => refresh.refresh(), 60_000, false);
|
||||
return () => { stop(); refresh.cancel(); };
|
||||
});
|
||||
function rangeHref(days: number): string {
|
||||
const params = new URLSearchParams(contextSearch);
|
||||
params.set('days', String(days));
|
||||
return `/metrics?${params}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Metrics · Electric Cherry</title></svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Metrics" subtitle="Plan hosting around server utilization, map play time, and capacity pressure." />
|
||||
<SectionHeader title="Metrics" subtitle="Understand server use, map play time, and measured capacity pressure.">
|
||||
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Refresh</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="toolbar">
|
||||
<nav aria-label="Metrics time range">
|
||||
{#each [1, 7, 30] as days}
|
||||
<a href={`/metrics?days=${days}`} aria-current={data.days === days ? 'page' : undefined}>{days === 1 ? '24 hours' : `${days} days`}</a>
|
||||
<a href={rangeHref(days)} aria-current={report.days === days ? 'page' : undefined} data-sveltekit-keepfocus data-sveltekit-noscroll>{days === 1 ? '24 hours' : `${days} days`}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<span>Updated {$timeFormat.time(data.now)} · refreshes every minute</span>
|
||||
<span class="range">{$timeFormat.period(report.since)} – {$timeFormat.period(report.now)}</span>
|
||||
</div>
|
||||
|
||||
{#if data.servers.length === 0}
|
||||
<section><h2>Collecting your first observations</h2><p>Metrics start with the next server status checks. Leave the manager running to build a history of busy hours and map demand.</p></section>
|
||||
{:else}
|
||||
<div class="stats">
|
||||
<section><span>Servers utilized</span><strong>{percent(data.summary.active, data.summary.observed)}</strong><small>{hours(data.summary.active)} with players</small></section>
|
||||
<section><span>Servers idle</span><strong>{percent(data.summary.idle, data.summary.observed)}</strong><small>{hours(data.summary.idle)} empty</small></section>
|
||||
<section><span>Player time</span><strong>{hours(data.summary.playerHours)}</strong><small>Players × observed time</small></section>
|
||||
<section><span>Unknown status</span><strong>{hours(data.summary.unknown)}</strong><small>Excluded from utilization percentages</small></section>
|
||||
{#if refreshError}
|
||||
<div class="refresh-error" role="alert">
|
||||
<div><strong>Could not refresh metrics.</strong> {refreshError}<small>Showing the report from {$timeFormat.dateTime(report.now)}.</small></div>
|
||||
<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Utilization over time</h2>
|
||||
<p>Server-hours per {data.days === 1 ? 'hour' : '24-hour period'}. Period starts are shown in {$timeZone.replaceAll('_', ' ')}. Taller bars mean more time under observation.</p>
|
||||
<div class="legend"><span><i class="active"></i>Utilized</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span></div>
|
||||
<div class="chart" role="img" aria-label="Stacked server-hours chart. Exact values are in the time data table below.">
|
||||
{#each data.points as point}
|
||||
<div class="bar" title={`${label(point.at)}: ${hours(point.active)} utilized, ${hours(point.idle)} idle, ${hours(point.unknown)} unknown`}>
|
||||
<div class="unknown" style:height={`${point.unknown / peak * 100}%`}></div>
|
||||
<div class="idle" style:height={`${point.idle / peak * 100}%`}></div>
|
||||
<div class="active" style:height={`${point.active / peak * 100}%`}></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="axis"><span>{label(data.points[0].at)}</span><span>0–{hours(peak)} per bar</span><span>{label(data.points[data.points.length - 1].at)}</span></div>
|
||||
<details><summary>View time data</summary><div class="table-wrap"><table><thead><tr><th>Period start ({$timeZone.replaceAll('_', ' ')})</th><th>Utilized</th><th>Idle</th><th>Unknown</th><th>Player time</th></tr></thead><tbody>{#each data.points as point}<tr><td>{label(point.at)}</td><td>{hours(point.active)}</td><td>{hours(point.idle)}</td><td>{hours(point.unknown)}</td><td>{hours(point.playerHours)}</td></tr>{/each}</tbody></table></div></details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Map play time & capacity pressure</h2>
|
||||
<p>Ranked by player-hours. “All lobbies full” measures time when every tracked lobby for a map has known capacity and is full. It indicates capacity pressure; failed joins and unmet demand are not tracked.</p>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Map</th><th>Player time</th><th>Utilized server time</th><th>All lobbies full</th><th>Capacity coverage</th></tr></thead>
|
||||
<tbody>{#each data.maps as map}<tr><td>{map.map}<small>{map.game.toUpperCase()}</small></td><td>{hours(map.playerHours)}</td><td>{hours(map.active)}</td><td>{map.capacityObserved > 0 ? `${hours(map.full)} (${percent(map.full, map.capacityObserved)})` : 'Unknown'}</td><td>{hours(map.capacityObserved)}</td></tr>{/each}</tbody>
|
||||
</table></div>
|
||||
<p class="note">Capacity coverage is map time with a known player limit and status for every tracked lobby. Servers that do not report a player limit cannot establish whether all lobbies are full.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Server utilization</h2>
|
||||
<div class="table-wrap"><table><thead><tr><th>Server</th><th>Utilized</th><th>Idle</th><th>Observed time</th><th>Unknown</th></tr></thead>
|
||||
<tbody>{#each data.servers as server}<tr><td>{server.server}</td><td>{percent(server.active, server.observed)}</td><td>{percent(server.idle, server.observed)}</td><td>{hours(server.observed)}</td><td>{hours(server.unknown)}</td></tr>{/each}</tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
{/if}
|
||||
<p class="note">Estimates use periodic server status observations. Each observation covers at most 45 seconds; longer collection gaps are excluded. History begins when collection is enabled and is retained for 31 days. Player time includes all players reported by the server.</p>
|
||||
{#if captured > 0}
|
||||
<div class="stats">
|
||||
<Card>
|
||||
<Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
|
||||
<p class="stat-context">{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} known server time</p>
|
||||
{#if report.summary.observed === 0}<p class="stat-note">Occupancy is unknown for all captured time.</p>{/if}
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
|
||||
<p class="stat-context">Players × known time · one player for one hour = 1</p>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="context" aria-label="About this report">
|
||||
{#if captured > 0}<span><strong>{metricDuration(report.summary.observed)}</strong> known server time</span><span><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span>{/if}
|
||||
<span>Last collected <strong>{report.latestSampleAt === null ? 'No retained samples' : $timeFormat.dateTime(report.latestSampleAt)}</strong></span>
|
||||
<span>Report refreshed {$timeFormat.time(report.now)} · refreshes every minute while visible</span>
|
||||
{#if report.firstRetainedSampleAt !== null}<span>Earliest retained sample {$timeFormat.dateTime(report.firstRetainedSampleAt)}</span>{/if}
|
||||
</div>
|
||||
{#if captured === 0}
|
||||
<Card>
|
||||
<EmptyState title={report.latestSampleAt === null ? 'No retained observations yet' : report.servers.length > 0 ? 'Waiting for observed time' : 'No captured time in this range'}
|
||||
description={report.latestSampleAt === null
|
||||
? report.registeredServerCount > 0 ? 'Leave the manager running to collect server status and build a history.' : 'Register a server to begin collecting utilization history.'
|
||||
: report.servers.length > 0 ? 'The first sample has arrived. Time builds between status checks; this report has no elapsed observations yet.'
|
||||
: 'There are no elapsed observations in the selected window. Collection gaps are excluded from these estimates.'}>
|
||||
{#snippet actions()}
|
||||
{#if report.latestSampleAt !== null && report.days < 30}<Button href={rangeHref(30)} size="sm">View 30 days</Button>{/if}
|
||||
{#if report.registeredServerCount === 0}<Button href="/" size="sm">View fleet</Button>{/if}
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
</Card>
|
||||
{:else}
|
||||
{#if report.registeredServerCount === 0}
|
||||
<p class="notice">No servers are currently registered. This report shows retained history; new samples resume when servers are registered.</p>
|
||||
{:else if report.latestSampleAt !== null && report.now - report.latestSampleAt > 5 * 60_000}
|
||||
<p class="notice">No new collection samples in the last five minutes. This report still includes the retained observations shown below.</p>
|
||||
{/if}
|
||||
{#if report.summary.observed === 0}<p class="notice">All captured server status is unknown. A dash means occupancy could not be calculated; it does not mean servers were empty.</p>{/if}
|
||||
<MetricsChart points={report.points} days={report.days} />
|
||||
<MetricsTables maps={report.maps} servers={report.servers} />
|
||||
{/if}
|
||||
<details class="methodology">
|
||||
<summary>How these estimates work</summary>
|
||||
<p>Time with players is occupied server time divided by known server time. Known time includes occupied and idle observations; unknown status and collection gaps are excluded from that percentage.</p>
|
||||
<p>Each periodic observation covers at most 45 seconds. Longer collection gaps are excluded. History is retained for 31 days. Player-hours include all players reported by the server and are estimates, not individual session records.</p>
|
||||
<p>Chart buckets are aligned to UTC, with labels shown in {$timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial.</p>
|
||||
<p>Historical server rows are grouped by name and may include multiple registrations using that name. Map groups preserve the recorded game and map label.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: grid; gap: var(--space-6); min-width: 0; }
|
||||
section { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
|
||||
h2 { font-size: var(--text-lg); margin: 0 0 12px; }
|
||||
p { color: var(--text-secondary); margin: 8px 0 16px; }
|
||||
.toolbar, .axis, .legend { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.toolbar { align-items: center; }
|
||||
.toolbar span, .axis, .note, small { color: var(--text-tertiary); font-size: var(--text-sm); }
|
||||
nav { display: flex; gap: 8px; }
|
||||
nav a { padding: 8px 12px; border-radius: var(--radius-md); text-decoration: none; }
|
||||
.page { display: grid; gap: var(--space-5); min-width: 0; }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||
nav { display: flex; gap: var(--space-1); padding: 4px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); }
|
||||
nav a { padding: 9px 12px; color: var(--text-secondary); border-radius: var(--radius-sm); text-decoration: none; white-space: nowrap; font-size: var(--text-sm); }
|
||||
nav a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
|
||||
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||||
.stats section { display: grid; gap: 8px; }
|
||||
strong { font-size: 30px; font-variant-numeric: tabular-nums; }
|
||||
small { display: block; }
|
||||
.legend { justify-content: flex-start; font-size: var(--text-sm); }
|
||||
.legend span { display: flex; align-items: center; gap: 6px; }
|
||||
i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; }
|
||||
.active { background: var(--accent-primary); }
|
||||
.idle { background: #7b9baf; }
|
||||
.unknown { background: repeating-linear-gradient(135deg, #605b64 0 3px, #a7a0ad 3px 5px); }
|
||||
.chart { display: flex; height: 200px; gap: 4px; border-bottom: 1px solid var(--border-strong); margin: 20px 0 8px; }
|
||||
.bar { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
details { margin-top: 20px; } summary { cursor: pointer; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
|
||||
th, td { padding: 12px; border-bottom: 1px solid var(--border-subtle); font-variant-numeric: tabular-nums; }
|
||||
th { color: var(--text-secondary); white-space: nowrap; }
|
||||
@media (max-width: 800px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 450px) { .stats { grid-template-columns: 1fr; } }
|
||||
nav a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.range, .context, .methodology, .notice { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; }
|
||||
.range { max-width: 50ch; }
|
||||
.stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
|
||||
.stat-context { margin: 10px 0 0; font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.5; }
|
||||
.stat-note { margin: 6px 0 0; color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.context { display: flex; flex-wrap: wrap; column-gap: var(--space-5); row-gap: var(--space-1); padding: 0 2px; }
|
||||
.context strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.refresh-error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); border: 1px solid var(--state-warning); padding: var(--space-4); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.refresh-error small { display: block; margin-top: var(--space-1); color: var(--text-secondary); }
|
||||
.notice { margin: 0; border-left: 2px solid var(--state-warning); padding: var(--space-2) var(--space-3); color: var(--text-secondary); }
|
||||
.methodology { padding: var(--space-3) 2px; }
|
||||
.methodology summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.methodology p { max-width: 110ch; margin: var(--space-3) 0 0; }
|
||||
@media (max-width: 540px) {
|
||||
.page { gap: var(--space-4); }
|
||||
.stats { gap: var(--space-2); }
|
||||
.stats :global(.card) { padding: var(--space-3); }
|
||||
.stats :global(.value) { font-size: var(--text-xl); }
|
||||
.stats :global(.label) { font-size: 11px; }
|
||||
.stat-context { font-size: var(--text-xs); }
|
||||
.context { font-size: 11px; column-gap: var(--space-3); }
|
||||
.refresh-error { align-items: flex-start; flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user