Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+385 -74
View File
@@ -36,62 +36,154 @@
const report = $derived(snapshot ?? data);
const format = $derived(createTimeFormatter(report.scope.timeZone));
const captured = $derived(report.summary.observed + report.summary.unknown);
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const view = $derived(new URLSearchParams(contextSearch).get('view') === 'instances' ? 'instances' : new URLSearchParams(contextSearch).get('view') === 'insights' ? 'insights' : 'maps');
const contextSearch = $derived(
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
);
const view = $derived(
new URLSearchParams(contextSearch).get('view') === 'instances'
? 'instances'
: new URLSearchParams(contextSearch).get('view') === 'insights'
? 'insights'
: 'maps'
);
function changeView(value: string) {
const url = new URL(page.url); url.search = contextSearch; url.searchParams.set('view', value);
const url = new URL(page.url);
url.search = contextSearch;
url.searchParams.set('view', value);
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
const gameOptions = $derived([{ value: '', label: 'All games' }, ...[...new Set([...report.scopeOptions.games, ...(report.scope.game ? [report.scope.game] : [])])].map(game => ({ value: game, label: gameName(game) }))]);
const gameOptions = $derived([
{ value: '', label: 'All games' },
...[
...new Set([
...report.scopeOptions.games,
...(report.scope.game ? [report.scope.game] : [])
])
].map((game) => ({ value: game, label: gameName(game) }))
]);
const mapOptions = $derived([
{ value: '', label: 'All maps' },
...report.scopeOptions.maps.filter(map => !report.scope.game || map.game === report.scope.game).map(map => ({ value: JSON.stringify([map.game, map.map]), label: `${map.map}${report.scope.game ? '' : ` · ${gameName(map.game)}`}` })),
...(report.scope.map && !report.scopeOptions.maps.some(map => map.game === report.scope.game && map.map === report.scope.map) ? [{ value: JSON.stringify([report.scope.game, report.scope.map]), label: report.scope.map }] : [])
...report.scopeOptions.maps
.filter((map) => !report.scope.game || map.game === report.scope.game)
.map((map) => ({
value: JSON.stringify([map.game, map.map]),
label: `${map.map}${report.scope.game ? '' : ` · ${gameName(map.game)}`}`
})),
...(report.scope.map &&
!report.scopeOptions.maps.some(
(map) => map.game === report.scope.game && map.map === report.scope.map
)
? [
{
value: JSON.stringify([report.scope.game, report.scope.map]),
label: report.scope.map
}
]
: [])
]);
const mapValue = $derived(report.scope.map ? JSON.stringify([report.scope.game, report.scope.map]) : '');
const scopeLabel = $derived(report.scope.map ? `${report.scope.map} · ${gameName(report.scope.game!)}` : report.scope.game ? gameName(report.scope.game) : 'All games and maps');
const mapValue = $derived(
report.scope.map ? JSON.stringify([report.scope.game, report.scope.map]) : ''
);
const scopeLabel = $derived(
report.scope.map
? `${report.scope.map} · ${gameName(report.scope.game!)}`
: report.scope.game
? gameName(report.scope.game)
: 'All games and maps'
);
const refresh = createMetricsRefresh({
getQuery: () => {
const params = new URLSearchParams({ days: String(data.days), timeZone: data.scope.timeZone });
const params = new URLSearchParams({
days: String(data.days),
timeZone: data.scope.timeZone
});
if (data.scope.game) params.set('game', data.scope.game);
if (data.scope.map) params.set('map', data.scope.map);
return params.toString();
},
onData: value => { snapshot = value; checkedAt = Date.now(); },
onError: value => { refreshError = value; },
onLoading: value => { refreshing = value; }
onData: (value) => {
snapshot = value;
checkedAt = Date.now();
},
onError: (value) => {
refreshError = value;
},
onLoading: (value) => {
refreshing = value;
}
});
beforeNavigate(() => {
refresh.cancel();
scopeNavigating = true;
});
afterNavigate(() => {
scopeNavigating = false;
attemptedPreference = null;
});
beforeNavigate(() => { refresh.cancel(); scopeNavigating = true; });
afterNavigate(() => { scopeNavigating = false; attemptedPreference = null; });
$effect(() => {
// Navigation owns the new report; an older background request cannot replace it.
void data;
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; checkedAt = Date.now(); });
untrack(() => {
refresh.cancel();
snapshot = null;
refreshError = null;
checkedAt = Date.now();
});
});
$effect(() => {
// Layout initializes the saved preference on mount; react when it arrives.
const preferredZone = $timeZone;
if (mounted && !scopeNavigating && !new URLSearchParams(contextSearch).has('timeZone') && preferredZone !== data.scope.timeZone && preferredZone !== attemptedPreference) {
untrack(() => { attemptedPreference = preferredZone; void changeScope({ timeZone: preferredZone }, true); });
if (
mounted &&
!scopeNavigating &&
!new URLSearchParams(contextSearch).has('timeZone') &&
preferredZone !== data.scope.timeZone &&
preferredZone !== attemptedPreference
) {
untrack(() => {
attemptedPreference = preferredZone;
void changeScope({ timeZone: preferredZone }, true);
});
}
});
onMount(() => {
mounted = true;
const stop = pollVisible(() => scopeNavigating ? undefined : refresh.refresh(), 60_000, false);
return () => { stop(); refresh.cancel(); };
const stop = pollVisible(
() => (scopeNavigating ? undefined : refresh.refresh()),
60_000,
false
);
return () => {
stop();
refresh.cancel();
};
});
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
function gameName(game: string) {
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
}
async function changeScope(changes: Record<string, string>, replace = false) {
const params = new URLSearchParams(contextSearch);
if (!params.has('timeZone')) params.set('timeZone', report.scope.timeZone);
for (const [key, value] of Object.entries(changes)) { if (value) params.set(key, value); else params.delete(key); }
params.delete('mapPage'); params.delete('serverPage');
for (const [key, value] of Object.entries(changes)) {
if (value) params.set(key, value);
else params.delete(key);
}
params.delete('mapPage');
params.delete('serverPage');
refresh.cancel();
scopeError = '';
scopeNavigating = true;
try { await goto(`/metrics?${params}`, { keepFocus: true, noScroll: true, replaceState: replace }); }
catch { scopeError = 'Could not change the report scope. The previous report is still shown.'; }
finally { scopeNavigating = false; }
try {
await goto(`/metrics?${params}`, {
keepFocus: true,
noScroll: true,
replaceState: replace
});
} catch {
scopeError = 'Could not change the report scope. The previous report is still shown.';
} finally {
scopeNavigating = false;
}
}
function changeMap(value: string) {
if (!value) return changeScope({ map: '' });
@@ -101,7 +193,8 @@
function rangeHref(days: number): string {
const params = new URLSearchParams(contextSearch);
params.set('days', String(days));
params.delete('mapPage'); params.delete('serverPage');
params.delete('mapPage');
params.delete('serverPage');
params.set('timeZone', report.scope.timeZone);
return `/metrics?${params}`;
}
@@ -114,18 +207,50 @@
<div class="toolbar">
<nav aria-label="Metrics time range">
{#each [1, 7, 30] as days}
<a href={rangeHref(days)} aria-current={report.days === days ? 'page' : undefined} data-sveltekit-keepfocus data-sveltekit-noscroll>{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>
<Field label="Game"><Select value={report.scope.game ?? ''} options={gameOptions} disabled={scopeNavigating} onchange={event => changeScope({ game: (event.currentTarget as HTMLSelectElement).value, map: '' })} /></Field>
<Field label="Map"><Select value={mapValue} options={mapOptions} disabled={scopeNavigating} onchange={event => changeMap((event.currentTarget as HTMLSelectElement).value)} /></Field>
<div class="refresh-controls">{#if scopeNavigating}<span role="status">Updating report…</span>{:else if !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every minute" />{/if}</div>
<Field label="Game"
><Select
value={report.scope.game ?? ''}
options={gameOptions}
disabled={scopeNavigating}
onchange={(event) =>
changeScope({
game: (event.currentTarget as HTMLSelectElement).value,
map: ''
})}
/></Field
>
<Field label="Map"
><Select
value={mapValue}
options={mapOptions}
disabled={scopeNavigating}
onchange={(event) => changeMap((event.currentTarget as HTMLSelectElement).value)}
/></Field
>
<div class="refresh-controls">
{#if scopeNavigating}<span role="status">Updating report…</span
>{:else if !refreshError}<Freshness
updatedAt={checkedAt}
intervalLabel="Updates every minute"
/>{/if}
</div>
</div>
{#if scopeError}<p class="notice" role="alert">{scopeError}</p>{/if}
{#if refreshError}
<div class="refresh-error" role="alert">
<div><strong>Could not refresh metrics.</strong> {refreshError}<small>Showing the report from {format.dateTime(report.now)}.</small></div>
<div>
<strong>Could not refresh metrics.</strong>
{refreshError}<small>Showing the report from {format.dateTime(report.now)}.</small>
</div>
<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button>
</div>
{/if}
@@ -133,68 +258,254 @@
{#if captured > 0}<MetricsSummary {report} />{/if}
{#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 scope'}
<EmptyState
title={report.latestSampleAt === null
? 'No retained observations yet'
: report.servers.length > 0
? 'Waiting for observed time'
: 'No captured time in this scope'}
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 ? 'Captured samples have no elapsed time in this window. Time builds between status checks.'
: 'There are no elapsed observations for the selected game, map, and time window. Try another scope or a wider range.'}>
? 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
? 'Captured samples have no elapsed time in this window. Time builds between status checks.'
: 'There are no elapsed observations for the selected game, map, and time window. Try another scope or a wider range.'}
>
{#snippet actions()}
{#if report.latestSampleAt !== null && report.days < 30}<Button href={rangeHref(30)} size="sm">View 30 days</Button>{/if}
{#if report.scope.game || report.scope.map}<Button size="sm" onclick={() => changeScope({ game: '', map: '' })}>View all games</Button>{/if}
{#if report.registeredServerCount === 0}<Button href="/" size="sm">View fleet</Button>{/if}
{#if report.latestSampleAt !== null && report.days < 30}<Button
href={rangeHref(30)}
size="sm">View 30 days</Button
>{/if}
{#if report.scope.game || report.scope.map}<Button
size="sm"
onclick={() => changeScope({ game: '', map: '' })}
>View all games</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>
<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>
<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}
{#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} timeZone={report.scope.timeZone} />
{/if}
{#if captured > 0}
<MetricsHeatmap heatmap={report.heatmap} disabled={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} />
<Tabs tabs={[{ id: 'maps', label: 'Maps' }, { id: 'instances', label: 'Instances' }, { id: 'insights', label: 'Hosting insights' }]} value={view} onchange={changeView} ariaLabel="Metrics breakdown" idPrefix="metrics-views" panelId="metrics-breakdown" />
<MetricsHeatmap
heatmap={report.heatmap}
disabled={scopeNavigating}
onTimeZoneChange={(value) => changeScope({ timeZone: value })}
/>
<Tabs
tabs={[
{ id: 'maps', label: 'Maps' },
{ id: 'instances', label: 'Instances' },
{ id: 'insights', label: 'Hosting insights' }
]}
value={view}
onchange={changeView}
ariaLabel="Metrics breakdown"
idPrefix="metrics-views"
panelId="metrics-breakdown"
/>
<div id="metrics-breakdown" role="tabpanel" aria-labelledby={`metrics-views-${view}`}>
{#if view === 'insights'}<MetricsExplore {report} />{:else}<MetricsTables maps={report.maps} servers={report.servers} {view} />{/if}
{#if view === 'insights'}<MetricsExplore {report} />{:else}<MetricsTables
maps={report.maps}
servers={report.servers}
{view}
/>{/if}
</div>
{/if}
<details class="methodology">
<summary>About this report</summary>
<div class="context">
{#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>Scope last collected <strong>{report.scopeLatestSampleAt === null ? 'No retained samples' : format.dateTime(report.scopeLatestSampleAt)}</strong></span>
<span>Fleet last collected <strong>{report.latestSampleAt === null ? 'No retained samples' : format.dateTime(report.latestSampleAt)}</strong></span>
<span>Report refreshed {format.time(report.now)} · refreshes every minute while visible</span>
{#if report.scopeFirstSampleAt !== null}<span>Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}</span>{/if}
</div>
<p>Previous window: {format.period(report.previous.since)} – {format.period(report.previous.until)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(report.summary.observed)} current. Percentage points show the difference between two percentages. Equal windows do not guarantee equal observation coverage.</p>
<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 {ONLINE_MAX_MS / 1000} 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>Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial. Percentage bars divide occupied, idle, and unknown time by captured time; the headline occupancy percentage uses known time only.</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>
<div class="context">
{#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
>Scope last collected <strong
>{report.scopeLatestSampleAt === null
? 'No retained samples'
: format.dateTime(report.scopeLatestSampleAt)}</strong
></span
>
<span
>Fleet last collected <strong
>{report.latestSampleAt === null
? 'No retained samples'
: format.dateTime(report.latestSampleAt)}</strong
></span
>
<span
>Report refreshed {format.time(report.now)} · refreshes every minute while visible</span
>
{#if report.scopeFirstSampleAt !== null}<span
>Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}</span
>{/if}
</div>
<p>
Previous window: {format.period(report.previous.since)} – {format.period(
report.previous.until
)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(
report.summary.observed
)} current. Percentage points show the difference between two percentages. Equal windows do
not guarantee equal observation coverage.
</p>
<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 {ONLINE_MAX_MS / 1000} 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>
Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll(
'_',
' '
)}. Daily buckets are 24-hour periods, not local calendar days. The first and latest
buckets can be partial. Percentage bars divide occupied, idle, and unknown time by
captured time; the headline occupancy percentage uses known time only.
</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; grid-template-columns: minmax(0, 1fr); gap: var(--space-5); min-width: 0; }
.toolbar { display: flex; align-items: end; gap: var(--space-3); flex-wrap: wrap; }
.toolbar :global(.field) { flex: 1 1 180px; max-width: 300px; }
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); }
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 { margin: 0; }
.refresh-controls { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: var(--text-xs); color: var(--text-secondary); }
.context { display: flex; flex-wrap: wrap; column-gap: var(--space-5); row-gap: var(--space-1); padding: 0 2px; }
.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) { .toolbar { align-items: stretch; } .toolbar :global(.field) { flex-basis: 100%; max-width: none; } .refresh-controls { margin-left: 0; } .refresh-error { flex-wrap: wrap; } }
.page {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-5);
min-width: 0;
}
.toolbar {
display: flex;
align-items: end;
gap: var(--space-3);
flex-wrap: wrap;
}
.toolbar :global(.field) {
flex: 1 1 180px;
max-width: 300px;
}
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);
}
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 {
margin: 0;
}
.refresh-controls {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.context {
display: flex;
flex-wrap: wrap;
column-gap: var(--space-5);
row-gap: var(--space-1);
padding: 0 2px;
}
.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) {
.toolbar {
align-items: stretch;
}
.toolbar :global(.field) {
flex-basis: 100%;
max-width: none;
}
.refresh-controls {
margin-left: 0;
}
.refresh-error {
flex-wrap: wrap;
}
}
</style>