Simplify instance details and clarify diagnostics

This commit is contained in:
2026-10-06 17:42:05 -04:00
parent f4094b7f5d
commit b8cccc5a98
15 changed files with 329 additions and 323 deletions
+2
View File
@@ -44,6 +44,8 @@ export interface FleetOperation {
href: string; href: string;
state: 'active' | 'attention' | 'done'; state: 'active' | 'attention' | 'done';
startedAt: string; startedAt: string;
activityLabel?: string;
resultState?: string;
} }
export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' | 'pinned'; export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' | 'pinned';
+1
View File
@@ -66,6 +66,7 @@ function readOperations(now: number, server?: string): FleetOperation[] {
: ['failed', 'interrupted'].includes(row.state) ? 'attention' : 'active'; : ['failed', 'interrupted'].includes(row.state) ? 'attention' : 'active';
operations.push({ key: `deployment:${row.id}`, group: `deployment:${row.id}`, kind: 'deployment', operations.push({ key: `deployment:${row.id}`, group: `deployment:${row.id}`, kind: 'deployment',
server: row.server, state, startedAt: row.started_at, server: row.server, state, startedAt: row.started_at,
activityLabel: `${actionLabels[row.action as ProvisioningAction] ?? row.action} instance`, resultState: row.state,
label: state === 'done' ? row.state === 'cancelled' ? 'Removal cancelled' : row.state === 'warning' ? 'Completed with warnings' : 'Complete' label: state === 'done' ? row.state === 'cancelled' ? 'Removal cancelled' : row.state === 'warning' ? 'Completed with warnings' : 'Complete'
: state === 'attention' ? `${actionLabels[row.action as ProvisioningAction]} needs attention` : row.stage, : state === 'attention' ? `${actionLabels[row.action as ProvisioningAction]} needs attention` : row.stage,
detail: row.error ?? row.stage, href: `/provisioning/${encodeURIComponent(row.id)}` }); detail: row.error ?? row.stage, href: `/provisioning/${encodeURIComponent(row.id)}` });
+9 -6
View File
@@ -18,6 +18,7 @@
let missing = $state(false); let missing = $state(false);
let body = $state<HTMLDivElement | null>(null); let body = $state<HTMLDivElement | null>(null);
let follow = $state(true); let follow = $state(true);
let paused = $state(false);
let controller: AbortController | null = null; let controller: AbortController | null = null;
async function refresh(manual = false) { async function refresh(manual = false) {
@@ -31,9 +32,10 @@
controller = request; controller = request;
try { try {
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, { signal: request.signal }); const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, { signal: request.signal });
const data = await response.json(); const data = await response.json().catch(() => null);
if (!response.ok || !data.ok) throw new Error(data.reason ?? 'Could not refresh chat.'); if (!response.ok || !data?.ok) throw new Error('Could not refresh chat. Try again shortly.');
if (request.signal.aborted) return; if (request.signal.aborted) return;
if (paused && !manual) return;
entries = data.entries ?? []; entries = data.entries ?? [];
missing = !!data.missing; missing = !!data.missing;
loaded = true; loaded = true;
@@ -54,7 +56,7 @@
if (body) body.scrollTop = body.scrollHeight; if (body) body.scrollTop = body.scrollHeight;
} }
onMount(() => { onMount(() => {
const stop = pollVisible(() => refresh(), 5000); const stop = pollVisible(() => paused ? Promise.resolve() : refresh(), 5000);
return () => { stop(); controller?.abort(); }; return () => { stop(); controller?.abort(); };
}); });
</script> </script>
@@ -87,8 +89,9 @@
<section aria-label="Player chat"> <section aria-label="Player chat">
<div class="chat-head"> <div class="chat-head">
<div class="chat-title"><h2>Chat</h2><span class="update-cadence">Updates every 5s</span></div> <div class="chat-title"><h2>Chat</h2><span class="update-cadence">{paused ? 'Paused' : 'Live · 5s'}</span></div>
<div class="actions"> <div class="actions">
{#if hasMessages}<Button size="sm" variant="ghost" onclick={() => paused = !paused}>{paused ? 'Resume' : 'Pause'}</Button>{/if}
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}>Latest messages</Button>{/if} {#if !follow}<Button size="sm" variant="ghost" onclick={latest}>Latest messages</Button>{/if}
<Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing} ariaLabel={manualRefreshing ? 'Refreshing chat' : 'Refresh chat'}>Refresh</Button> <Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing} ariaLabel={manualRefreshing ? 'Refreshing chat' : 'Refresh chat'}>Refresh</Button>
</div> </div>
@@ -143,8 +146,8 @@
.chat-title { gap: var(--space-2) var(--space-3); } .chat-title { gap: var(--space-2) var(--space-3); }
h2 { margin: 0; font-size: var(--text-lg); } h2 { margin: 0; font-size: var(--text-lg); }
.update-cadence { color: var(--text-secondary); font-size: var(--text-xs); } .update-cadence { color: var(--text-secondary); font-size: var(--text-xs); }
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: var(--space-6) 0; } .empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 8px 0; margin-bottom: 0; }
.messages { max-height: 60vh; overflow: auto; margin-top: var(--space-4); } .messages { max-height: 360px; overflow: auto; margin-top: var(--space-4); }
.messages:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-primary); border-radius: var(--radius-sm); } .messages:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-primary); border-radius: var(--radius-sm); }
ol { list-style: none; padding: 0; margin: 0; } ol { list-style: none; padding: 0; margin: 0; }
.message { display: grid; grid-template-columns: 6rem minmax(0, 1fr); align-items: baseline; column-gap: var(--space-4); padding: var(--space-3) 0; } .message { display: grid; grid-template-columns: 6rem minmax(0, 1fr); align-items: baseline; column-gap: var(--space-4); padding: var(--space-3) 0; }
+6 -6
View File
@@ -74,18 +74,18 @@
</script> </script>
<section class="inspector" aria-label="Effective config inspector" aria-busy={loading}> <section class="inspector" aria-label="Effective config inspector" aria-busy={loading}>
<div class="heading"><div><span class="eyebrow">{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span><h2>{artifactId ? 'On this server' : 'Promoted configuration'}</h2></div> <div class="heading"><div>{#if !fixedServer}<span class="eyebrow">{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span>{/if}<h2>{artifactId ? 'On this server' : 'Promoted configuration'}</h2></div>
<Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button> <Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button>
</div> </div>
<p class="intro">{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}</p> <p class="intro">{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}</p>
<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!!fixedServer || !eligible.length}> {#if !fixedServer}<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!!fixedServer || !eligible.length}>
{#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each} {#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each}
{#if !eligible.length}<option value="">No matching servers</option>{/if} {#if !eligible.length}<option value="">No matching servers</option>{/if}
</select></label> </select></label>{/if}
{#if error}<div class="error" role="alert">{error}{inspection ? ' Showing the last completed preview.' : ''}</div>{/if} {#if error}<div class="error" role="alert">{error}{inspection ? ' Showing the last completed preview.' : ''}</div>{/if}
<p class="preview-status" role="status">{loading ? inspection ? 'Updating preview… Showing the last completed result.' : 'Composing configuration…' : inspection ? 'Preview up to date.' : ''}</p> {#if !fixedServer || loading}<p class="preview-status" role="status">{loading ? inspection ? 'Updating preview… Showing the last completed result.' : 'Composing configuration…' : inspection ? 'Preview up to date.' : ''}</p>{/if}
{#if !eligible.length && !loading}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p> {#if !eligible.length && !loading}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
{:else if inspection?.reason}<p class="empty">{inspection.reason}</p> {:else if inspection?.reason}<p class="empty">{fixedServer && inspection.reason.startsWith('No promoted baseline') ? `No promoted baseline for ${servers.find(item => item.name === fixedServer)?.game === 't6' ? 'Black Ops II' : servers.find(item => item.name === fixedServer)?.game === 't5' ? 'Black Ops I' : 'this game'}.` : inspection.reason}</p>{#if fixedServer}<a href="/configs">Open config library →</a>{/if}
{:else if inspection} {:else if inspection}
<ol class="sources" aria-label="Composition order, later layers take precedence"> <ol class="sources" aria-label="Composition order, later layers take precedence">
{#each inspection.sources as source, index} {#each inspection.sources as source, index}
@@ -154,7 +154,7 @@
small { display: block; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 3px; } small { display: block; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 3px; }
.composed { display: block; width: 100%; box-sizing: border-box; margin-top: 16px; padding: 20px; background: var(--surface-sunken); color: var(--text-primary); border: 0; border-radius: var(--radius-sm); min-height: 260px; max-height: 600px; resize: vertical; overflow: auto; font: var(--text-xs)/1.8 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; } .composed { display: block; width: 100%; box-sizing: border-box; margin-top: 16px; padding: 20px; background: var(--surface-sunken); color: var(--text-primary); border: 0; border-radius: var(--radius-sm); min-height: 260px; max-height: 600px; resize: vertical; overflow: auto; font: var(--text-xs)/1.8 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.notes { margin-top: 16px; color: var(--state-warning); font-size: var(--text-xs); } .notes summary { cursor: pointer; } .notes li { margin: 8px 0; } .notes { margin-top: 16px; color: var(--state-warning); font-size: var(--text-xs); } .notes summary { cursor: pointer; } .notes li { margin: 8px 0; }
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; } .empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 8px 0; }
.error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); } .error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); }
@media (max-width: 600px) { .inspector { padding: 16px; } } @media (max-width: 600px) { .inspector { padding: 16px; } }
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); } input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
+6 -3
View File
@@ -134,12 +134,12 @@
</button> </button>
</div> </div>
</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><div class="log-actions"><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></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>
{#if copyStatus}<p class="copy-status" role="status">{copyStatus}</p>{/if} {#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 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}> <div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
{#if refreshError}<div class="ui-error" role="alert">{refreshError} {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => refresh.refresh()} disabled={loading}>Retry</Button></div>{/if} {#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 !current && !refreshError} {#if !current && !refreshError}
<div class="empty">{loading ? 'loading…' : 'No log loaded. Select Refresh to load it.'}</div> <div class="empty">{loading ? 'loading…' : 'No log loaded. Select Refresh to load it.'}</div>
{:else if current && !current.ok} {:else if current && !current.ok}
@@ -165,7 +165,10 @@
</div> </div>
<style> <style>
.log-tools, .log-actions, .follow-state { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .log-tools, .action-items, .follow-state { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.log-actions { position: relative; font-size: var(--text-sm); }
summary { cursor: pointer; }
.action-items { position: absolute; right: 0; z-index: 3; padding: 12px; min-width: 160px; background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); }
.log-tools { align-items: end; } .log-tools { align-items: end; }
.log-search { display: grid; gap: 6px; flex: 1; min-width: 180px; font-size: var(--text-xs); color: var(--text-secondary); } .log-search { display: grid; gap: 6px; flex: 1; min-width: 180px; font-size: var(--text-xs); color: var(--text-secondary); }
.log-search input { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 9px 12px; font: inherit; } .log-search input { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 9px 12px; font: inherit; }
+8 -7
View File
@@ -50,7 +50,7 @@
<Card padding="lg"> <Card padding="lg">
<h2>Match history</h2> <h2>Match history</h2>
<p>Newest matches first. Open a completed match to see its player results. Unique players are counted across all match reports. — means the start or end wasn’t recorded, or the match is still in progress.</p>
{#if error} {#if error}
<div role="alert">{error} <Button onclick={() => refresh(server, limit)}>Retry</Button></div> <div role="alert">{error} <Button onclick={() => refresh(server, limit)}>Retry</Button></div>
{/if} {/if}
@@ -63,22 +63,21 @@
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="table-scroll" tabindex="0" role="region" aria-label="Match history"> <div class="table-scroll" tabindex="0" role="region" aria-label="Match history">
<table> <table>
<thead><tr><th>Started</th><th>Ended</th>{#if server === undefined}<th>Server</th>{/if}<th>Map</th><th>Status</th><th>Players at start</th><th>Unique players</th><th>Players at end</th><th>Game time</th><th>Final round</th></tr></thead> <thead><tr><th>Started</th>{#if server === undefined}<th>Server</th>{/if}<th>Map</th><th>Status</th><th>Unique players</th><th>Duration</th><th>Final round</th></tr></thead>
<tbody> <tbody>
{#each matches as match (match.id)} {#each matches as match (match.id)}
<tr class:completed={match.status === 'completed'}> <tr class:completed={match.status === 'completed'}>
<td><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></td> <td><time datetime={match.startedAt} title={$timeFormat.dateTime(match.startedAt)}>{$timeFormat.period(match.startedAt)}</time></td>
<td>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</td>
{#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if} {#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if}
<td> <td>
{#if match.status === 'completed'} {#if match.status === 'completed'}
<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}> <a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>
{match.map}<small>View match →</small> {match.map}
</a> </a>
{:else}{match.map}{/if} {:else}{match.map}{/if}
</td> </td>
<td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td> <td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
<td>{match.startPlayers ?? '—'}</td><td>{match.uniquePlayers}</td><td>{match.endPlayers ?? '—'}</td> <td>{match.uniquePlayers}</td>
<td>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</td><td>{match.finalRound ?? '—'}</td> <td>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</td><td>{match.finalRound ?? '—'}</td>
</tr> </tr>
{/each} {/each}
@@ -88,6 +87,7 @@
<ListFooter total={Math.min(total, 10000)} bind:limit onchange={saveContext} label="matches" /> <ListFooter total={Math.min(total, 10000)} bind:limit onchange={saveContext} label="matches" />
{#if total > 10000}<p>Showing the latest 10,000 matches.</p>{/if} {#if total > 10000}<p>Showing the latest 10,000 matches.</p>{/if}
{/if} {/if}
<details><summary>About match history</summary><p>Newest matches first. Unique players are counted across all reports. — means a value was not recorded or the match is still in progress. Open a completed match for exact timestamps and player counts at the start and end.</p></details>
</Card> </Card>
<style> <style>
@@ -103,5 +103,6 @@
.match-link { display: block; color: var(--accent-primary-hover); text-decoration: none; } .match-link { display: block; color: var(--accent-primary-hover); text-decoration: none; }
.match-link:hover { text-decoration: underline; } .match-link:hover { text-decoration: underline; }
.match-link:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); } .match-link:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
.match-link small { display: block; margin-top: var(--space-1); font-size: var(--text-xs); } details { margin-top: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
summary { cursor: pointer; }
</style> </style>
+18 -15
View File
@@ -10,10 +10,12 @@
import Badge from './primitives/Badge.svelte'; import Badge from './primitives/Badge.svelte';
import EmptyState from './primitives/EmptyState.svelte'; import EmptyState from './primitives/EmptyState.svelte';
let { report, mapLabel, current = true, after = null }: { report: GameTelemetry | undefined; mapLabel?: string; current?: boolean; after?: string | null } = $props(); let { report, mapLabel, current = true, after = null, matchElapsed = null, activity }: { report: GameTelemetry | undefined; mapLabel?: string; current?: boolean; after?: string | null; matchElapsed?: string | null; activity?: string } = $props();
const fresh = $derived(current && freshTelemetry(report, $uiNow, after)); const fresh = $derived(current && freshTelemetry(report, $uiNow, after));
const timingNow = $derived(report && (report.event === 'game_end' || !fresh) const timingNow = $derived(report && (report.event === 'game_end' || !fresh)
? Date.parse(report.receivedAt) : $uiNow); ? Date.parse(report.receivedAt) : $uiNow);
const displayElapsed = $derived(fresh ? matchElapsed : report?.timing?.server?.gameStartedAt && !report.timing.server.gameEndedAt
? elapsedSince(report.timing.server.gameStartedAt, timingNow) : null);
const columns = [ const columns = [
{ key: 'score', label: 'Score' }, { key: 'score', label: 'Score' },
{ key: 'kills', label: 'Kills' }, { key: 'kills', label: 'Kills' },
@@ -24,6 +26,8 @@
type StatKey = typeof columns[number]['key']; type StatKey = typeof columns[number]['key'];
let sortBy = $state<StatKey>('score'); let sortBy = $state<StatKey>('score');
let descending = $state(true); let descending = $state(true);
let expanded = $state<string[]>([]);
const uid = $props.id();
const number = new Intl.NumberFormat('en-US'); const number = new Intl.NumberFormat('en-US');
const players = $derived(report?.event === 'game_start' const players = $derived(report?.event === 'game_start'
? report.players.map(player => ({ ...player, score: null, kills: null, headshots: null, downs: null, revives: null })) ? report.players.map(player => ({ ...player, score: null, kills: null, headshots: null, downs: null, revives: null }))
@@ -48,12 +52,13 @@
<div class="match-title"> <div class="match-title">
<h2 title={report.map}>{mapLabel ?? mapTitle(report.map)}</h2> <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_start'}<Badge variant="accent" size="md">Round {report.round}</Badge>{/if}
{#if displayElapsed}<span class="match-time">Match {displayElapsed}{fresh ? '' : ' at last report'}</span>{/if}
{#if report.event === 'game_end'}<Badge>Game ended</Badge> {#if report.event === 'game_end'}<Badge>Game ended</Badge>
{:else if report.event === 'game_start'}<Badge>Game started</Badge>{/if} {:else if report.event === 'game_start'}<Badge>Game started</Badge>{/if}
</div> </div>
</div> </div>
<div class="report-meta"> <div class="report-meta">
<span>{players.length} {players.length === 1 ? 'player' : 'players'} in this report</span> <span>{players.length} {players.length === 1 ? 'player' : 'players'}{fresh ? '' : ' in last report'}</span>
{#if !fresh}<Badge variant="warning">Historical report</Badge>{/if} {#if !fresh}<Badge variant="warning">Historical report</Badge>{/if}
<time datetime={report.receivedAt} title={$timeFormat.dateTime(report.receivedAt)}> <time datetime={report.receivedAt} title={$timeFormat.dateTime(report.receivedAt)}>
Updated {elapsedSince(report.receivedAt, $uiNow)} ago Updated {elapsedSince(report.receivedAt, $uiNow)} ago
@@ -71,9 +76,6 @@
<thead> <thead>
<tr> <tr>
<th scope="col" class="player">Player</th> <th scope="col" class="player">Player</th>
<th scope="col" class="stat">Time on server</th>
<th scope="col" class="stat">Time in match</th>
<th scope="col" class="stat">Round started</th>
{#each columns as column (column.key)} {#each columns as column (column.key)}
<th scope="col" class="stat" aria-sort={sortBy === column.key ? descending ? 'descending' : 'ascending' : 'none'}> <th scope="col" class="stat" aria-sort={sortBy === column.key ? descending ? 'descending' : 'ascending' : 'none'}>
<button type="button" onclick={() => sort(column.key)} <button type="button" onclick={() => sort(column.key)}
@@ -90,11 +92,8 @@
<tr> <tr>
<th scope="row" class="player"> <th scope="row" class="player">
<a class="player-name" href={playerProfileHref(player.guid)}>{player.name}</a> <a class="player-name" href={playerProfileHref(player.guid)}>{player.name}</a>
<div class="guid"><span>GUID</span><CopyValue value={player.guid} label={`GUID for ${player.name}`} /></div> <button class="player-details" aria-label={`Details for ${player.name}`} aria-expanded={expanded.includes(player.guid)} aria-controls={`${uid}-${player.guid}`} onclick={() => expanded = expanded.includes(player.guid) ? expanded.filter(guid => guid !== player.guid) : [...expanded, player.guid]}>Details</button>
</th> </th>
<td class="stat">{timing ? elapsedSince(timing.serverJoinedAt, timingNow) : '—'}</td>
<td class="stat">{timing ? elapsedSince(timing.matchJoinedAt, timingNow) : '—'}</td>
<td class="stat">{timing?.roundStarted ?? '—'}</td>
{#each columns as column (column.key)} {#each columns as column (column.key)}
<td class="stat" class:score={column.key === 'score'}> <td class="stat" class:score={column.key === 'score'}>
{#if player[column.key] === null}<span class="unreported" aria-label="Not yet reported">—</span> {#if player[column.key] === null}<span class="unreported" aria-label="Not yet reported">—</span>
@@ -102,20 +101,24 @@
</td> </td>
{/each} {/each}
</tr> </tr>
<tr id={`${uid}-${player.guid}`} hidden={!expanded.includes(player.guid)}><td colspan="6"><div class="player-metadata"><div class="guid"><span>GUID</span><CopyValue value={player.guid} label={`GUID for ${player.name}`} /></div><span>Time on instance: {timing ? elapsedSince(timing.serverJoinedAt, timingNow) : '—'}</span><span>Time in match: {timing ? elapsedSince(timing.matchJoinedAt, timingNow) : '—'}</span><span>Joined round: {timing?.roundStarted ?? '—'}</span></div></td></tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div> </div>
{/if} {/if}
<div class="scoreboard-footer"> <div class="scoreboard-footer">
<p class="note">{report.event === 'game_start' ? 'Player stats arrive with the next round report or heartbeat.' : report.event === 'game_end' ? 'Final stats from this game. The scoreboard resets when the next game starts.' : 'Stats update at each round change, heartbeat, and game end.'}</p> <details><summary>About these stats</summary><p class="note">{report.event === 'game_start' ? 'Player stats arrive with the next round report or heartbeat.' : report.event === 'game_end' ? 'Final stats from this game. The scoreboard resets when the next game starts.' : 'Stats update at each round change, heartbeat, and game end.'}</p><p class="timing-note">Times and joining rounds are estimated from player reports. Time on instance carries across matches; ended or stale reports freeze at the last update.</p>{#if activity}<p class="timing-note">{activity}</p>{/if}</details>
<a href="/leaderboard">View lifetime leaderboard →</a> <a href="/leaderboard">View lifetime leaderboard →</a>
</div> </div>
<p class="timing-note">Times and joining rounds are estimated from player reports. Server time carries across consecutive matches; ended or stale reports show time at the last update.</p>
{/if} {/if}
<style> <style>
.scoreboard-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-4); margin-bottom: var(--space-5); } .player-metadata { display: flex; flex-wrap: wrap; gap: 12px 24px; font-size: var(--text-xs); color: var(--text-secondary); }
summary { cursor: pointer; color: var(--text-secondary); }
.match-time { font-size: var(--text-sm); color: var(--text-secondary); }
button.player-details { font-size: var(--text-xs); color: var(--text-secondary); }
.scoreboard-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-4); margin-bottom: var(--space-3); }
.match { display: grid; gap: var(--space-2); min-width: 0; } .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); } .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); } .match-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); }
@@ -124,11 +127,11 @@
time { font-size: var(--text-xs); color: var(--text-tertiary); } 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 { 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-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
table { width: 100%; min-width: 1000px; border-collapse: separate; border-spacing: 0; } table { width: 100%; min-width: 560px; border-collapse: separate; border-spacing: 0; }
th, td { padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border-subtle); } 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); } 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; } .player { text-align: left; position: sticky; left: 0; z-index: 1; }
thead .player { min-width: 230px; } thead .player { min-width: 160px; }
tbody .player { background: var(--surface); font-weight: var(--weight-medium); } 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); } .player-name { display: block; max-width: 320px; overflow-wrap: anywhere; font-size: var(--text-sm); color: var(--text-primary); }
.player-name { text-decoration: none; } .player-name { text-decoration: none; }
@@ -154,7 +157,7 @@
@media (max-width: 640px) { @media (max-width: 640px) {
.report-meta { text-align: left; } .report-meta { text-align: left; }
th, td { padding: var(--space-3); } th, td { padding: var(--space-3); }
thead .player { min-width: 190px; } thead .player { min-width: 150px; }
.player-name { max-width: 220px; } .player-name { max-width: 220px; }
} }
</style> </style>
@@ -79,20 +79,10 @@
<Button size="sm" onclick={refresh} disabled={refreshing} loading={refreshing}>Refresh</Button> <Button size="sm" onclick={refresh} disabled={refreshing} loading={refreshing}>Refresh</Button>
</div> </div>
<p class="intro">
Incoming <code>POST /api/telemetry</code> requests with this exact server name.
The scoreboard uses telemetry; the fleet player count comes from RCON separately.
</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>
{#if error} {#if error}
<p class="load-error" role="alert"> <p class="load-error" role="alert">
{error}{#if loaded} Previously loaded requests are shown below.{/if} Couldn’t load telemetry requests. {#if loaded} Previously loaded requests are shown below.{/if}
</p> </p><Button size="sm" onclick={refresh} disabled={refreshing}>Retry</Button><details class="payload"><summary>Technical details</summary>{error}</details>
{/if} {/if}
{#if !loaded && !error} {#if !loaded && !error}
@@ -133,14 +123,24 @@
<ListFooter total={requests.length} bind:limit label="requests" /> <ListFooter total={requests.length} bind:limit label="requests" />
{/if} {/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.
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"> <p class="attribution">
Logging starts after this update is installed.
Requests can be linked here only when readable JSON contains a matching top-level <code>server</code> field. Requests can be linked here only when readable JSON contains a matching top-level <code>server</code> field.
Calls that never reach Electric Cherry, malformed JSON, and different server names will not appear here. Calls that never reach Electric Cherry, malformed JSON, and different server names will not appear here.
</p> </p></details>
</Card> </Card>
<style> <style>
.request-help { margin-top: 16px; font-size: var(--text-xs); }
.panel-head, .request-head, .request-result { .panel-head, .request-head, .request-result {
display: flex; display: flex;
align-items: center; align-items: center;
+3 -2
View File
@@ -52,8 +52,9 @@ export function createLogRefresh(options: Options) {
try { try {
const response = await request(`/api/servers/${encodeURIComponent(target.server)}/logs/${encodeURIComponent(target.key)}?lines=${target.lines}`, const response = await request(`/api/servers/${encodeURIComponent(target.server)}/logs/${encodeURIComponent(target.key)}?lines=${target.lines}`,
{ signal: entry.controller.signal, cache: 'no-store' }); { signal: entry.controller.signal, cache: 'no-store' });
const body = await response.json() as LogResponse; const body = await response.json().catch(() => null) as LogResponse | null;
if (!response.ok || !body.ok) throw new Error(body.reason ?? 'Could not refresh log.'); if (!response.ok || !body?.ok) throw new Error(body?.reason ?? `HTTP ${response.status}: ${body ? 'Log request failed.' : 'Expected a JSON log response.'}`);
if (!Array.isArray(body.lines) || !body.lines.every(line => typeof line === 'string')) throw new Error('Invalid log response: expected an array of lines.');
if (current()) { options.onError(''); options.onData(target.key, body); } if (current()) { options.onError(''); options.onData(target.key, body); }
} catch (error) { } catch (error) {
if (current() && !entry.controller.signal.aborted) options.onError(error instanceof Error ? error.message : 'Could not refresh log.'); if (current() && !entry.controller.signal.aborted) options.onError(error instanceof Error ? error.message : 'Could not refresh log.');
+30
View File
@@ -0,0 +1,30 @@
import type { FleetOperation } from '$lib/fleet-overview.js';
export interface InstanceCampaign {
id: string;
action: string;
started_at: string;
servers: Array<{ server: string; restart_state: string; error: string | null; files_changed: number }>;
}
/** Merge overlapping campaign snapshots without hiding distinct provisioning records. */
export function instanceActivity(name: string, operations: FleetOperation[], campaigns: InstanceCampaign[]) {
const campaignLinks = new Set(campaigns.map(item => `/campaigns/${encodeURIComponent(item.id)}`));
return [
...operations.filter(op => op.server === name && !(op.kind === 'campaign' && campaignLinks.has(op.href))).map(op => ({
key: op.key, at: op.startedAt,
label: op.activityLabel ?? (op.label === 'Complete' ? op.detail : op.label),
state: op.resultState ?? op.state,
source: op.kind === 'deployment' ? 'Provisioning' : op.kind.charAt(0).toUpperCase() + op.kind.slice(1),
href: op.href, detail: op.state === 'attention' ? op.detail : null
})),
...campaigns.flatMap(campaign => {
const result = campaign.servers.find(item => item.server === name);
if (!result) return [];
const action = campaign.action === 'promote' ? 'Promote files' : campaign.action === 'rollback' ? 'Roll back files' : 'Reconcile files';
return [{ key: `history-${campaign.id}`, at: campaign.started_at,
label: `${action} · ${result.files_changed} ${result.files_changed === 1 ? 'file' : 'files'}`,
state: result.restart_state, source: 'Campaign', href: `/campaigns/${encodeURIComponent(campaign.id)}`, detail: result.error }];
})
].sort((a, b) => Date.parse(b.at) - Date.parse(a.at) || a.key.localeCompare(b.key));
}
@@ -6,13 +6,16 @@ import { db } from '$lib/server/db/index.js';
export async function GET({ params }: { params: { name: string } }) { export async function GET({ params }: { params: { name: string } }) {
const server = getServer(params.name); const server = getServer(params.name);
if (!server) return json({ ok: false, error: 'no such server' }, { status: 404 }); if (!server) return json({ ok: false, error: 'no such server' }, { status: 404 });
const metadata = db().prepare(`SELECT i.rel_path, s.kind FROM server_scripts i const metadata = db().prepare(`SELECT i.rel_path, s.kind, s.id AS artifactId, v.version, v.release_version AS releaseVersion FROM server_scripts i
JOIN script_versions v ON v.id = i.script_version_id JOIN scripts s ON s.id = v.script_id JOIN script_versions v ON v.id = i.script_version_id JOIN scripts s ON s.id = v.script_id
WHERE i.server = ?`).all(params.name) as Array<{ rel_path: string; kind: string }>; WHERE i.server = ? AND i.sha256 = v.sha256`).all(params.name) as Array<{ rel_path: string; kind: string; artifactId: number; version: number; releaseVersion: string | null }>;
const kinds = new Map(metadata.map(row => [row.rel_path, row.kind])); const versions = new Map(metadata.map(row => [row.rel_path, row]));
const scripts = listServerScripts(params.name).map((r) => ({ const scripts = listServerScripts(params.name).map((r) => ({
relPath: r.rel_path, relPath: r.rel_path,
kind: kinds.get(r.rel_path) ?? (/\.cfg$/i.test(r.rel_path) ? 'config' : /\.dll$/i.test(r.rel_path) ? 'plugin' : /\.gsc$/i.test(r.rel_path) ? 'script' : 'file'), artifactId: versions.get(r.rel_path)?.artifactId ?? null,
version: versions.get(r.rel_path)?.version ?? null,
releaseVersion: versions.get(r.rel_path)?.releaseVersion ?? null,
kind: versions.get(r.rel_path)?.kind ?? (/\.cfg$/i.test(r.rel_path) ? 'config' : /\.dll$/i.test(r.rel_path) ? 'plugin' : /\.gsc$/i.test(r.rel_path) ? 'script' : 'file'),
scriptVersionId: r.script_version_id, scriptVersionId: r.script_version_id,
sha256: r.sha256, sha256: r.sha256,
size: r.size, size: r.size,
+75 -266
View File
@@ -1,19 +1,21 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { tick } from 'svelte';
import { instanceActivity } from '$ui/server-activity.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { freshTelemetry } from '$lib/server-observation.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { serverSection, serverSectionAnchor } from '$lib/server-sections.js'; import { serverSection, serverSectionAnchor } from '$lib/server-sections.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte'; import BackLink from '$ui/components/BackLink.svelte';
let { data }: { data: import('./$types').PageData } = $props(); let { data }: { data: import('./$types').PageData } = $props();
import RuntimeVersion from '$ui/components/RuntimeVersion.svelte'; import RuntimeVersion from '$ui/components/RuntimeVersion.svelte';
import MatchHistory from '$ui/components/MatchHistory.svelte'; import MatchHistory from '$ui/components/MatchHistory.svelte';
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
import TelemetryDiagnostics from '$ui/components/TelemetryDiagnostics.svelte'; import TelemetryDiagnostics from '$ui/components/TelemetryDiagnostics.svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { fetchCampaigns } from '$ui/campaigns.js'; import { fetchCampaigns } from '$ui/campaigns.js';
import { createFleetStore } from '$ui/stores/fleet.js'; import { createFleetStore } from '$ui/stores/fleet.js';
import { freshServer, fleetIdentity } from '$lib/fleet-overview.js'; import { freshServer, fleetIdentity } from '$lib/fleet-overview.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import CopyValue from '$ui/components/CopyValue.svelte'; import CopyValue from '$ui/components/CopyValue.svelte';
import ConfigInspector from '$ui/components/ConfigInspector.svelte'; import ConfigInspector from '$ui/components/ConfigInspector.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
@@ -31,17 +33,14 @@
import PlayerScoreboard from '$ui/components/PlayerScoreboard.svelte'; import PlayerScoreboard from '$ui/components/PlayerScoreboard.svelte';
import LogPanel from '$ui/components/LogPanel.svelte'; import LogPanel from '$ui/components/LogPanel.svelte';
import ChatPanel from '$ui/components/ChatPanel.svelte'; import ChatPanel from '$ui/components/ChatPanel.svelte';
import { gametypeLabel } from '$ui/gametype.js';
import { decodeRconError, rconFixHint } from '$ui/rconError.js'; import { decodeRconError, rconFixHint } from '$ui/rconError.js';
import Card from '$ui/components/primitives/Card.svelte'; import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte'; import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import Stat from '$ui/components/primitives/Stat.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte'; import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusPip from '$ui/components/primitives/StatusPip.svelte'; import StatusPip from '$ui/components/primitives/StatusPip.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
interface RconProbeResult { interface RconProbeResult {
@@ -75,6 +74,7 @@
} }
interface InventoryEntry { interface InventoryEntry {
relPath: string; kind?: string; scriptVersionId: number | null; relPath: string; kind?: string; scriptVersionId: number | null;
artifactId?: number | null; version?: number | null; releaseVersion?: string | null;
sha256: string; size: number; updatedAt: string; source: string | null; sha256: string; size: number; updatedAt: string; source: string | null;
} }
interface DeploymentRow { interface DeploymentRow {
@@ -93,6 +93,7 @@
const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.serverSnapshot.servers); const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.serverSnapshot.servers);
let server = $derived((visibleFleet.find(s => s.name === name) as Server | undefined) ?? null); let server = $derived((visibleFleet.find(s => s.name === name) as Server | undefined) ?? null);
const native = $derived(!!server?.native_instance_id); const native = $derived(!!server?.native_instance_id);
let telemetryOpen = $state(false);
let history = $state<DeploymentRow[]>([]); let history = $state<DeploymentRow[]>([]);
let inventory = $state<InventoryEntry[]>([]); let inventory = $state<InventoryEntry[]>([]);
let rconProbe = $state<RconProbeResult | null>(null); let rconProbe = $state<RconProbeResult | null>(null);
@@ -123,6 +124,8 @@
let inventoryLimit = $state(25); let inventoryLimit = $state(25);
const operations = $derived(snapshot.operations.filter(op => op.server === name)); const operations = $derived(snapshot.operations.filter(op => op.server === name));
const activity = $derived(instanceActivity(name, operations, history));
let tabSequence = 0; let tabSequence = 0;
async function loadTab(currentName: string, tab: string, signal?: AbortSignal) { async function loadTab(currentName: string, tab: string, signal?: AbortSignal) {
if (tab !== 'history' && tab !== 'configuration') return; if (tab !== 'history' && tab !== 'configuration') return;
@@ -157,8 +160,6 @@
const fresh = $derived(server ? freshServer(server, $uiNow) : false); const fresh = $derived(server ? freshServer(server, $uiNow) : false);
let effectivePlayers = $derived(fresh ? server?.last_players ?? null : null); let effectivePlayers = $derived(fresh ? server?.last_players ?? null : null);
let effectiveMap = $derived(server?.last_map ?? null);
let effectiveGametype = $derived(server?.last_gametype ?? null);
const scoreboard = $derived(server?.telemetry ?? $telemetryByServer.get(name)); const scoreboard = $derived(server?.telemetry ?? $telemetryByServer.get(name));
let effectiveHealth = $derived<Health>(server?.health === 'online' && !fresh ? 'stale' : server?.health ?? 'offline'); let effectiveHealth = $derived<Health>(server?.health === 'online' && !fresh ? 'stale' : server?.health ?? 'offline');
let effectiveSeen = $derived(server?.last_seen_at ?? null); let effectiveSeen = $derived(server?.last_seen_at ?? null);
@@ -191,20 +192,19 @@
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`; if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`; return `${Math.floor(d / 86_400_000)}d ago`;
} }
function endpointLabel(ep: RconEndpoint | null): string {
if (!ep) return 'unresolved';
return `${ep.host}:${ep.port}`;
}
async function probeRcon(): Promise<void> { async function probeRcon(): Promise<void> {
if (probingRcon || !name) return; if (probingRcon || !name) return;
const currentName = name;
probingRcon = true; rconProbe = null; probingRcon = true; rconProbe = null;
try { try {
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-rcon`, { method: 'POST' }); const res = await fetch(`/api/servers/${encodeURIComponent(currentName)}/probe-rcon`, { method: 'POST' });
rconProbe = (await res.json()) as RconProbeResult; const body = await res.json().catch(() => null);
if (!body || typeof body.ok !== 'boolean') throw new Error(`HTTP ${res.status}: Expected a JSON connection result.`);
if (name !== currentName) return;
rconProbe = body as RconProbeResult;
await refreshFleet(); await refreshFleet();
} catch (err) { } catch (err) {
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message }; if (name === currentName) rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
} finally { probingRcon = false; } } finally { probingRcon = false; }
} }
// Tab config — Diagnostics gets a warning badge when there's an issue. // Tab config — Diagnostics gets a warning badge when there's an issue.
@@ -212,7 +212,7 @@
{ id: 'live', label: 'Live' }, { id: 'live', label: 'Live' },
{ id: 'matches', label: 'Matches' }, { id: 'matches', label: 'Matches' },
{ id: 'configuration', label: 'Configuration' }, { id: 'configuration', label: 'Configuration' },
{ id: 'diagnostics', label: 'Diagnostics', badge: hasDiagnosticIssue ? '!' : null }, { id: 'diagnostics', label: 'Diagnostics', badge: hasDiagnosticIssue || effectiveErr ? '!' : null },
{ id: 'history', label: 'History' } { id: 'history', label: 'History' }
]); ]);
</script> </script>
@@ -223,73 +223,19 @@
{#if server} {#if server}
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};"> <div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
<!-- Hero: name + status + player count + primary actions --> <header class="instance-header">
<Card padding="lg" elevation="flat"> <BackLink fallback="/" />
<SectionHeader title={serverTitle(server.name)}> <SectionHeader title={serverTitle(server.name)}>
{#snippet crumbs()}
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
{/snippet}
{#snippet actions()} {#snippet actions()}
<details class="server-actions"><summary>Server actions</summary>{#each runtimeActions as action}<Button onclick={() => { lifecycleAction = action; lifecycleOpen = true; }}>{actionLabels[action]}</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Queue server removal</Button></details> <details class="server-actions"><summary>Instance actions</summary><div class="action-menu">{#each runtimeActions as action}<Button onclick={() => { lifecycleAction = action; lifecycleOpen = true; }}>{actionLabels[action]}</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Queue instance removal</Button></div></details>
{/snippet} {/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback="/" /> <div class="instance-meta">
<span class="health"><StatusPip health={effectiveHealth} size="md" />{effectiveHealth === 'online' ? 'Online' : effectiveHealth === 'starting' ? 'Starting' : effectiveHealth === 'provisioning' ? 'Provisioning' : effectiveHealth === 'stale' ? 'Stale' : 'Offline'}</span>
<CopyValue value={name} label="Server ID" /> {#if server.host_id}<a href={contextHref(`/hosts/${server.host_id}`, $page.url)}>{server.host?.name ?? server.host_id}</a>{/if}
<RuntimeVersion {name} version={server.plutonium} after={server.telemetry_after} ready={effectiveHealth === 'online'} /> <details><summary>Details</summary><div class="instance-details"><CopyValue value={name} label="Instance ID" /><RuntimeVersion {name} version={server.plutonium} after={server.telemetry_after} ready={effectiveHealth === 'online'} /></div></details>
<div class="metrics">
<div class="metric-cell name-cell">
<StatusPip health={effectiveHealth} size="md" />
<span class="health-label">
{effectiveHealth === 'online' ? 'Online' :
effectiveHealth === 'starting' ? 'Starting' :
effectiveHealth === 'provisioning' ? 'Provisioning' :
effectiveHealth === 'stale' ? 'Stale' : 'Offline'}
</span>
</div>
<div class="metric-cell">
<Stat
label="Players"
value={effectiveHealth === 'starting' ? '—' : effectivePlayers ?? '—'}
size="md"
tone={(effectivePlayers ?? 0) > 0 ? 'online' : 'primary'}
tween
/>
</div>
<div class="metric-cell">
<Stat label="Map" value={fleetIdentity(server).map} size="md" tween={false} />
{#if gametypeLabel(effectiveGametype)}
<div class="sub">{gametypeLabel(effectiveGametype)}</div>
{/if}
</div>
{#if resetElapsed}
<div class="metric-cell">
<Stat label="Resetting" value={resetElapsed} size="md" tween={false} />
</div>
{:else if activeElapsed}
<div class="metric-cell">
<Stat label="Active for" value={activeElapsed} size="md" tween={false} />
</div>
{:else if inactiveElapsed}
<div class="metric-cell">
<Stat label="Inactive for" value={inactiveElapsed} size="md" tween={false} />
</div>
{/if}
{#if gameElapsed}
<div class="metric-cell">
<Stat label="Match" value={gameElapsed} size="md" tween={false} tone="accent" />
</div>
{/if}
{#if effectiveHealth !== 'online'}
<div class="metric-cell">
<Stat label="Last seen" value={ago(effectiveSeen, $uiNow)} size="md" tween={false} />
</div>
{/if}
</div> </div>
</header>
{#if effectiveHealth === 'online'}<RoundTelemetry {name} report={scoreboard} since={server?.telemetry_after ?? null} />{/if}
</Card>
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry</Button></div>{/if} {#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry</Button></div>{/if}
{#if hasDiagnosticIssue} {#if hasDiagnosticIssue}
@@ -305,19 +251,18 @@
<div class="tab-panel" class:diagnostics={activeSection === 'diagnostics'} class:history-panel={activeSection === 'history'} role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeSection}> <div class="tab-panel" class:diagnostics={activeSection === 'diagnostics'} class:history-panel={activeSection === 'history'} role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeSection}>
{#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeSection)}>Retry</Button></div>{/if} {#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeSection)}>Retry</Button></div>{/if}
{#if activeSection === 'live'} {#if activeSection === 'live'}
<nav class="section-links" aria-label="Live sections"><a href="#scoreboard">Scoreboard</a><a href="#chat">Chat</a></nav>
<section id="scoreboard" aria-label="Scoreboard"> <section id="scoreboard" aria-label="Scoreboard">
{#if !scoreboard || !fresh || !freshTelemetry(scoreboard, $uiNow, server.telemetry_after)}<p class="live-context">Players: {effectivePlayers ?? '—'} · {fleetIdentity(server).map}{#if !fresh} · Current count unavailable{/if}</p>{/if}
<Card padding="lg"> <Card padding="lg">
{#key name}<PlayerScoreboard current={fresh} after={server?.telemetry_after ?? null} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key} {#key name}<PlayerScoreboard matchElapsed={gameElapsed} activity={resetElapsed ? `Resetting · ${resetElapsed}` : activeElapsed ? `Players present for ${activeElapsed}` : inactiveElapsed ? `Empty for ${inactiveElapsed}` : undefined} current={fresh} after={server?.telemetry_after ?? null} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key}
</Card> </Card>
</section> </section>
<section id="chat" aria-label="Chat"> <section id="chat" aria-label="Chat">
<Card padding="lg"> <Card>
{#key name}<ChatPanel server={name} />{/key} {#key name}<ChatPanel server={name} />{/key}
</Card> </Card>
</section> </section>
{:else if activeSection === 'configuration'} {:else if activeSection === 'configuration'}
<nav class="section-links" aria-label="Configuration sections"><a href="#config">Config</a><a href="#inventory">Inventory</a></nav>
<section id="config" aria-label="Config"> <section id="config" aria-label="Config">
{#key name}<ConfigInspector fixedServer={name} />{/key} {#key name}<ConfigInspector fixedServer={name} />{/key}
</section> </section>
@@ -327,48 +272,33 @@
<h2>Installed files</h2> <h2>Installed files</h2>
<span class="panel-count">{inventory.length}</span> <span class="panel-count">{inventory.length}</span>
</div> </div>
{#if tabLoading}<EmptyState title="Loading inventory…" />{:else if inventory.length === 0} {#if tabLoading}<EmptyState title="Loading inventory…" />{:else if inventory.length === 0 && !tabError}
<EmptyState <EmptyState
title="No files tracked yet" title="No files tracked yet"
description="Files appear here after scripts, configs, or plugins are deployed to this server." description="Files appear here after scripts, configs, or plugins are deployed to this server."
/> />
{:else} {:else}
<div class="inv-list"> <div class="table-scroll"><table class="detail-table"><thead><tr><th>File</th><th>Type</th><th>Recorded version</th><th>Details</th></tr></thead><tbody>
{#each inventory.slice(0,inventoryLimit) as f (f.relPath)} {#each inventory.slice(0, inventoryLimit) as f}
<div class="inv-row"> <tr><td>{#if f.artifactId}<a href={contextHref(`/${f.kind === 'plugin' ? 'plugins' : f.kind === 'config' || f.kind === 'baseline' ? 'configs' : 'scripts'}/${f.artifactId}`, $page.url)}>{f.relPath}</a>{:else}{f.relPath}{/if}</td><td>{f.kind ?? 'file'}</td><td>{f.releaseVersion ?? (f.version ? `v${f.version}` : '—')}</td><td><details><summary><span class="sr-only">{f.relPath} </span>Details</summary><dl class="file-details"><dt>Size</dt><dd>{new Intl.NumberFormat().format(f.size)} B</dd><dt>SHA-256</dt><dd><code>{f.sha256}</code></dd><dt>Recorded</dt><dd>{$timeFormat.dateTime(f.updatedAt)}</dd><dt>Source</dt><dd>{#if f.source?.startsWith('promote:') || f.source?.startsWith('rollback:')}<a href={contextHref(`/campaigns/${f.source.slice(f.source.indexOf(':') + 1)}`, $page.url)}>View campaign</a>{:else}{f.source === 'native-create' ? 'Instance creation' : f.source ?? 'Unknown'}{/if}</dd></dl></details></td></tr>
<span class="rel">{f.relPath}<span class="file-kind">{f.kind ?? (f.relPath.endsWith(".dll") ? "Plugin" : f.relPath.endsWith(".cfg") ? "Config" : "Script")}</span></span>
<span class="size">{f.size} B</span>
<span class="src">{f.source ?? '—'}</span>
<span class="sha">{f.sha256.slice(0, 12)}</span>
</div>
{/each} {/each}
</div> </tbody></table></div>
{/if} {/if}
</Card> </Card>
<ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" /> <ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" />
</section> </section>
{:else if activeSection === 'diagnostics'} {:else if activeSection === 'diagnostics'}
<nav class="section-links" aria-label="Diagnostic sections"><a href="#connection">Connection health</a><a href="#logs">Logs</a></nav>
<section id="connection" aria-label="Connection health"> <section id="connection" aria-label="Connection health">
<Card padding="lg"> <Card padding="lg">
<div class="panel-head"> <div class="panel-head">
<h2>RCON diagnostic</h2> <h2>Connection health</h2>
{#if effectiveHealth === 'provisioning'}
<Badge variant="info" uppercase>Provisioning</Badge>
{:else if effectiveHealth === 'starting'}
<Badge variant="info" uppercase>Waiting for container</Badge>
{:else if hasDiagnosticIssue}
<Badge variant="warning" uppercase>Issue</Badge>
{:else}
<Badge variant="online" uppercase>Healthy</Badge>
{/if}
</div> </div>
<div class="connection-status">
<p class="sub">Current player count: {server.status_source === 'telemetry' ? 'game telemetry' : server.status_source === 'rcon' ? 'RCON' : 'unavailable'}. RCON probes below show that connection's separate results.</p> {#if native}<div><strong>Host agent</strong><span>{server.host?.connected && freshStatusTimestamp(server.host.lastHeartbeat, $uiNow) ? 'Online' : 'Unavailable'}</span><small>{server.host?.lastHeartbeat ? `Last heartbeat ${ago(server.host.lastHeartbeat, $uiNow)}` : 'No heartbeat received'}</small>{#if server.host_id}<a href={contextHref(`/hosts/${server.host_id}`, $page.url)}>Inspect host</a>{/if}</div>{/if}
{#if native} <div><strong>Game connection · RCON</strong><span>{effectiveReason ? 'Issue' : server.rcon_status?.last_seen_at && freshStatusTimestamp(server.rcon_status.last_seen_at, $uiNow) ? 'Responding' : 'No fresh response'}</span><small>{server.rcon_status?.last_seen_at ? `Last response ${ago(server.rcon_status.last_seen_at, $uiNow)}` : 'No response received'}</small>{#if effectiveEndpoint || native}<Button size="sm" variant="ghost" onclick={probeRcon} disabled={probingRcon} loading={probingRcon}>Test connection</Button>{/if}</div>
<div class="diag-grid"><div class="kv"><span class="k">Game connection</span><span class="v">RCON runs locally through the host agent</span></div><div class="kv"><span class="k">Host agent</span><span class="v {server.host?.connected ? 'ok' : 'bad'}">{server.host?.connectionState ?? 'unknown'}</span></div><div class="kv"><span class="k">Last heartbeat</span><span class="v">{server.host?.lastHeartbeat ? ago(server.host.lastHeartbeat, $uiNow) : 'Not received'}</span></div></div> <div><strong>Game telemetry</strong><span>{fresh && freshTelemetry(scoreboard, $uiNow, server.telemetry_after) ? 'Reporting' : scoreboard ? 'Last report outdated' : 'No report'}</span><small>{scoreboard ? `Last report ${ago(scoreboard.receivedAt, $uiNow)}` : 'Waiting for game data'}</small><button class="text-button" onclick={() => telemetryOpen = !telemetryOpen} aria-expanded={telemetryOpen}>Inspect requests</button></div>
{/if} </div>
<p class="sub">Player count source: {fresh ? server.status_source === 'telemetry' ? 'game telemetry' : server.status_source === 'rcon' ? 'RCON' : 'unavailable' : 'unavailable'}.</p>
{#if effectiveReason} {#if effectiveReason}
<div class="alert alert-danger"> <div class="alert alert-danger">
<Badge variant="danger" uppercase>{effectiveReason}</Badge> <Badge variant="danger" uppercase>{effectiveReason}</Badge>
@@ -389,15 +319,6 @@
</details> </details>
{/if} {/if}
<div class="probe-actions">
{#if effectiveEndpoint || native}
<Button variant="secondary" size="sm" onclick={probeRcon} disabled={probingRcon} loading={probingRcon}>
{native ? 'Probe RCON through agent' : 'Probe RCON'}
</Button>
{/if}
{#if server.host_id}<Button variant="secondary" size="sm" href={contextHref(`/hosts/${server.host_id}`, $page.url)}>Inspect host</Button>{/if}
</div>
{#if rconProbe?.ok} {#if rconProbe?.ok}
<div class="alert alert-ok"> <div class="alert alert-ok">
<Badge variant="online" uppercase>Connected</Badge> <Badge variant="online" uppercase>Connected</Badge>
@@ -409,9 +330,8 @@
</div> </div>
{/if} {/if}
<div class="panel-head divider"> {#if rconProbe && !rconProbe.ok}<div class="ui-error" role="alert">Connection test failed. <details><summary>Technical details</summary>{rconProbe.detail ?? rconProbe.reason}</details></div>{/if}
<h2>Configuration</h2> <details class="connection-details"><summary>Connection details</summary>
</div>
<div class="diag-grid"> <div class="diag-grid">
{#if native}<div class="kv"><span class="k">Host</span><span class="v"><a href={contextHref(`/hosts/${server.host_id}`, $page.url)}>{server.host?.name ?? server.host_id}</a></span></div><div class="kv"><span class="k">Instance</span><span class="v mono">{server.native_instance_id}</span></div><div class="kv"><span class="k">Generation</span><span class="v mono">{server.instance_generation}</span></div><div class="kv"><span class="k">Game-file version</span><span class="v mono"><a href="/game-files">{server.game_library_id}</a></span></div>{#if server.network}<div class="kv"><span class="k">Local UDP binding</span><span class="v mono">{server.network.bindAddress}:{server.network.localPort}</span></div><div class="kv"><span class="k">Public / NAT endpoint</span><span class="v mono">{server.network.publicAddress || server.host?.publicAddress || server.host?.hostname || 'Not configured'}:{server.network.publicPort ?? server.network.localPort}</span></div>{/if} {#if native}<div class="kv"><span class="k">Host</span><span class="v"><a href={contextHref(`/hosts/${server.host_id}`, $page.url)}>{server.host?.name ?? server.host_id}</a></span></div><div class="kv"><span class="k">Instance</span><span class="v mono">{server.native_instance_id}</span></div><div class="kv"><span class="k">Generation</span><span class="v mono">{server.instance_generation}</span></div><div class="kv"><span class="k">Game-file version</span><span class="v mono"><a href="/game-files">{server.game_library_id}</a></span></div>{#if server.network}<div class="kv"><span class="k">Local UDP binding</span><span class="v mono">{server.network.bindAddress}:{server.network.localPort}</span></div><div class="kv"><span class="k">Public / NAT endpoint</span><span class="v mono">{server.network.publicAddress || server.host?.publicAddress || server.host?.hostname || 'Not configured'}:{server.network.publicPort ?? server.network.localPort}</span></div>{/if}
{/if} {/if}
@@ -422,8 +342,9 @@
<div class="kv"><span class="k">Held by beta</span><span class="v mono">{server.held.map((h) => `${h.target} (beta ${h.betaId})`).join(', ')}</span></div> <div class="kv"><span class="k">Held by beta</span><span class="v mono">{server.held.map((h) => `${h.target} (beta ${h.betaId})`).join(', ')}</span></div>
{/if} {/if}
</div> </div>
</details>
</Card> </Card>
<div id="telemetry">{#key name}<TelemetryDiagnostics server={name} />{/key}</div> <details class="telemetry-inspector" bind:open={telemetryOpen}><summary>Telemetry requests</summary>{#if telemetryOpen}<div id="telemetry">{#key name}<TelemetryDiagnostics server={name} />{/key}</div>{/if}</details>
</section> </section>
<section id="logs" aria-label="Logs"> <section id="logs" aria-label="Logs">
<Card padding="lg"> <Card padding="lg">
@@ -439,53 +360,13 @@
{:else if activeSection === 'matches'} {:else if activeSection === 'matches'}
{#key name}<MatchHistory server={name} />{/key} {#key name}<MatchHistory server={name} />{/key}
{:else if activeSection === 'history'} {:else if activeSection === 'history'}
<div class="history-links"><Button href={contextHref(`/provisioning?view=history&server=${encodeURIComponent(name)}`, $page.url)}>Provisioning history</Button><Button href={contextHref(`/hosts/operations?server=${encodeURIComponent(name)}`, $page.url)}>Host operation history</Button></div> <div class="history-links"><a href={contextHref(`/provisioning?view=history&server=${encodeURIComponent(name)}`, $page.url)}>Provisioning history</a><a href={contextHref(`/hosts/operations?server=${encodeURIComponent(name)}`, $page.url)}>Host operation history</a></div>
{#if operations.length} <Card>
<Card padding="lg"> <div class="panel-head"><h2>Activity</h2></div><p class="sub">Recent operations and campaign history</p>
<div class="panel-head"> {#if tabLoading}<p class="sub">Loading campaign history…</p>{/if}
<h2>Operations</h2> {#if activity.length}<div class="table-scroll"><table class="detail-table"><thead><tr><th>Time</th><th>Activity</th><th>Result</th><th>Source</th></tr></thead><tbody>
<span class="panel-count">{operations.length}</span> {#each activity as item (item.key)}<tr><td><time datetime={item.at} title={$timeFormat.dateTime(item.at)}>{ago(item.at, $uiNow)}</time></td><td>{item.label}{#if item.detail}<div class="ui-error">{item.detail}</div>{/if}</td><td><StatusBadge state={item.state} /></td><td><a href={contextHref(item.href, $page.url)}>{item.source}</a></td></tr>{/each}
</div> </tbody></table></div>{:else if !tabLoading && !tabError}<EmptyState title="No activity recorded" />{/if}
<div class="server-operations">
{#each operations as operation (operation.key)}
<a href={contextHref(operation.href, $page.url)}>
<strong>{operation.label}</strong>
<span>{operation.detail}</span>
<StatusBadge state={operation.state} />
</a>
{/each}
</div>
</Card>
{/if}
<Card padding="lg">
<div class="panel-head">
<h2>Campaign history</h2>
<span class="panel-count">{history.length}</span>
</div>
{#if tabLoading}<EmptyState title="Loading history…" />{:else if history.length === 0}
<EmptyState
title="No campaigns yet"
description="Nothing has been pushed to this server. Open a beta or push scripts to see history here."
/>
{:else}
<div class="history">
{#each history.slice(0,historyLimit) as d (d.id)}
{@const mine = d.servers.find((s) => s.server === name)!}
<a class="hist-row s-{mine.restart_state}" href={contextHref(`/campaigns/${d.id}`, $page.url)}>
<span class="hist-action">{d.action}</span>
<span class="hist-state">
<StatusBadge state={mine.restart_state} />
{#if mine.players !== null && mine.players > 0}
<span class="hist-players">{mine.players}p</span>
{/if}
</span>
<span class="hist-files">{mine.files_changed} file{mine.files_changed === 1 ? '' : 's'}</span>
<span class="hist-when">{ago(d.started_at, $uiNow)}</span>
{#if mine.error}<span class="hist-err">{mine.error}</span>{/if}
</a>
{/each}
</div>
{/if}
</Card> </Card>
<ListFooter total={historyTotal} bind:limit={historyLimit} onchange={() => loadTab(name, 'history')} label="campaigns" /> <ListFooter total={historyTotal} bind:limit={historyLimit} onchange={() => loadTab(name, 'history')} label="campaigns" />
{/if} {/if}
@@ -500,7 +381,6 @@
{/if} {/if}
<style> <style>
.section-links { display:flex; gap:20px; flex-wrap:wrap; font-size:var(--text-sm); }
.tab-panel > section { display:grid; gap:16px; min-width:0; scroll-margin-top:80px; } .tab-panel > section { display:grid; gap:16px; min-width:0; scroll-margin-top:80px; }
.history-links { display:flex; flex-wrap:wrap; gap:12px; } .history-links { display:flex; flex-wrap:wrap; gap:12px; }
.server-actions { text-align: right; } .server-actions { text-align: right; }
@@ -520,31 +400,6 @@
color: var(--text-tertiary); color: var(--text-tertiary);
} }
/* Hero metric grid */
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--space-6);
padding-top: var(--space-4);
border-top: 1px solid var(--border-subtle);
margin-top: var(--space-2);
}
.metric-cell {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.name-cell {
flex-direction: row;
align-items: center;
gap: var(--space-2);
}
.health-label {
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: var(--weight-medium);
}
.sub { .sub {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-tertiary); color: var(--text-tertiary);
@@ -558,7 +413,7 @@
} }
.tabs-row { .tabs-row {
display: flex; display: flex;
justify-content: center; justify-content: flex-start;
} }
/* Panel headers */ /* Panel headers */
@@ -569,11 +424,6 @@
gap: var(--space-3); gap: var(--space-3);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
.panel-head.divider {
margin-top: var(--space-6);
padding-top: var(--space-5);
border-top: 1px solid var(--border-subtle);
}
.panel-head h2 { .panel-head h2 {
margin: 0; margin: 0;
font-family: var(--font-body); font-family: var(--font-body);
@@ -618,8 +468,6 @@
word-break: break-word; word-break: break-word;
} }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.ok { color: var(--state-online); }
.bad { color: var(--state-danger); }
/* Inline alerts */ /* Inline alerts */
.alert { .alert {
@@ -650,12 +498,6 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.probe-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-4);
}
/* Snippet details */ /* Snippet details */
.snippet-details { .snippet-details {
@@ -685,61 +527,28 @@
word-break: break-all; word-break: break-all;
} }
/* Inventory */ .live-context { margin: 0; font-size: var(--text-sm); color: var(--text-secondary); }
.inv-list { .instance-header { display: grid; gap: 12px; }
display: flex; .instance-meta, .health { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: var(--text-sm); }
flex-direction: column; .health { gap: 8px; }
gap: var(--space-1); .instance-details { display: grid; gap: 8px; padding: 12px 0; }
} details > summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-sm); }
.inv-row { .server-actions { position: relative; }
display: grid; .action-menu { position: absolute; z-index: 5; right: 0; min-width: 220px; display: grid; gap: 8px; padding: 12px; background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); }
grid-template-columns: minmax(0, 1fr) 80px 140px 120px; .table-scroll { overflow-x: auto; }
gap: var(--space-3); .detail-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
padding: var(--space-2) var(--space-3); .detail-table th, .detail-table td { text-align: left; padding: 12px; border-bottom: 1px solid var(--border-subtle); vertical-align: top; }
border-radius: var(--radius-md); .detail-table th { color: var(--text-secondary); font-size: var(--text-xs); white-space: nowrap; }
border: 1px solid var(--border-subtle); .detail-table td { overflow-wrap: anywhere; }
font-size: var(--text-xs); .file-details { max-width: 360px; font-size: var(--text-xs); }
font-family: var(--font-mono); .file-details dt { color: var(--text-secondary); margin-top: 8px; }
align-items: center; .file-details dd { margin: 4px 0; overflow-wrap: anywhere; }
} .connection-status { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
.inv-row:hover { background: var(--surface-raised); } .connection-status > div { display: flex; flex-direction: column; align-items: start; gap: 8px; font-size: var(--text-sm); }
.inv-row .rel { color: var(--text-primary); word-break: break-all; } .connection-status small { color: var(--text-secondary); }
.inv-row .size, .connection-details { margin-top: 20px; }
.inv-row .src, .text-button { padding: 0; border: 0; color: var(--accent-primary); background: none; font: inherit; cursor: pointer; }
.inv-row .sha { color: var(--text-tertiary); font-variant-numeric: tabular-nums; } .telemetry-inspector > summary { margin-bottom: 12px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
/* History rows */ @media (max-width: 640px) { .panel-head, .alert { flex-wrap: wrap; } }
.history { display: flex; flex-direction: column; gap: var(--space-1); }
.hist-row {
display: grid;
grid-template-columns: minmax(0, 220px) auto 110px 100px 1fr;
gap: var(--space-3);
align-items: center;
padding: var(--space-3) var(--space-4);
text-decoration: none;
color: var(--text-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
transition: background-color var(--dur-fast) var(--ease-standard);
}
.hist-row:hover { background: var(--surface-raised); }
.hist-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.hist-action { color: var(--text-primary); font-weight: var(--weight-medium); font-size: var(--text-sm); }
.hist-state { display: inline-flex; align-items: center; gap: var(--space-2); }
.hist-players { color: var(--state-online); font-family: var(--font-mono); font-size: var(--text-xs); }
.hist-files, .hist-when { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
.hist-err { color: var(--state-danger); font-size: var(--text-xs); font-family: var(--font-mono); }
@media (max-width: 640px) {
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.panel-head, .alert { flex-wrap: wrap; }
.hist-row {
grid-template-columns: 1fr;
gap: var(--space-1);
}
}
.server-operations { display:grid; gap:8px; }
.server-operations a { display:flex; gap:12px; flex-wrap:wrap; align-items:center; text-decoration:none; color:var(--text-primary); padding:10px 0; }
.server-operations a span { color:var(--text-secondary); font-size:13px; }
.file-kind { display:block; color:var(--text-secondary); font:12px var(--font-body); margin-top:5px; }
</style> </style>
+27
View File
@@ -0,0 +1,27 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
const name = 't5_zombie_theater_1';
beforeEach(async () => {
vi.resetModules(); fixture = await newFixture([name]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
});
afterEach(async () => { await fixture.cleanup(); });
it('links only inventory whose exact content matches its library version', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const artifactId = Number(db().prepare("INSERT INTO scripts(name, target_rel_path, kind) VALUES ('utils', 'utils.dll', 'plugin')").run().lastInsertRowid);
const versionId = Number(db().prepare("INSERT INTO script_versions(script_id, version, release_version, sha256, size) VALUES (?, 2, 'v1.1.0', 'known', 10)").run(artifactId).lastInsertRowid);
const { recordScript } = await import('../../src/lib/server/fleet/inventory.js');
recordScript(name, 'utils.dll', versionId, 'known', 10, 'native-create');
recordScript(name, 'changed.dll', versionId, 'different', 12, 'native-create');
recordScript(name, 'unknown.gsc', null, 'unknown', 2, 'native-create');
const { GET } = await import('../../src/routes/api/servers/[name]/scripts/+server.js');
const response = await GET({ params: { name } });
expect(response.status).toBe(200);
const { scripts } = await response.json();
expect(scripts.find((item: { relPath: string }) => item.relPath === 'utils.dll')).toMatchObject({ artifactId, version: 2, releaseVersion: 'v1.1.0', kind: 'plugin' });
expect(scripts.find((item: { relPath: string }) => item.relPath === 'changed.dll')).toMatchObject({ artifactId: null, version: null, releaseVersion: null });
expect(scripts.find((item: { relPath: string }) => item.relPath === 'unknown.gsc')).toMatchObject({ artifactId: null, version: null, kind: 'script' });
});
+25
View File
@@ -184,3 +184,28 @@ describe('log refresh controls', () => {
expect(onLoading).not.toHaveBeenCalled(); expect(onLoading).not.toHaveBeenCalled();
}); });
}); });
it('keeps the last tail on a non-JSON gateway failure and can recover with a retry', async () => {
const { fetcher, onData, onError } = setup();
fetcher.mockResolvedValueOnce(response('saved'))
.mockResolvedValueOnce(new Response('<html>Bad Gateway</html>', { status: 502 }))
.mockResolvedValueOnce(response('recovered'));
controller.configure(target, false, 5000);
await controller.refresh();
await controller.refresh();
expect(onError).toHaveBeenLastCalledWith('HTTP 502: Expected a JSON log response.');
expect(onData).toHaveBeenCalledTimes(1);
expect(onData).toHaveBeenLastCalledWith('console', expect.objectContaining({ lines: ['saved'] }));
await controller.refresh();
expect(onError).toHaveBeenLastCalledWith('');
expect(onData).toHaveBeenLastCalledWith('console', expect.objectContaining({ lines: ['recovered'] }));
});
it('rejects malformed successful log payloads before they reach the renderer', async () => {
const { fetcher, onData, onError } = setup();
fetcher.mockResolvedValue(new Response(JSON.stringify({ ok: true, lines: [42] })));
controller.configure(target, false, 5000);
await controller.refresh();
expect(onData).not.toHaveBeenCalled();
expect(onError).toHaveBeenLastCalledWith('Invalid log response: expected an array of lines.');
});
+98
View File
@@ -0,0 +1,98 @@
import { expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import { get } from 'svelte/store';
import type { ComponentProps } from 'svelte';
import type { GameTelemetry } from '../../src/lib/telemetry.js';
import type { FleetOperation } from '../../src/lib/fleet-overview.js';
import ServerPage from '../../src/routes/servers/[name]/+page.svelte';
import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte';
import ConfigInspector from '../../src/lib/ui/components/ConfigInspector.svelte';
import { uiNow } from '../../src/lib/ui/stores/servers.js';
import { instanceActivity } from '../../src/lib/ui/server-activity.js';
import { gameEvent } from '../helpers/telemetry.js';
import { page } from '$app/stores';
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/servers/t5_zombie_theater_1') } }));
vi.mock('$app/stores', () => ({ page: writable({ params: { name: 't5_zombie_theater_1' }, url: new URL('http://localhost/servers/t5_zombie_theater_1') }) }));
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), afterNavigate: vi.fn(), goto: vi.fn() }));
const report = (current = true): GameTelemetry => {
const receivedAt = new Date(get(uiNow) - (current ? 10_000 : 300_000)).toISOString();
const joined = new Date(Date.parse(receivedAt) - 60_000).toISOString();
return { ...gameEvent('heartbeat', 4, 1), receivedAt, timing: { matchId: 1, players: {
'12345678901234560': { serverJoinedAt: joined, matchJoinedAt: joined, roundStarted: 1 }
} } };
};
it('keeps scoreboard columns focused and collapses player identifiers and timings', () => {
const body = render(PlayerScoreboard, { props: { report: report(), matchElapsed: '4m 19s' } }).body;
expect(body).toContain('Live scoreboard');
expect(body).toContain('Round 4');
expect(body).toContain('Match 4m 19s');
expect(body).toContain('aria-expanded="false"');
expect(body).toMatch(/<tr[^>]*hidden/);
expect(body).toContain('Time on instance: 1m 10s');
expect(body).toContain('aria-label="Copy GUID for Player 1"');
expect(body).not.toContain('>Time on server</th>');
expect(body).not.toContain('>Round started</th>');
expect(body).toContain('aria-sort="descending"');
expect(body).toContain('About these stats');
});
it('freezes historical timing and keeps unreported scores distinct from zero', () => {
const body = render(PlayerScoreboard, { props: { report: report(false) } }).body;
expect(body).toContain('Last reported scoreboard');
expect(body).toContain('in last report');
expect(body).toContain('Time on instance: 1m 00s');
expect(body).not.toContain('Time on instance: 6m');
const start = { ...gameEvent('game_start', 0, 1), receivedAt: report().receivedAt };
expect(render(PlayerScoreboard, { props: { report: start } }).body).toContain('aria-label="Not yet reported"');
});
it('omits the fixed instance selector while keeping promoted/live distinction', () => {
const body = render(ConfigInspector, { props: { fixedServer: 't5_zombie_theater_1' } }).body;
expect(body).not.toContain('<select');
expect(body).toContain('Promoted configuration');
expect(body).toContain('Live files and active betas may differ');
});
const op = (changes: Partial<FleetOperation> = {}): FleetOperation => ({ key: 'deployment:1', group: 'deployment:1', server: 'one', kind: 'deployment', label: 'Complete', activityLabel: 'Restart instance', resultState: 'cancelled', detail: 'Checking game status', href: '/provisioning/1', state: 'done', startedAt: '2026-10-06T12:00:00Z', ...changes });
it('merges duplicate campaign records, retains provisioning and preserves the real outcome', () => {
const rows = instanceActivity('one', [op(), op({ key: 'campaign:x:one', kind: 'campaign', href: '/campaigns/x' }), op({ key: 'other', server: 'two' })], [{
id: 'x', action: 'promote', started_at: '2026-10-06T13:00:00Z', servers: [{ server: 'one', restart_state: 'failed', error: 'Restart failed', files_changed: 1 }]
}]);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ label: 'Promote files · 1 file', state: 'failed', detail: 'Restart failed' });
expect(rows[1]).toMatchObject({ label: 'Restart instance', state: 'cancelled', href: '/provisioning/1' });
});
function pageBody(tab: string) {
(page as unknown as ReturnType<typeof writable>).set({ params: { name: 't5_zombie_theater_1' }, url: new URL(`http://localhost/servers/t5_zombie_theater_1?tab=${tab}&returnTo=%2F%3Ffilter%3Doccupied`) });
const telemetry = report();
const server = { name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', health: 'online', last_players: 1,
last_seen_at: telemetry.receivedAt, last_error: null, held: [], telemetry, status_source: 'telemetry', host_id: 'host-1', native_instance_id: 'instance-1',
host: { name: 'ec-1', connected: true, lastHeartbeat: telemetry.receivedAt },
rcon_status: { last_seen_at: null, last_error: 'timeout: no reply' }, game_started_at: null, game_ended_at: null, active_since: null, inactive_since: null };
const data = { serverSnapshot: { servers: [server], operations: [], serverNow: get(uiNow), integrations: {}, reservedNames: [] } } as unknown as ComponentProps<typeof ServerPage>['data'];
return render(ServerPage, { props: { data } }).body;
}
it('keeps match content out of the shared header and preserves the fleet return filter', () => {
const body = pageBody('configuration');
expect(body).toContain('Instance actions');
expect(body).toContain('href="/?filter=occupied"');
expect(body).toContain('ec-1');
expect(body).not.toContain('Active for');
expect(body).not.toContain('Round 4');
expect(body).not.toContain('aria-label="Configuration sections"');
});
it('reports RCON failures independently of fresh telemetry and defers the request inspector', () => {
const body = pageBody('diagnostics');
expect(body).toContain('Game connection · RCON');
expect(body).toContain('Issue');
expect(body).toContain('Reporting');
expect(body).toContain('Player count source: game telemetry');
expect(body).toContain('Connection details');
expect(body).not.toContain('Telemetry API requests');
expect(body).not.toContain('>Healthy<');
});