Add Metrics tab with historical server utilization and map capacity
This commit is contained in:
@@ -51,7 +51,7 @@
|
||||
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
|
||||
const shortcuts: Record<string, string> = {
|
||||
f: '/', d: '/deployments', a: '/campaigns', b: '/betas',
|
||||
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings'
|
||||
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings', m: '/metrics'
|
||||
};
|
||||
let armed = false;
|
||||
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -100,7 +100,8 @@
|
||||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
|
||||
{ href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') || p.startsWith('/settings') },
|
||||
{ href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') },
|
||||
{ href: '/matches', label: 'Match History', match: (p: string) => p.startsWith('/matches') }
|
||||
{ href: '/matches', label: 'Match History', match: (p: string) => p.startsWith('/matches') },
|
||||
{ href: '/metrics', label: 'Metrics', match: (p: string) => p === '/metrics' }
|
||||
];
|
||||
const secondary = $derived(tabs[1].match($page.url.pathname)
|
||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
||||
@@ -145,7 +146,7 @@
|
||||
<Confirmation />
|
||||
<NotificationHistory bind:open={notificationsOpen} />
|
||||
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
|
||||
<dl class="shortcuts">{#each [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Server deployments'],['g a','Campaigns'],['g b','Betas'],['g h','System status'],['g k','Server keys'],['g l','Leaderboard'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
|
||||
<dl class="shortcuts">{#each [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Server deployments'],['g a','Campaigns'],['g b','Betas'],['g h','System status'],['g k','Server keys'],['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -268,7 +269,7 @@
|
||||
}
|
||||
|
||||
/* The full navigation needs more room than a phone or portrait tablet. */
|
||||
@media (max-width: 1080px) {
|
||||
@media (max-width: 1200px) {
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { readMetrics } from '$lib/server/fleet/metrics.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ url }) => {
|
||||
const requested = Number(url.searchParams.get('days') ?? 7);
|
||||
return readMetrics([1, 7, 30].includes(requested) ? requested : 7);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
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) => new Date(at).toISOString().slice(0, data.days === 1 ? 16 : 10).replace('T', ' ');
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => { if (!document.hidden) void invalidateAll(); }, 60_000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
</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." />
|
||||
<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>
|
||||
{/each}
|
||||
</nav>
|
||||
<span>Updated {new Date(data.now).toISOString().slice(11, 19)} UTC · refreshes every minute</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>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Utilization over time</h2>
|
||||
<p>Server-hours per {data.days === 1 ? 'hour' : 'day'} (UTC). 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)} UTC: ${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)} UTC</span></div>
|
||||
<details><summary>View time data</summary><div class="table-wrap"><table><thead><tr><th>Period (UTC)</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>
|
||||
</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; }
|
||||
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; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user