Replace server roster with live game scoreboard
This commit is contained in:
+1
-1
@@ -10,7 +10,7 @@ The interface keeps the existing dark palette and blue accent, with shared contr
|
||||
6. **Deployment organization:** Active / Blueprints / History views. Failures and warnings link to filtered history. Destroy fleet lives in Fleet actions and retains its explicit preview.
|
||||
7. **Navigation:** Fleet / Library / Operations / Betas / Settings, with section navigation and a mobile menu. Existing route URLs remain usable.
|
||||
8. **Status and progress:** shared status labels and tones distinguish ordinary waiting, work in progress, attention, passing tests, and promotion. Deployment, rollout, and beta details share operation history.
|
||||
9. **Server detail hub:** live status, current operations, roster, logs, effective configuration, inventory, diagnostics, and scoped rollout history. Missing or stale player counts are unknown. Inventory identifies scripts, plugins, configs, and other files.
|
||||
9. **Server detail hub:** live status, current operations, scoreboard, logs, effective configuration, inventory, diagnostics, and scoped rollout history. The scoreboard shows game-reported names, copyable GUIDs, score, kills, headshots, downs, and revives, with sortable stats, map, round, and update time. Final scores remain visible until the next game starts. Missing or stale player counts are unknown. Inventory identifies scripts, plugins, configs, and other files.
|
||||
10. **Dialogs and feedback:** in-app confirmations for deletion, rollback, reconciliation, and unsaved edits. File comparisons use a native modal dialog with Escape and focus restoration. Refresh errors retain loaded data and provide retry controls.
|
||||
11. **Keyboard and forms:** labeled fields, roving keyboard tabs, visible focus, a skip link, and separate navigation/action targets. Removed nested buttons and buttons inside rollout links.
|
||||
12. **Readability:** brighter secondary metadata, readable small labels, contrasting primary-button text, and full-opacity offline cards.
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||
|
||||
interface Props {
|
||||
roster: RosterPlayer[] | null;
|
||||
health: Health;
|
||||
compact?: boolean; // hide guid column, cap rows
|
||||
limit?: number;
|
||||
}
|
||||
let { roster, health, compact = false, limit }: Props = $props();
|
||||
|
||||
let sorted = $derived(
|
||||
(roster ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit ?? Infinity)
|
||||
);
|
||||
|
||||
function pingClass(ping: number): string {
|
||||
if (ping < 60) return 'ping-good';
|
||||
if (ping < 150) return 'ping-ok';
|
||||
return 'ping-bad';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if health === 'starting'}
|
||||
<div class="state empty">Server starting</div>
|
||||
{:else if health === 'offline'}
|
||||
<div class="state offline">server unreachable</div>
|
||||
{:else if !roster || roster.length === 0}
|
||||
<div class="state empty">lobby is empty</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Player roster" tabindex="0">
|
||||
<table class="roster" class:compact>
|
||||
<thead>
|
||||
<tr>
|
||||
{#if !compact}<th class="col-num">#</th>{/if}
|
||||
<th class="col-name">player</th>
|
||||
<th class="col-score">score</th>
|
||||
<th class="col-ping">ping</th>
|
||||
{#if !compact}<th class="col-guid">guid</th>{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted as p (p.guid ?? p.name + '-' + p.num)}
|
||||
<tr>
|
||||
{#if !compact}<td class="col-num mono">{p.num ?? ''}</td>{/if}
|
||||
<td class="col-name">{p.name || '—'}</td>
|
||||
<td class="col-score mono">{p.score}</td>
|
||||
<td class="col-ping mono {pingClass(p.ping)}">{p.ping}</td>
|
||||
{#if !compact}<td class="col-guid mono">{p.guid ? (p.guid.length > 12 ? p.guid.slice(0, 10) + '…' : p.guid) : '—'}</td>{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
.table-scroll:focus-visible { box-shadow: var(--focus-ring); }
|
||||
.roster:not(.compact) { min-width: 520px; }
|
||||
.state {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.state.offline {
|
||||
color: var(--danger-500);
|
||||
background: rgba(255, 48, 80, 0.08);
|
||||
border: 1px solid rgba(255, 48, 80, 0.25);
|
||||
}
|
||||
.state.empty {
|
||||
color: var(--ink-500);
|
||||
background: var(--bg-nav);
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.roster {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
color: var(--ink-100);
|
||||
}
|
||||
.roster thead th {
|
||||
text-align: left;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--ink-500);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.roster tbody td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid rgba(30, 50, 96, 0.5);
|
||||
}
|
||||
.roster tbody tr:hover td {
|
||||
background: rgba(124, 192, 245, 0.06);
|
||||
}
|
||||
.roster.compact tbody td { padding: 6px 10px; font-size: var(--text-sm); }
|
||||
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.col-num { width: 48px; color: var(--ink-500); }
|
||||
.col-name { color: var(--cream); font-weight: 500; }
|
||||
.col-score { width: 100px; color: var(--spark-500); }
|
||||
.col-ping { width: 90px; }
|
||||
.col-guid { width: 120px; color: var(--ink-500); }
|
||||
|
||||
.ping-good { color: var(--spark-400); }
|
||||
.ping-ok { color: var(--cream-dim); }
|
||||
.ping-bad { color: var(--amber-500); }
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import type { GameTelemetry } from '$lib/telemetry.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
import { mapTitle } from '$ui/server-labels.js';
|
||||
import CopyValue from './CopyValue.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import EmptyState from './primitives/EmptyState.svelte';
|
||||
|
||||
let { report, mapLabel }: { report: GameTelemetry | undefined; mapLabel?: string } = $props();
|
||||
const columns = [
|
||||
{ key: 'score', label: 'Score' },
|
||||
{ key: 'kills', label: 'Kills' },
|
||||
{ key: 'headshots', label: 'Headshots' },
|
||||
{ key: 'downs', label: 'Downs' },
|
||||
{ key: 'revives', label: 'Revives' }
|
||||
] as const;
|
||||
type StatKey = typeof columns[number]['key'];
|
||||
let sortBy = $state<StatKey>('score');
|
||||
let descending = $state(true);
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const players = $derived(report?.event === 'game_start'
|
||||
? report.players.map(player => ({ ...player, score: null, kills: null, headshots: null, downs: null, revives: null }))
|
||||
: report?.players ?? []);
|
||||
const sorted = $derived([...players].sort((a, b) => {
|
||||
const difference = (a[sortBy] ?? 0) - (b[sortBy] ?? 0);
|
||||
return (descending ? -difference : difference) || a.name.localeCompare(b.name);
|
||||
}));
|
||||
|
||||
function sort(key: StatKey) {
|
||||
descending = sortBy === key ? !descending : true;
|
||||
sortBy = key;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !report}
|
||||
<EmptyState title="Waiting for game data" description="The scoreboard will appear when this server reports a game start, round change, or game end." />
|
||||
{:else}
|
||||
<header class="scoreboard-header">
|
||||
<div class="match">
|
||||
<span class="eyebrow">{report.event === 'game_end' ? 'Final scoreboard' : 'Latest scoreboard'}</span>
|
||||
<div class="match-title">
|
||||
<h2 title={report.map}>{mapLabel ?? mapTitle(report.map)}</h2>
|
||||
{#if report.event !== 'game_start'}<Badge variant="accent" size="md">Round {report.round}</Badge>{/if}
|
||||
{#if report.event === 'game_end'}<Badge>Game ended</Badge>
|
||||
{:else if report.event === 'game_start'}<Badge>Game started</Badge>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-meta">
|
||||
<span>{players.length} {players.length === 1 ? 'player' : 'players'}</span>
|
||||
<time datetime={report.receivedAt} title={new Date(report.receivedAt).toLocaleString()}>
|
||||
Updated {elapsedSince(report.receivedAt, $uiNow)} ago
|
||||
</time>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if !players.length}
|
||||
<EmptyState title="No players reported" description="This game report has an empty scoreboard." />
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Player scoreboard" tabindex="0">
|
||||
<table>
|
||||
<caption class="sr-only">{report.event === 'game_end' ? 'Final' : 'Latest'} player scores for {report.server}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="player">Player</th>
|
||||
{#each columns as column (column.key)}
|
||||
<th scope="col" class="stat" aria-sort={sortBy === column.key ? descending ? 'descending' : 'ascending' : 'none'}>
|
||||
<button type="button" onclick={() => sort(column.key)}
|
||||
aria-label={`Sort by ${column.label.toLowerCase()}, ${sortBy === column.key && descending ? 'ascending' : 'descending'}`}>
|
||||
{column.label}<span class:active={sortBy === column.key} aria-hidden="true">{sortBy === column.key ? descending ? '↓' : '↑' : '↕'}</span>
|
||||
</button>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted as player}
|
||||
<tr>
|
||||
<th scope="row" class="player">
|
||||
<span class="player-name">{player.name}</span>
|
||||
<div class="guid"><span>GUID</span><CopyValue value={player.guid} label={`GUID for ${player.name}`} /></div>
|
||||
</th>
|
||||
{#each columns as column (column.key)}
|
||||
<td class="stat" class:score={column.key === 'score'}>
|
||||
{#if player[column.key] === null}<span class="unreported" aria-label="Not yet reported">—</span>
|
||||
{:else}{number.format(player[column.key]!)}{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="note">{report.event === 'game_start' ? 'Player stats arrive with the next round report.' : report.event === 'game_end' ? 'Final stats from this game. The scoreboard resets when the next game starts.' : 'Stats update at each round change and game end.'}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scoreboard-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-4); margin-bottom: var(--space-5); }
|
||||
.match { display: grid; gap: var(--space-2); min-width: 0; }
|
||||
.eyebrow { font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-label); text-transform: uppercase; color: var(--text-tertiary); }
|
||||
.match-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); }
|
||||
h2 { margin: 0; font-size: var(--text-lg); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
||||
.report-meta { display: grid; gap: var(--space-1); text-align: right; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
time { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
table { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0; }
|
||||
th, td { padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border-subtle); }
|
||||
thead th { font-size: var(--text-xs); color: var(--text-secondary); font-weight: var(--weight-semibold); background: var(--surface-sunken); }
|
||||
.player { text-align: left; position: sticky; left: 0; z-index: 1; }
|
||||
thead .player { min-width: 230px; }
|
||||
tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
|
||||
.player-name { display: block; max-width: 320px; overflow-wrap: anywhere; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.guid { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.stat { text-align: right; white-space: nowrap; }
|
||||
td.stat { font: var(--text-sm) var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.score { color: var(--accent-primary-hover); font-weight: var(--weight-semibold); }
|
||||
.unreported { color: var(--text-tertiary); }
|
||||
tbody tr:last-child > * { border-bottom: none; }
|
||||
tbody tr:hover > * { background: var(--surface-raised); }
|
||||
button { display: inline-flex; align-items: center; justify-content: flex-end; gap: var(--space-1); border: 0; background: transparent; padding: var(--space-1) 0; color: inherit; font: inherit; cursor: pointer; }
|
||||
button span { color: var(--text-tertiary); }
|
||||
button span.active, button:hover { color: var(--accent-primary-hover); }
|
||||
button:focus-visible { outline: none; border-radius: var(--radius-sm); box-shadow: var(--focus-ring); }
|
||||
.note { margin: var(--space-3) 0 0; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 640px) {
|
||||
.report-meta { text-align: left; }
|
||||
th, td { padding: var(--space-3); }
|
||||
thead .player { min-width: 190px; }
|
||||
.player-name { max-width: 220px; }
|
||||
}
|
||||
</style>
|
||||
@@ -11,10 +11,10 @@
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
|
||||
import PlayerScoreboard from '$ui/components/PlayerScoreboard.svelte';
|
||||
import LogPanel from '$ui/components/LogPanel.svelte';
|
||||
import { gametypeLabel } from '$ui/gametype.js';
|
||||
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
|
||||
@@ -99,7 +99,7 @@
|
||||
let rconProbe = $state<RconProbeResult | null>(null);
|
||||
let probingRcon = $state(false);
|
||||
let destroyOpen = $state(false);
|
||||
let activeTab = $state('roster');
|
||||
let activeTab = $state('scoreboard');
|
||||
let tabLoading = $state(false);
|
||||
let tabError = $state('');
|
||||
let historyLimit = $state(25);
|
||||
@@ -131,7 +131,7 @@
|
||||
let effectivePlayers = $derived(fresh ? server?.last_players ?? null : null);
|
||||
let effectiveMap = $derived(server?.last_map ?? null);
|
||||
let effectiveGametype = $derived(server?.last_gametype ?? null);
|
||||
let effectiveRoster = $derived(server?.roster ?? null);
|
||||
const scoreboard = $derived($telemetryByServer.get(name));
|
||||
let effectiveHealth = $derived<Health>(server?.health === 'online' && !fresh ? 'stale' : server?.health ?? 'offline');
|
||||
let effectiveSeen = $derived(server?.last_seen_at ?? null);
|
||||
let effectiveErr = $derived(server?.last_error ?? null);
|
||||
@@ -202,7 +202,7 @@
|
||||
}
|
||||
// Tab config — Diagnostics gets a warning badge when there's an issue.
|
||||
let tabs = $derived([
|
||||
{ id: 'roster', label: 'Roster', badge: effectiveRoster?.length ?? null },
|
||||
{ id: 'scoreboard', label: 'Scoreboard', badge: scoreboard?.players.length ?? null },
|
||||
{ id: 'logs', label: 'Logs' },
|
||||
{ id: 'config', label: 'Config' },
|
||||
{ id: 'inventory', label: 'Inventory', badge: inventory.length || null },
|
||||
@@ -286,9 +286,9 @@
|
||||
|
||||
<div class="tab-panel" role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeTab}>
|
||||
{#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeTab)}>Retry</Button></div>{/if}
|
||||
{#if activeTab === 'roster'}
|
||||
{#if activeTab === 'scoreboard'}
|
||||
<Card padding="lg">
|
||||
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
|
||||
{#key name}<PlayerScoreboard report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key}
|
||||
</Card>
|
||||
{:else if activeTab === 'logs'}
|
||||
<Card padding="lg">
|
||||
|
||||
Reference in New Issue
Block a user