Make UI language, timestamps and live feedback consistent
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { activityTypes, activitySources, activityStates, type ActivityPage, type ActivityQuery, type ActivitySource } from '$lib/activity.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { activityApiHref, activityHistoryHref, activityQuery, activityTimeLabel } from '$ui/activity-history.js';
|
||||
import { activityApiHref, activityHistoryHref, activityQuery } from '$ui/activity-history.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { fleet } from '$ui/stores/fleet.js';
|
||||
@@ -117,7 +117,7 @@
|
||||
{#if row.hostName}<span class="host-name">{row.hostName}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="outcome"><StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time datetime={row.at} title={$timeFormat.dateTime(row.at)} aria-label={$timeFormat.dateTime(row.at)}>{activityTimeLabel(row.at, $timeZone, $uiNow)}</time>
|
||||
<div class="outcome"><StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time datetime={row.at} title={$timeFormat.dateTime(row.at)} aria-label={$timeFormat.dateTime(row.at)}>{$timeFormat.relative(row.at, $uiNow)}</time>
|
||||
<span class="sources">{(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')}</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { open = $bindable(false), onadded }: { open?: boolean; onadded: () => Promise<unknown> } = $props();
|
||||
@@ -30,7 +31,7 @@
|
||||
<p role="status">{name} created. Run this command with administrator access on that machine.</p>
|
||||
<textarea readonly aria-label="Host installation command" rows="6" value={enrollment.command}></textarea>
|
||||
<div class="actions"><Button onclick={copy}>{copied ? 'Copied' : 'Copy command'}</Button><Button variant="primary" href={`/hosts/${enrollment.hostId}?tab=setup`}>Open host setup</Button></div>
|
||||
<p class="muted">This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Generate a new installer from host settings if it expires.</p>
|
||||
<p class="muted">This single-use command expires {$timeFormat.dateTime(enrollment.expiresAt)}. Generate a new installer from host settings if it expires.</p>
|
||||
<Button variant="ghost" onclick={() => { enrollment = null; name = ''; copied = false; error = ''; }}>Add another host</Button>
|
||||
{:else}
|
||||
<p>Use a Debian 12 or 13 amd64 machine. The agent connects over HTTPS and prepares the runtime and game files automatically.</p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
@@ -353,7 +354,7 @@
|
||||
</div></details>
|
||||
</Card>
|
||||
|
||||
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{installed.server}</a> · v{installed.version} · {$timeFormat.dateTime(installed.recordedAt)}</li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
|
||||
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{serverTitle(installed.server)}</a> · v{installed.version} · <RelativeTime value={installed.recordedAt} /></li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
|
||||
|
||||
{#if a.kind === 'config' || a.kind === 'baseline'}
|
||||
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
|
||||
@@ -375,7 +376,7 @@
|
||||
<span class="v-notes">{v.notes ?? ''}
|
||||
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
|
||||
</span>
|
||||
<time class="v-when" datetime={v.uploaded_at}>{$timeFormat.dateTime(v.uploaded_at)}</time>
|
||||
<span class="v-when"><RelativeTime value={v.uploaded_at} /></span>
|
||||
{#if a.current_version_id === v.id}
|
||||
<Badge variant="online" size="sm">Promoted</Badge>
|
||||
{:else}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
@@ -43,7 +45,6 @@
|
||||
&& (!evidenceKind || (evidenceKind === 'gameplay' ? ['game_start','round_change','heartbeat','game_end'].includes(event.kind) : evidenceKind === 'health' ? ['health','status-sample'].includes(event.kind) : event.kind === evidenceKind))
|
||||
&& (!evidenceQuery || `${betaEvidenceSummary(event)} ${event.payload}`.toLowerCase().includes(evidenceQuery.toLowerCase()))));
|
||||
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; });
|
||||
function reportAge(at:string) { const minutes=Math.max(0,Math.floor((Date.now()-Date.parse(at))/60000));return minutes<1?'less than a minute ago':minutes<60?`${minutes}m ago`:`${Math.floor(minutes/60)}h ${minutes%60}m ago`; }
|
||||
export async function action(action:string, extra:Record<string,string>={}) {
|
||||
if (action === 'retry' && !await confirmAction({title:'Start a fresh test attempt?',message:`Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? $timeFormat.dateTime(deadline) : 'unchanged'}.`,confirmLabel:'Reset and restart testing'})) return;
|
||||
busy = true; error = ''; message = '';
|
||||
@@ -121,8 +122,8 @@
|
||||
<summary>Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})</summary>
|
||||
{#if evidenceOpen}
|
||||
<p>Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.</p>
|
||||
<div class="filters"><label>Server<select bind:value={evidenceServer}><option value="">All servers</option>{#each [...new Set(test.evidence.map(event=>event.server))] as server}<option value={server}>{server}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
|
||||
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><HistoricalServerIdentity name={event.server} deletedAt={event.server_deleted_at}/><time datetime={event.received_at}>{$timeFormat.dateTime(event.received_at)}</time></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
|
||||
<div class="filters"><label>Server<select bind:value={evidenceServer}><option value="">All servers</option>{#each [...new Set(test.evidence.map(event=>event.server))] as server}<option value={server}>{serverTitle(server)}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
|
||||
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><HistoricalServerIdentity name={event.server} deletedAt={event.server_deleted_at}/><span class="time"><RelativeTime value={event.received_at} /></span></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
|
||||
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events"/>
|
||||
{#if !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
|
||||
{/if}
|
||||
@@ -130,7 +131,7 @@
|
||||
</section>
|
||||
|
||||
{#snippet checks(server: BetaTestView['servers'][number])}
|
||||
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <time datetime={server.observation.lastGameplayAt} title={$timeFormat.dateTime(server.observation.lastGameplayAt)}>{reportAge(server.observation.lastGameplayAt)}</time> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} {#if !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a>{/if}</p>{/if}
|
||||
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <span class="time"><RelativeTime value={server.observation.lastGameplayAt} /></span> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} {#if !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a>{/if}</p>{/if}
|
||||
{#each server.progress as progress (progress.id)}
|
||||
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
|
||||
{#if requirement}<div class="requirement" class:failed={progress.failed}>
|
||||
@@ -158,7 +159,7 @@
|
||||
.deployment-details,.deployment-links { display:flex; flex-wrap:wrap; gap:8px 20px; margin:12px 0; font-size:var(--text-sm); }
|
||||
@media(max-width:720px) { table { min-width:620px; } }
|
||||
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size: var(--text-md); }
|
||||
p,span,time { font-size: var(--text-sm); line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
|
||||
p,span,.time { font-size: var(--text-sm); line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
|
||||
.heading,.actions,.filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; } .heading { justify-content:space-between; }
|
||||
.server { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; }
|
||||
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size: var(--text-base); } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { CapacitySamplePage } from '$lib/hosts.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
@@ -54,7 +55,7 @@
|
||||
<table><caption>CPU and memory peaks per container and minute; times are local.</caption>
|
||||
<thead><tr><th scope="col">Time</th>{#if scope === 'fleet'}<th scope="col">Host</th>{/if}<th scope="col">Server</th><th scope="col">Phase</th><th scope="col">CPU cores</th><th scope="col">Memory</th></tr></thead>
|
||||
<tbody>{#each result.samples as sample}
|
||||
<tr><td><time datetime={sample.timestamp}>{new Date(sample.timestamp).toLocaleString()}</time></td>
|
||||
<tr><td><RelativeTime value={sample.timestamp} /></td>
|
||||
{#if scope === 'fleet'}<td>{sample.hostName ?? sample.hostId}</td>{/if}
|
||||
<td>{sample.server ?? sample.instanceId}<small title={sample.instanceId}>Generation {sample.generation}</small></td>
|
||||
<td>{sample.phase === 'startup' ? 'Starting' : 'Running'}</td><td>{sample.cpuCores.toFixed(3)}</td><td>{(sample.memoryBytes / 1048576).toFixed(1)} MiB</td></tr>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
||||
import ConfigInspector from './ConfigInspector.svelte';
|
||||
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||
import { gameTitle } from '$ui/server-labels.js';
|
||||
|
||||
interface Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; }
|
||||
interface Artifact {
|
||||
@@ -116,7 +117,7 @@
|
||||
{:else}
|
||||
{#if loading || artifacts.length || error}<div class="library-toolbar">
|
||||
<input type="search" class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
|
||||
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
|
||||
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option>{/each}</select>
|
||||
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
|
||||
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
|
||||
{#if filtered.length && (query || game || scope || status)}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
|
||||
@@ -147,7 +148,7 @@
|
||||
<thead><tr><th>Config</th><th>Scope</th><th>Version / status</th><th>Eligible servers</th></tr></thead>
|
||||
<tbody>{#each rows as artifact (artifact.id)}<tr>
|
||||
<td><a class="name" href={contextHref(`/configs/${artifact.id}`, $page.url)}>{artifact.name}</a></td>
|
||||
<td>{artifact.scope_type === 'game' ? `All ${artifact.scope_value?.toUpperCase()} servers` : artifact.scope_type === 'map' ? mapLabel(artifact.scope_value ?? '') : 'Unscoped'}</td>
|
||||
<td>{artifact.scope_type === 'game' ? `All ${gameTitle(artifact.scope_value)} servers` : artifact.scope_type === 'map' ? mapLabel(artifact.scope_value ?? '') : 'Unscoped'}</td>
|
||||
<td><LibraryVersions current={artifact.currentVersion} latest={artifact.latestVersion} betaId={artifact.runningBetaId} /></td>
|
||||
<td class="numeric">{artifact.servers.length}</td>
|
||||
</tr>{/each}</tbody>
|
||||
@@ -158,7 +159,7 @@
|
||||
<form onsubmit={create}>
|
||||
<div class="dialog-heading"><div><span class="eyebrow">Create version 1</span><h2 id="new-config-title">New config</h2></div><Button variant="ghost" onclick={() => dialog.close()} disabled={saving} ariaLabel="Close new config">✕</Button></div>
|
||||
<div class="kind-picker"><button type="button" class:active={kind === 'config'} aria-pressed={kind === 'config'} onclick={() => kind = 'config'} disabled={saving}><strong>Overlay</strong><span>Override selected settings</span></button><button type="button" class:active={kind === 'baseline'} aria-pressed={kind === 'baseline'} onclick={() => kind = 'baseline'} disabled={saving}><strong>Baseline</strong><span>A complete config for a game</span></button></div>
|
||||
<div class="form-grid"><label>Name<input bind:value={name} placeholder={kind === 'config' ? 'No AFK timeout' : 'T5 baseline'} required disabled={saving} /></label><label>Game<select aria-label="Game" bind:value={scopeGame} onchange={() => scopeMap = ''} required disabled={saving}><option value="" disabled>Choose a game</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select></label>
|
||||
<div class="form-grid"><label>Name<input bind:value={name} placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'} required disabled={saving} /></label><label>Game<select aria-label="Game" bind:value={scopeGame} onchange={() => scopeMap = ''} required disabled={saving}><option value="" disabled>Choose a game</option>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option>{/each}</select></label>
|
||||
{#if kind === 'config'}<label>Apply to<select aria-label="Apply to" bind:value={scopeType} disabled={saving}><option value="game">Whole game</option><option value="map">One map</option></select></label>{#if scopeType === 'map'}<label>Map<select aria-label="Map" bind:value={scopeMap} required disabled={saving || !scopeGame}><option value="" disabled>Choose a map</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option value={option}>{option}</option>{/each}</select></label>{/if}{/if}
|
||||
</div>
|
||||
{#if baselineExists}<p class="warning">This game already has a baseline. Open it from the library to save a new version.</p>{/if}
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
{#snippet actions()}
|
||||
<div class="launch-actions">
|
||||
<p class:ready={canLaunch && !unconfirmedLaunch} role="status">{footerStatus}</p>
|
||||
<div><Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant="primary" loading={launching} disabled={!canLaunch} onclick={launch}>{unconfirmedLaunch ? 'Retry launch' : 'Launch server'}</Button></div>
|
||||
<div><Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant="primary" loading={launching} disabled={!canLaunch} onclick={launch}>{unconfirmedLaunch ? 'Retry creation' : 'Create server'}</Button></div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let { updatedAt = null, error = '', intervalLabel = '', onRetry }: { updatedAt?: number | null; error?: string; intervalLabel?: string; onRetry?: () => void } = $props();
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
let { updatedAt = null, error = '', intervalLabel = '', onRetry, paused = false, retryDisabled = false }:
|
||||
{ updatedAt?: number | null; error?: string; intervalLabel?: string; onRetry?: () => void; paused?: boolean; retryDisabled?: boolean } = $props();
|
||||
let now = $state(Date.now());
|
||||
onMount(() => { const timer = setInterval(() => now = Date.now(), 1000); return () => clearInterval(timer); });
|
||||
const age = $derived(updatedAt === null ? null : Math.max(0, Math.floor((now - updatedAt) / 1000)));
|
||||
const ago = $derived(age === null ? '' : age < 5 ? 'just now' : age < 60 ? `${age} s ago` : `${Math.floor(age / 60)} min ago`);
|
||||
// These are local receipt times, so their age uses the browser clock. The
|
||||
// shared formatter still supplies the same language and exact tooltip.
|
||||
const known = $derived(updatedAt !== null && Number.isFinite(updatedAt));
|
||||
</script>
|
||||
<div class="freshness" class:failed={!!error}>
|
||||
{#if error}<span>{error}</span>{#if onRetry}<button type="button" onclick={onRetry}>Retry</button>{/if}
|
||||
{:else}<span>{updatedAt === null ? 'Waiting for update' : `Live · updated ${ago}`}{intervalLabel ? ` · ${intervalLabel}` : ''}</span>{/if}
|
||||
{#if error}<span>{error}</span>{#if onRetry}<button type="button" disabled={retryDisabled} onclick={onRetry}>Retry</button>{/if}
|
||||
{:else}<span>{paused ? 'Paused' : known ? 'Live' : 'Waiting for update'}{#if known}{' · updated '}<time datetime={new Date(updatedAt!).toISOString()} title={$timeFormat.dateTime(updatedAt!)}>{$timeFormat.relative(updatedAt!, now)}</time>{/if}{!paused && intervalLabel ? ` · ${intervalLabel}` : ''}</span>{/if}
|
||||
</div>
|
||||
<style>
|
||||
.freshness { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{@const host = hosts.find(host => host.id === cache.hostId)}
|
||||
{@const waiting = latest && cache.status === 'missing'}
|
||||
<tr>
|
||||
<td><a href={`/hosts/${cache.hostId}`}>{host?.name ?? cache.hostId}</a>{#if !host?.connected}<span class="muted"> · Offline</span>{/if}</td>
|
||||
<td><a href={`/hosts/${cache.hostId}`}>{host?.name ?? cache.hostId}</a>{#if !host?.connected}<span class="muted">{' · Offline'}</span>{/if}</td>
|
||||
<td><Badge variant={cache.status === 'failed' ? 'danger' : cache.status === 'ready' ? 'online' : 'neutral'}>{waiting ? 'Waiting' : cache.status[0].toUpperCase() + cache.status.slice(1)}</Badge></td>
|
||||
<td>{#if cache.status === 'ready'}<details><summary>Transfer details</summary><GameFileTransferProgress {cache} connected={host?.connected ?? false} /></details>
|
||||
{:else if cache.status !== 'missing'}<GameFileTransferProgress {cache} connected={host?.connected ?? false} />{/if}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import type { Host } from '$lib/hosts.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { hostListStatus, hostReadingsStale, hostCpuUsage, hostUsedSpace } from '$ui/host-overview.js';
|
||||
import { plural } from '$ui/plural.js';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
let { hosts, now, returnTo = '/hosts' }: { hosts: (Host & { serverCount: number; playerCount: number; unreportedInstances: number })[]; now: number; returnTo?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Host</th><th>Status</th><th title="Assigned instances, including stopped instances">Instances</th><th title="Players reported by online instances">Players</th><th>CPU</th><th>Memory <span>used / total</span></th><th>Disk <span>used / total</span></th></tr></thead>
|
||||
<thead><tr><th>Host</th><th>Status</th><th title="Assigned servers, including stopped servers">Servers</th><th title="Players reported by online servers">Players</th><th>CPU</th><th>Memory <span>used / total</span></th><th>Disk <span>used / total</span></th></tr></thead>
|
||||
<tbody>{#each hosts as host (host.id)}
|
||||
{@const status = hostListStatus(host, now)}
|
||||
{@const stale = hostReadingsStale(host, now)}
|
||||
@@ -15,8 +16,8 @@
|
||||
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a>{#if host.hostname && host.hostname !== host.name}<span class="secondary">{host.hostname}</span>{/if}
|
||||
{#if stale && host.lastHeartbeat}<span class="secondary">Resource readings stale</span>{/if}</td>
|
||||
<td class="status"><Badge variant={status.tone}>{status.label}</Badge>{#if status.detail}<a class="reason" href={contextHref(`/hosts/${host.id}?tab=${status.tab}`, returnTo)}>{status.detail}</a>{/if}</td>
|
||||
<td><a href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)} aria-label={`${host.serverCount} assigned instances on ${host.name}`}>{host.serverCount}</a></td>
|
||||
<td title={host.unreportedInstances ? `Player counts unavailable for ${host.unreportedInstances} of ${host.serverCount} instances` : 'Players reported by online instances'}>{host.unreportedInstances === host.serverCount && host.serverCount > 0 ? '—' : `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td>
|
||||
<td><a href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)} aria-label={`${host.serverCount} assigned ${plural(host.serverCount, 'server')} on ${host.name}`}>{host.serverCount}</a></td>
|
||||
<td title={host.unreportedInstances ? `Player counts unavailable for ${host.unreportedInstances} of ${host.serverCount} ${plural(host.serverCount, 'server')}` : 'Players reported by online servers'}>{host.unreportedInstances === host.serverCount && host.serverCount > 0 ? '—' : `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td>
|
||||
<td class:stale>{hostCpuUsage(host.inventory.cpuUsagePercent)}</td>
|
||||
<td class:stale>{hostUsedSpace(host.inventory.memoryTotal, host.inventory.memoryAvailable)}</td>
|
||||
<td class:stale>{hostUsedSpace(host.inventory.storageTotal, host.inventory.storageAvailable)}</td>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</script>
|
||||
|
||||
<div class="preparation" aria-live="polite">
|
||||
<p>The default runtime and the latest available T5 and T6 game files are prepared automatically. Re-enabling a host requires a fresh runtime pull and verification of both BO1 (T5) and BO2 (T6) libraries before it accepts deployments. Running servers keep their current files.</p>
|
||||
<p>The default runtime and the latest available Black Ops I and Black Ops II game files are prepared automatically. Re-enabling a host requires a fresh runtime pull and verification of both game libraries before it accepts deployments. Running servers keep their current files.</p>
|
||||
<p>Supported hosts also cache Plutonium files to speed up new deployments. This optional cache refreshes when a new production release is detected; deployments can proceed while it is unavailable.</p>
|
||||
{#if host.preparation}
|
||||
<p class="status" class:blocked={host.preparation.state === 'blocked'}>Automatic setup: {host.preparation.state === 'ready' ? 'Ready' : host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : 'Waiting'}</p>
|
||||
@@ -25,7 +25,7 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p>Setup progress will appear after the host connects.</p>{/if}
|
||||
<p><a href="/game-files">Upload and view T5 / T6 game files →</a></p>
|
||||
<p><a href="/game-files">Upload and view game files →</a></p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
|
||||
let { recipe, historical = false, selectionStatus = null }: { recipe: LaunchRecipeSummary; historical?: boolean; selectionStatus?: 'queued' | 'attempted' | null } = $props();
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@
|
||||
<details>
|
||||
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary>
|
||||
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
|
||||
<p>{historical ? 'Recorded' : 'Previewed'} {$timeFormat.dateTime(recipe.capturedAt)}. {historical ? selectionStatus === 'queued' ? 'Versions are selected again when creation begins. This queued capture does not pin them.' : selectionStatus === 'attempted' ? 'These files were selected for this attempt; this does not confirm installation succeeded. Launch again uses the latest promoted setup.' : 'This records the saved setup for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}</p>
|
||||
<p>{historical ? 'Recorded' : 'Previewed'} <RelativeTime value={recipe.capturedAt} />. {historical ? selectionStatus === 'queued' ? 'Versions are selected again when creation begins. This queued capture does not pin them.' : selectionStatus === 'attempted' ? 'These files were selected for this attempt; this does not confirm installation succeeded. Launch again uses the latest promoted setup.' : 'This records the saved setup for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}</p>
|
||||
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { createLogRefresh, type LogResponse } from '$ui/log-refresh.js';
|
||||
import { stripGameColorCodes } from '$ui/log-text.js';
|
||||
import Tabs from './primitives/Tabs.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Freshness from './Freshness.svelte';
|
||||
import RelativeTime from './RelativeTime.svelte';
|
||||
const uid = $props.id();
|
||||
|
||||
interface TabDef {
|
||||
@@ -22,6 +25,7 @@
|
||||
|
||||
const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? '');
|
||||
let data = $state<Record<string, LogResponse | null>>({});
|
||||
let checkedAt = $state<Record<string, number>>({});
|
||||
|
||||
function chooseLog(key: string) {
|
||||
const url = new URL($page.url); url.searchParams.set('log', key);
|
||||
@@ -37,11 +41,12 @@
|
||||
let stickToBottom = $state(true);
|
||||
|
||||
const refresh = createLogRefresh({
|
||||
onReset: () => { data = {}; refreshError = ''; copyStatus = ''; },
|
||||
onReset: () => { data = {}; checkedAt = {}; refreshError = ''; copyStatus = ''; },
|
||||
onLoading: value => { loading = value; },
|
||||
onError: message => { refreshError = message; },
|
||||
onData: (key, body) => {
|
||||
data = { ...data, [key]: body };
|
||||
checkedAt = { ...checkedAt, [key]: Date.now() };
|
||||
const currentServer = server;
|
||||
void tick().then(() => {
|
||||
if (server === currentServer && active === key && stickToBottom && !query && bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
@@ -97,15 +102,6 @@
|
||||
untrack(() => refresh.configure(target, auto, interval));
|
||||
});
|
||||
|
||||
function ago(iso: string | null | undefined): string {
|
||||
if (!iso) return 'never';
|
||||
const d = Date.now() - new Date(iso).getTime();
|
||||
if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
|
||||
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
|
||||
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
|
||||
return `${Math.floor(d / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
if (n === undefined || n === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
@@ -115,7 +111,7 @@
|
||||
}
|
||||
|
||||
let current = $derived(data[active] ?? null);
|
||||
const visibleLines = $derived((current?.lines ?? []).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
|
||||
const visibleLines = $derived((current?.lines ?? []).map(stripGameColorCodes).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
|
||||
</script>
|
||||
|
||||
<div class="log-panel">
|
||||
@@ -123,25 +119,26 @@
|
||||
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} value={active} onchange={chooseLog} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
|
||||
<div class="right">
|
||||
{#if current?.mtime}
|
||||
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
|
||||
<span class="meta">{fmtBytes(current.size)} · File changed <RelativeTime value={current.mtime} /></span>
|
||||
{/if}
|
||||
<label class="auto">
|
||||
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
|
||||
Auto-refresh · {pollMs / 1000}s
|
||||
Auto-refresh
|
||||
</label>
|
||||
<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
|
||||
{#if !autoRefresh}<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><details class="log-actions"><summary>More</summary><div class="action-items"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download tail</Button></div></details></div>
|
||||
<Freshness updatedAt={checkedAt[active] ?? null} paused={!autoRefresh} intervalLabel={`Updates every ${pollMs / 1000} seconds`} error={refreshError ? 'Log updates interrupted.' : ''} onRetry={() => void refresh.refresh()} retryDisabled={loading} />
|
||||
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><details class="log-actions"><summary>More</summary><div class="action-items"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download raw tail</Button></div></details></div>
|
||||
{#if copyStatus}<p class="copy-status" role="status">{copyStatus}</p>{/if}
|
||||
<div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div>
|
||||
|
||||
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
|
||||
{#if refreshError}<div class="ui-error" role="alert">Couldn’t load logs. {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => refresh.refresh()} disabled={loading}>Retry</Button><details><summary>Technical details</summary>{refreshError}</details></div>{/if}
|
||||
{#if refreshError}<div class="ui-error" role="alert">Couldn’t load logs. {current ? 'Showing the last loaded log.' : ''}<details><summary>Technical details</summary>{refreshError}</details></div>{/if}
|
||||
{#if !current && !refreshError}
|
||||
<div class="empty">{loading ? 'loading…' : 'No log loaded. Select Refresh to load it.'}</div>
|
||||
<div class="empty">{loading ? 'Loading log…' : autoRefresh ? 'Waiting for the first log update.' : 'No log loaded. Select Refresh to load it.'}</div>
|
||||
{:else if current && !current.ok}
|
||||
<div class="error">error: {current.reason ?? 'unknown'}</div>
|
||||
{:else if current?.missing}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
@@ -6,7 +7,7 @@
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import type { MatchHistoryResponse } from '$lib/match-history.js';
|
||||
import { matchApiParams, matchHistoryHref, matchPageSize, readMatchQuery, type MatchHistoryQuery, type MatchSort } from '$lib/match-history-query.js';
|
||||
import { matchInstanceLabel, matchStartedLabel } from '$ui/match-history.js';
|
||||
import { matchInstanceLabel } from '$ui/match-history.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { formatDuration } from '$ui/duration.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
@@ -121,11 +122,11 @@
|
||||
</tr></thead>
|
||||
<tbody>{#each data.matches as match (match.id)}
|
||||
<tr>
|
||||
<td><time datetime={match.startedAt} title={$timeFormat.dateTime(match.startedAt)}>{matchStartedLabel(match.startedAt, $timeZone)}</time></td>
|
||||
<td><RelativeTime value={match.startedAt} /></td>
|
||||
<td class="match">
|
||||
{#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map} · Match #{match.id}</a>
|
||||
{:else}<span class="match-name">{match.map}</span>{/if}
|
||||
<small class="mobile-match-context"><time datetime={match.startedAt}>{matchStartedLabel(match.startedAt, $timeZone)}</time> · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''}</small>
|
||||
<small class="mobile-match-context"><RelativeTime value={match.startedAt} /> · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''}</small>
|
||||
{#if server === undefined}<div class="instance">
|
||||
<span title={match.server}>{matchInstanceLabel(match)}</span>{#if match.server_deleted_at}<span> · Historical instance</span>{/if}
|
||||
</div>{/if}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { gameTitle } from '$ui/server-labels.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
|
||||
import type { GameLibrary } from '$lib/game-files.js';
|
||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { latestGameFiles, selectLatestGameFiles, type NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
let { games, selection = $bindable(), ready = $bindable(false), status = $bindable(), disabled = false, network = false, compact = false, refreshVersion = 0, onchange }:
|
||||
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; status?: NativeHostSelectionStatus; disabled?: boolean; network?: boolean; compact?: boolean; refreshVersion?: number; onchange?: () => void } = $props();
|
||||
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
|
||||
@@ -103,19 +104,19 @@
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host readiness and game files…</p>{/if}
|
||||
<p>Hosts are assigned when provisioning starts. You can queue launches while hosts are busy or preparing; each server waits for an eligible host and available capacity.</p>
|
||||
{#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and latest T5 and T6 files will be prepared automatically.</p>{/if}
|
||||
{#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and latest Black Ops I and Black Ops II files will be prepared automatically.</p>{/if}
|
||||
{#if !loading}{#each games as game}
|
||||
<div class="game-files">
|
||||
<strong>{game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)'} files</strong>
|
||||
<strong>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} files</strong>
|
||||
{#if latest[game]}
|
||||
<p>Latest upload · {$timeFormat.dateTime(latest[game]?.publishedAt ?? latest[game]?.createdAt)}</p>
|
||||
<p>Latest upload · <RelativeTime value={latest[game]?.publishedAt ?? latest[game]?.createdAt} /></p>
|
||||
<p role="status">{cacheReady(game) ? 'Latest files ready on an eligible host.' : 'Launches can be queued now and will wait for a host to prepare the runtime and latest files.'}</p>
|
||||
{:else}
|
||||
<p>No files uploaded yet. <a href="/game-files" target="_blank" rel="noopener">Upload {game.toUpperCase()} files</a> to prepare your hosts.</p>
|
||||
<p>No files uploaded yet. <a href="/game-files" target="_blank" rel="noopener">Upload {gameTitle(game, true)} files</a> to prepare your hosts.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}{/if}
|
||||
<p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
|
||||
<p>Hosts keep the latest Black Ops I and Black Ops II files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
|
||||
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.</p>{@render runtimeFields()}</details>
|
||||
{#if network}<details><summary>Network overrides</summary>{@render networkFields()}</details>{/if}
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
@@ -26,14 +26,14 @@
|
||||
</section>
|
||||
{/if}
|
||||
<section>
|
||||
{#if showOverview && latest}<p class="latest"><strong>Latest update</strong> {latest.message}<time datetime={latest.created_at}>{$timeFormat.dateTime(latest.created_at)}</time></p>{/if}
|
||||
{#if showOverview && latest}<p class="latest"><strong>Latest update</strong> {latest.message}<span class="time"><RelativeTime value={latest.created_at} /></span></p>{/if}
|
||||
<details>
|
||||
<summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary>
|
||||
{#if events.length}
|
||||
<div class="tools"><label>Search activity<input type="search" bind:value={query} oninput={() => limit = 25} placeholder="Server, stage, or message" /></label><label>Order<select bind:value={newestFirst}><option value={true}>Newest first</option><option value={false}>Oldest first</option></select></label></div>
|
||||
<ol class="timeline">
|
||||
{#each visible as event (event.id)}
|
||||
<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>
|
||||
<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if !matching.length}<p class="muted">No matching activity.</p>{/if}
|
||||
@@ -53,12 +53,12 @@
|
||||
details[open] summary { margin-bottom: 16px; }
|
||||
.latest { padding: 12px 16px; background: var(--surface); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.latest strong { display: block; margin-bottom: 4px; }
|
||||
.latest time { display: block; margin-top: 4px; }
|
||||
.latest .time { display: block; margin-top: 4px; }
|
||||
.tools { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; }
|
||||
label { display: grid; gap: 6px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
label:first-child { flex: 1; min-width: 180px; }
|
||||
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); padding: 9px 12px; border-radius: var(--radius-sm); font: inherit; }
|
||||
time, .muted { color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.time, .muted { color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
p { margin: 0; line-height: 1.6; }
|
||||
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
|
||||
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: var(--text-sm); border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { PlayerMatch } from '$lib/leaderboard.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { playerCompletionLabel } from '$ui/player-match.js';
|
||||
let { matches, returnTo }: { matches: PlayerMatch[]; returnTo?: string } = $props();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
</script>
|
||||
@@ -11,7 +12,7 @@
|
||||
<article>
|
||||
<h3>{#if match.status === 'completed'}<a href={contextHref(`/matches/${match.id}`, returnTo ?? "/leaderboard")}>{match.mapName} · Match #{match.id}</a>{:else}{match.mapName} · Match #{match.id}{/if}</h3>
|
||||
<p>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</p>
|
||||
<p><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time> · {match.completed ? 'Completion credited' : 'No completion credit'}</p>
|
||||
<p><RelativeTime value={match.startedAt} /> · {playerCompletionLabel(match)}</p>
|
||||
<dl><div><dt>Round</dt><dd>{number.format(match.highestRound)}</dd></div><div><dt>Kills</dt><dd>{number.format(match.kills)}</dd></div><div><dt>Revives</dt><dd>{number.format(match.revives)}</dd></div></dl>
|
||||
</article>
|
||||
{/each}
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
</select></label>
|
||||
{#if !pluginId}
|
||||
<label>Plugin name<input bind:value={name} disabled={busy} placeholder="t6-gsc-utils" /></label>
|
||||
<label>Deploy scope<select bind:value={game} disabled={busy}><option value="">Unscoped — select servers in a beta</option><option value="t5">T5 · Black Ops I</option><option value="t6">T6 · Black Ops II</option></select></label>
|
||||
<label>Deploy scope<select bind:value={game} disabled={busy}><option value="">Unscoped — select servers in a beta</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if targetPath}<p>New versions deploy to <code>{targetPath}</code>.</p>{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { ProvisioningDetail, ProvisioningEvent } from '$lib/provisioning-detail.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { isProvisioningEventPage } from '$ui/provisioning-presentation.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="heading"><div><span class="eyebrow">Activity</span><h2 id="activity-title">Recent updates</h2></div><span class="count">{detail.activity.total} event{detail.activity.total === 1 ? '' : 's'}</span></div>
|
||||
<p class="muted">{open ? 'Recent updates stay in place while you read the full history.' : 'Newest first · updates automatically'}</p>
|
||||
{#if recent.length}<ol class="timeline recent">
|
||||
{#each recent as event (event.id)}<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
|
||||
{#each recent as event (event.id)}<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>{/each}
|
||||
</ol>{:else}<p class="empty">No detailed activity was recorded. The recorded outcome and timestamps are preserved above.</p>{/if}
|
||||
|
||||
<details class="history" ontoggle={toggle}>
|
||||
@@ -98,7 +98,7 @@
|
||||
{#if loading && !loaded}<p class="muted" role="status">Loading activity…</p>
|
||||
{:else if loaded && !entries.length}<p class="empty">{appliedQuery ? 'No activity matches this search.' : 'No detailed activity recorded.'}</p>
|
||||
{:else}<ol class="timeline">
|
||||
{#each entries as event (event.id)}<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
|
||||
{#each entries as event (event.id)}<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>{/each}
|
||||
</ol>{/if}
|
||||
{#if loaded}<div class="footer"><span>Showing {entries.length} of {total} matching events</span>{#if cursor !== null}<Button size="sm" loading={loading} disabled={loading} onclick={() => load(true)}>Show more activity</Button>{/if}</div>{/if}
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@
|
||||
.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: var(--space-2); }
|
||||
h2 { margin: 0; font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
|
||||
p { margin: 0; line-height: 1.65; overflow-wrap: anywhere; font-size: var(--text-sm); }
|
||||
.muted, .count, time, .footer { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.muted, .count, .time, .footer { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.empty { padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-secondary); }
|
||||
.timeline { list-style: none; margin: 0 0 0 var(--space-1); padding: 0 0 0 var(--space-5); border-left: 1px solid var(--border-strong); }
|
||||
.timeline li { padding: 0 0 var(--space-5); position: relative; }.timeline li:last-child { padding-bottom: 0; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
|
||||
import { provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
|
||||
let { detail }: { detail: ProvisioningDetail } = $props();
|
||||
@@ -37,7 +37,7 @@
|
||||
{#each group.steps as step (step.key)}
|
||||
<li aria-current={step.state === 'current' || step.state === 'waiting' ? 'step' : undefined}>
|
||||
<span class="step-icon state-{step.state}" aria-hidden="true">{progressIcons[step.state]}</span>
|
||||
<div><strong>{step.label}</strong><span class="step-meta state-{step.state}">{progressStateLabels[step.state]}{#if step.at} · <time datetime={step.at}>{$timeFormat.dateTime(step.at)}</time>{/if}</span></div>
|
||||
<div><strong>{step.label}</strong><span class="step-meta state-{step.state}">{progressStateLabels[step.state]}{#if step.at} · <span class="time"><RelativeTime value={step.at} /></span>{/if}</span></div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -49,17 +49,17 @@
|
||||
<div class="command" class:uncertain={progress.command.state === 'uncertain'}>
|
||||
<h3>{progress.command.state === 'attempted' ? 'Command submission attempted' : progress.command.state === 'accepted' ? 'Remote request accepted' : progress.command.state === 'rejected' ? 'Remote request rejected' : 'Remote response uncertain'}</h3>
|
||||
<p>{progress.command.message}</p>
|
||||
<time datetime={progress.command.at}>{$timeFormat.dateTime(progress.command.at)}</time>
|
||||
<span class="time"><RelativeTime value={progress.command.at} /></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if progress.checkpoints.some(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key)))}
|
||||
<details class="milestones"><summary>Recorded checkpoints</summary><ul>
|
||||
{#each progress.checkpoints.filter(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key))) as checkpoint (checkpoint.key)}
|
||||
<li><strong>{checkpoint.label}</strong><time datetime={checkpoint.at}>{$timeFormat.dateTime(checkpoint.at)}</time></li>
|
||||
<li><strong>{checkpoint.label}</strong><span class="time"><RelativeTime value={checkpoint.at} /></span></li>
|
||||
{/each}
|
||||
</ul></details>
|
||||
{/if}
|
||||
<div class="evidence-heading"><h3>Latest recorded checks</h3>{#if progress.observedAt}<span>Evaluated <time datetime={progress.observedAt}>{$timeFormat.dateTime(progress.observedAt)}</time></span>{/if}</div>
|
||||
<div class="evidence-heading"><h3>Latest recorded checks</h3>{#if progress.observedAt}<span>Evaluated <span class="time"><RelativeTime value={progress.observedAt} /></span></span>{/if}</div>
|
||||
{#if progress.checks.length}
|
||||
<div class="checks">
|
||||
{#each progress.checks as check (check.key)}
|
||||
@@ -70,7 +70,7 @@
|
||||
<div><dt>Observed</dt><dd>{check.actual ?? 'Unknown'}</dd></div>
|
||||
</dl>{/if}
|
||||
{#if check.message}<p>{check.message}</p>{/if}
|
||||
<div class="evidence-meta"><span>{check.source ? sources[check.source] : 'Source not recorded'}</span><span>Checked <time datetime={check.checkedAt}>{$timeFormat.dateTime(check.checkedAt)}</time></span>{#if check.receivedAt}<span>Evidence received <time datetime={check.receivedAt}>{$timeFormat.dateTime(check.receivedAt)}</time></span>{/if}</div>
|
||||
<div class="evidence-meta"><span>{check.source ? sources[check.source] : 'Source not recorded'}</span><span>Checked <span class="time"><RelativeTime value={check.checkedAt} /></span></span>{#if check.receivedAt}<span>Evidence received <span class="time"><RelativeTime value={check.receivedAt} /></span></span>{/if}</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -105,11 +105,11 @@
|
||||
.steps strong { display: block; font-size: var(--text-sm); font-weight: var(--weight-medium); line-height: 1.5; }
|
||||
.step-meta { display: block; font-size: var(--text-xs); margin-top: var(--space-1); line-height: 1.6; }
|
||||
.state-current { color: var(--accent-primary-hover); }.state-waiting { color: var(--state-warning); }.state-failed { color: var(--state-danger); }.state-done { color: var(--state-online); }
|
||||
.state-unknown, .state-pending, .state-cancelled, time { color: var(--text-secondary); }
|
||||
.state-unknown, .state-pending, .state-cancelled, .time { color: var(--text-secondary); }
|
||||
.command, .legacy { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.command.uncertain { border-color: color-mix(in srgb, var(--state-warning) 45%, var(--border-subtle)); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.65; overflow-wrap: anywhere; }
|
||||
time, .evidence-meta, .evidence-heading span, .milestones { font-size: var(--text-xs); }
|
||||
.time, .evidence-meta, .evidence-heading span, .milestones { font-size: var(--text-xs); }
|
||||
.evidence-heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-2); padding-top: var(--space-2); }
|
||||
.evidence-heading span { color: var(--text-secondary); }
|
||||
.checks { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { timestampISO, type Timestamp } from '$lib/time-zone.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
|
||||
let { value, missing = '—', class: className }: { value: Timestamp; missing?: string; class?: string } = $props();
|
||||
const iso = $derived(timestampISO(value));
|
||||
const now = $derived(browser ? $uiNow : Date.now());
|
||||
</script>
|
||||
|
||||
{#if iso}<time class={className} datetime={iso} title={$timeFormat.dateTime(value)}>{$timeFormat.relative(value, now)}</time>{:else}<span class={className}>{missing}</span>{/if}
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { tick } from 'svelte';
|
||||
import { plural } from '$ui/plural.js';
|
||||
import { gameTitle } from '$ui/server-labels.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { betaSelectionHref, mapLabel, scopeLabel, type BetaSelection, type LibraryScript } from '$lib/script-library.js';
|
||||
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } = $props();
|
||||
@@ -176,7 +178,7 @@
|
||||
{#if hasNew}
|
||||
<label>Scope for new scripts<select bind:value={scopeType} disabled={busy} onchange={() => { scopeGame = ''; scopeMap = ''; }}><option value="none">Unscoped · choose beta targets</option><option value="game">Game</option><option value="map">Map</option></select></label>
|
||||
{#if scopeType !== 'none'}
|
||||
<label>Game<select bind:value={scopeGame} disabled={busy || !scopeLoaded} onchange={() => scopeMap = ''}><option value="">Choose a game…</option>{#each scopeOptions.games as g}<option value={g}>{g.toUpperCase()}</option>{/each}</select></label>
|
||||
<label>Game<select bind:value={scopeGame} disabled={busy || !scopeLoaded} onchange={() => scopeMap = ''}><option value="">Choose a game…</option>{#each scopeOptions.games as g}<option value={g}>{gameTitle(g)}</option>{/each}</select></label>
|
||||
{#if scopeType === 'map'}<label>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}><option value="">Choose a map…</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>{/if}
|
||||
{#if scopeValue}<p class="note">{matches.length} matching servers: {matches.map(s => s.name).join(', ') || 'None'}</p>{/if}
|
||||
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">Map scope includes every server using this engine map, including its Survival, Grief, or other variants.</p>{/if}
|
||||
@@ -187,7 +189,7 @@
|
||||
{/if}
|
||||
{#if message}<p class="notice" role="status">{message}</p>{/if}
|
||||
<footer>
|
||||
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} files` : `Uploaded ${results.length} of ${queue.length} files`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{queue.some(q => q.error) ? ` · ${queue.filter(q => q.error).length} need attention` : ''}</p>{/if}
|
||||
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} ${plural(queue.length, 'file')}` : `Uploaded ${results.length} of ${queue.length} ${plural(queue.length, 'file')}`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}{@const failed = queue.filter(q => q.error).length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{failed ? ` · ${failed} ${plural(failed, 'needs', 'need')} attention` : ''}</p>{/if}
|
||||
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
|
||||
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
|
||||
</footer>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { TELEMETRY_REQUEST_RETENTION, type TelemetryRequestLogEntry } from '$lib/telemetry-diagnostics.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import Card from './primitives/Card.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Freshness from './Freshness.svelte';
|
||||
import RelativeTime from './RelativeTime.svelte';
|
||||
import EmptyState from './primitives/EmptyState.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
|
||||
@@ -15,15 +16,18 @@
|
||||
let refreshing = $state(false);
|
||||
let loaded = $state(false);
|
||||
let error = $state('');
|
||||
let refreshedAt = $state<string | null>(null);
|
||||
let refreshedAt = $state<number | null>(null);
|
||||
let limit = $state(25);
|
||||
let active = false;
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
async function refresh(signal?: AbortSignal): Promise<void | false> {
|
||||
if (!active || refreshing) return;
|
||||
const currentServer = server;
|
||||
const requestController = new AbortController();
|
||||
const abort = () => requestController.abort();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) requestController.abort();
|
||||
controller = requestController;
|
||||
refreshing = true;
|
||||
try {
|
||||
@@ -35,17 +39,19 @@
|
||||
if (!response.ok || !body?.ok || !Array.isArray(body.requests)) {
|
||||
throw new Error(body?.error || `Could not load telemetry requests (HTTP ${response.status}).`);
|
||||
}
|
||||
if (!active || server !== currentServer) return;
|
||||
if (!active || server !== currentServer || requestController.signal.aborted) return;
|
||||
requests = body.requests;
|
||||
retention = body.retention;
|
||||
loaded = true;
|
||||
error = '';
|
||||
refreshedAt = new Date().toISOString();
|
||||
refreshedAt = Date.now();
|
||||
} catch (cause) {
|
||||
if (active && server === currentServer && !requestController.signal.aborted) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not load telemetry requests.';
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (active && server === currentServer) refreshing = false;
|
||||
if (controller === requestController) controller = null;
|
||||
}
|
||||
@@ -61,10 +67,6 @@
|
||||
};
|
||||
});
|
||||
|
||||
function timestamp(value: string): string {
|
||||
return $timeFormat.dateTime(value);
|
||||
}
|
||||
|
||||
function outcome(status: number): string {
|
||||
return status >= 500 ? 'Failed' : status >= 400 ? 'Rejected' : 'Accepted';
|
||||
}
|
||||
@@ -74,15 +76,15 @@
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Telemetry API requests</h2>
|
||||
<p class="server-name"><code>{server}</code></p>
|
||||
<p class="server-name">{serverTitle(server)}</p>
|
||||
</div>
|
||||
<Button size="sm" onclick={refresh} disabled={refreshing} loading={refreshing}>Refresh</Button>
|
||||
<Freshness updatedAt={refreshedAt} error={error ? 'Telemetry request updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void refresh()} retryDisabled={refreshing} />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="load-error" role="alert">
|
||||
Couldn’t load telemetry requests. {#if loaded} Previously loaded requests are shown below.{/if}
|
||||
</p><Button size="sm" onclick={refresh} disabled={refreshing}>Retry</Button><details class="payload"><summary>Technical details</summary>{error}</details>
|
||||
</p><details class="payload"><summary>Technical details</summary>{error}</details>
|
||||
{/if}
|
||||
|
||||
{#if !loaded && !error}
|
||||
@@ -103,7 +105,7 @@
|
||||
</Badge>
|
||||
<code class="event">{request.event ?? 'Event not provided'}</code>
|
||||
</div>
|
||||
<time class="received" datetime={request.receivedAt}>{timestamp(request.receivedAt)}</time>
|
||||
<span class="received"><RelativeTime value={request.receivedAt} /></span>
|
||||
</div>
|
||||
<dl class="request-meta">
|
||||
<div><dt>Map</dt><dd><code>{request.map ?? 'Not provided'}</code></dd></div>
|
||||
@@ -124,13 +126,11 @@
|
||||
{/if}
|
||||
|
||||
<details class="request-help"><summary>About request logging</summary> <p class="intro">
|
||||
Incoming <code>POST /api/telemetry</code> requests with this exact server name.
|
||||
Incoming <code>POST /api/telemetry</code> requests with the exact server name <code>{server}</code>.
|
||||
Fresh game telemetry supplies player counts when available; RCON provides a fallback. The scoreboard uses telemetry.
|
||||
</p>
|
||||
<p class="retention">
|
||||
Newest first · {retention.maxPerServer ? `up to ${retention.maxPerServer} requests per server` : 'no per-server retention cap'} · {retention.maxAgeDays ? `last ${retention.maxAgeDays} days` : 'no age expiry'}. Fleet-wide limits also apply.
|
||||
Refreshes every 5 seconds while visible.
|
||||
{#if refreshedAt}<span>Last refreshed <time datetime={refreshedAt}>{timestamp(refreshedAt)}</time>.</span>{/if}
|
||||
</p>
|
||||
|
||||
<p class="attribution">
|
||||
@@ -161,7 +161,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.retention { margin-top: var(--space-2); }
|
||||
.retention span { display: block; }
|
||||
.attribution { margin-top: var(--space-4); }
|
||||
.request-list { list-style: none; padding: 0; margin: var(--space-5) 0 0; }
|
||||
.request { padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); }
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Host capacity comparison" tabindex="0"><table>
|
||||
<thead><tr><th>Host</th><th>Additional instances</th><th>Limited by</th><th>CPU available</th><th>Memory available</th><th>Free ports</th><th><span class="sr-only">Details</span></th></tr></thead>
|
||||
<thead><tr><th>Host</th><th>Additional servers</th><th>Limited by</th><th>CPU available</th><th>Memory available</th><th>Free ports</th><th><span class="sr-only">Details</span></th></tr></thead>
|
||||
<tbody>{#each hosts as host (host.id)}
|
||||
<tr>
|
||||
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a></td>
|
||||
@@ -31,12 +31,12 @@
|
||||
<div class="detail-content">
|
||||
{#if host.reasons.length}<ul>{#each host.reasons as reason}<li>{reason}</li>{/each}</ul>{/if}
|
||||
<p>Games ready: {host.games.length ? host.games.map(game => game === 't5' ? 'Black Ops I' : 'Black Ops II').join(', ') : 'None'} · {host.portsAvailable} of {host.totalPorts} ports free.</p>
|
||||
<p>Instance limits: CPU {count(host.limits.cpu)} · Memory {count(host.limits.memory)} · Ports {host.limits.ports}.</p>
|
||||
<dl>{#each [{ label: 'Running instance', value: host.calibration.running }, { label: 'Starting instance', value: host.calibration.startup }] as allowance}<div>
|
||||
<p>Server limits: CPU {count(host.limits.cpu)} · Memory {count(host.limits.memory)} · Ports {host.limits.ports}.</p>
|
||||
<dl>{#each [{ label: 'Running server', value: host.calibration.running }, { label: 'Starting server', value: host.calibration.startup }] as allowance}<div>
|
||||
<dt>{allowance.label}</dt><dd>{count(allowance.value.cpuCores)} cores · {memory(allowance.value.memoryBytes)}</dd>
|
||||
<dd class="secondary">{allowance.value.source === 'bootstrap' ? 'Default allowance' : `${allowance.value.source === 'host' ? 'Host' : 'Fleet'} observations · ${allowance.value.samples} samples`}</dd>
|
||||
</div>{/each}</dl>
|
||||
<p>Starting instances may need more resources. Provisioning checks current capacity before reserving a host and port.</p>
|
||||
<p>Starting servers may need more resources. The controller checks current capacity before reserving a host and port.</p>
|
||||
<p>{host.updatedAt ? `Reported ${$timeFormat.dateTime(host.updatedAt)}` : 'No host report yet'}</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
<p class="period-note">{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll('_', ' ')}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'}</p>
|
||||
{#if selected}
|
||||
<div class="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle instances'}</span></div>
|
||||
<div class="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle servers'}</span></div>
|
||||
<details class="inspection"><summary>Inspect a period</summary>
|
||||
<div class="period-controls">
|
||||
<div class="period-select"><Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => selectPeriod(Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="summary">
|
||||
<Card><Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
|
||||
<p>{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}</p>
|
||||
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed instance time</small>
|
||||
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed server time</small>
|
||||
</Card>
|
||||
<Card><Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
|
||||
<p>{playerDelta === null ? comparable && report.summary.playerHours > 0 ? 'New · no previous player-hours' : 'Comparison unavailable' : `${signed(playerDelta, '%')} vs ${previousLabel}`}</p>
|
||||
|
||||
Reference in New Issue
Block a user