199 lines
9.1 KiB
Svelte
199 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { TELEMETRY_REQUEST_RETENTION, type TelemetryRequestLogEntry } from '$lib/telemetry-diagnostics.js';
|
|
import { pollVisible } from '$ui/poll.js';
|
|
import Card from './primitives/Card.svelte';
|
|
import Badge from './primitives/Badge.svelte';
|
|
import Button from './primitives/Button.svelte';
|
|
import EmptyState from './primitives/EmptyState.svelte';
|
|
import ListFooter from './primitives/ListFooter.svelte';
|
|
|
|
let { server }: { server: string } = $props();
|
|
let requests = $state<TelemetryRequestLogEntry[]>([]);
|
|
let retention = $state<{ maxPerServer: number; maxAgeDays: number }>({ ...TELEMETRY_REQUEST_RETENTION });
|
|
let refreshing = $state(false);
|
|
let loaded = $state(false);
|
|
let error = $state('');
|
|
let refreshedAt = $state<string | null>(null);
|
|
let limit = $state(25);
|
|
let active = false;
|
|
let controller: AbortController | null = null;
|
|
|
|
async function refresh(): Promise<void> {
|
|
if (!active || refreshing) return;
|
|
const currentServer = server;
|
|
const requestController = new AbortController();
|
|
controller = requestController;
|
|
refreshing = true;
|
|
try {
|
|
const response = await fetch(`/api/servers/${encodeURIComponent(currentServer)}/telemetry-requests`, {
|
|
cache: 'no-store',
|
|
signal: requestController.signal
|
|
});
|
|
const body = await response.json().catch(() => null);
|
|
if (!response.ok || !body?.ok || !Array.isArray(body.requests)) {
|
|
throw new Error(body?.error || `Could not load telemetry requests (HTTP ${response.status}).`);
|
|
}
|
|
if (!active || server !== currentServer) return;
|
|
requests = body.requests;
|
|
retention = body.retention;
|
|
loaded = true;
|
|
error = '';
|
|
refreshedAt = new Date().toISOString();
|
|
} catch (cause) {
|
|
if (active && server === currentServer && !requestController.signal.aborted) {
|
|
error = cause instanceof Error ? cause.message : 'Could not load telemetry requests.';
|
|
}
|
|
} finally {
|
|
if (active && server === currentServer) refreshing = false;
|
|
if (controller === requestController) controller = null;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
active = true;
|
|
const stopPolling = pollVisible(refresh, 5000);
|
|
return () => {
|
|
active = false;
|
|
stopPolling();
|
|
controller?.abort();
|
|
};
|
|
});
|
|
|
|
function timestamp(value: string): string {
|
|
return new Date(value).toLocaleString(undefined, {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short'
|
|
});
|
|
}
|
|
|
|
function outcome(status: number): string {
|
|
return status >= 500 ? 'Failed' : status >= 400 ? 'Rejected' : 'Accepted';
|
|
}
|
|
</script>
|
|
|
|
<Card padding="lg" as="section" ariaLabel="Telemetry API requests">
|
|
<div class="panel-head">
|
|
<div>
|
|
<h2>Telemetry API requests</h2>
|
|
<p class="server-name"><code>{server}</code></p>
|
|
</div>
|
|
<Button size="sm" onclick={refresh} disabled={refreshing} loading={refreshing}>Refresh</Button>
|
|
</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 · up to {retention.maxPerServer} requests from the last {retention.maxAgeDays} days.
|
|
Refreshes every 5 seconds while visible.
|
|
{#if refreshedAt}<span>Last refreshed <time datetime={refreshedAt}>{timestamp(refreshedAt)}</time>.</span>{/if}
|
|
</p>
|
|
|
|
{#if error}
|
|
<p class="load-error" role="alert">
|
|
{error}{#if loaded} Previously loaded requests are shown below.{/if}
|
|
</p>
|
|
{/if}
|
|
|
|
{#if !loaded && !error}
|
|
<EmptyState title="Loading telemetry requests…" />
|
|
{:else if loaded && requests.length === 0}
|
|
<EmptyState
|
|
title="No telemetry requests recorded"
|
|
description="No matching requests are retained for this server. An empty log alone does not establish a network problem."
|
|
/>
|
|
{:else if requests.length > 0}
|
|
<ol class="request-list" aria-label="Recent telemetry requests">
|
|
{#each requests.slice(0, limit) as request (request.id)}
|
|
<li class="request">
|
|
<div class="request-head">
|
|
<div class="request-result">
|
|
<Badge variant={request.status >= 500 ? 'danger' : request.status >= 400 ? 'warning' : 'online'}>
|
|
{outcome(request.status)} · HTTP {request.status}
|
|
</Badge>
|
|
<code class="event">{request.event ?? 'Event not provided'}</code>
|
|
</div>
|
|
<time class="received" datetime={request.receivedAt}>{timestamp(request.receivedAt)}</time>
|
|
</div>
|
|
<dl class="request-meta">
|
|
<div><dt>Map</dt><dd><code>{request.map ?? 'Not provided'}</code></dd></div>
|
|
<div><dt>Round</dt><dd>{request.round ?? 'Not provided'}</dd></div>
|
|
<div><dt>Players</dt><dd>{request.playerCount ?? 'Not provided'}</dd></div>
|
|
</dl>
|
|
{#if request.error}<p class="request-error">{request.error}</p>{/if}
|
|
<details class="payload">
|
|
<summary>Request payload{#if request.payloadTruncated} (truncated){/if}</summary>
|
|
<p class="content-type">Content type: <code>{request.contentType ?? 'Not provided'}</code></p>
|
|
<pre>{request.payload}</pre>
|
|
{#if request.payloadTruncated}<p class="truncation">Only the beginning of this payload was retained.</p>{/if}
|
|
</details>
|
|
</li>
|
|
{/each}
|
|
</ol>
|
|
<ListFooter total={requests.length} bind:limit label="requests" />
|
|
{/if}
|
|
|
|
<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.
|
|
Calls that never reach Electric Cherry, malformed JSON, and different server names will not appear here.
|
|
</p>
|
|
</Card>
|
|
|
|
<style>
|
|
.panel-head, .request-head, .request-result {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-3);
|
|
flex-wrap: wrap;
|
|
}
|
|
.panel-head { margin-bottom: var(--space-4); }
|
|
h2 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); }
|
|
p { margin: 0; }
|
|
code, pre { font-family: var(--font-mono); }
|
|
.server-name { margin-top: var(--space-2); overflow-wrap: anywhere; color: var(--text-secondary); font-size: var(--text-xs); }
|
|
.intro { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
|
|
.retention, .attribution, .content-type, .truncation {
|
|
font-size: var(--text-xs);
|
|
color: var(--text-tertiary);
|
|
line-height: 1.6;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.retention { margin-top: var(--space-2); }
|
|
.retention span { display: block; }
|
|
.attribution { margin-top: var(--space-4); }
|
|
.request-list { list-style: none; padding: 0; margin: var(--space-5) 0 0; }
|
|
.request { padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); }
|
|
.request-result { justify-content: flex-start; min-width: 0; }
|
|
.event { font-size: var(--text-sm); overflow-wrap: anywhere; }
|
|
.received { color: var(--text-tertiary); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
|
.request-meta { display: flex; flex-wrap: wrap; gap: var(--space-3) var(--space-6); margin: var(--space-3) 0; font-size: var(--text-xs); }
|
|
.request-meta div { display: flex; gap: var(--space-2); min-width: 0; }
|
|
dt { color: var(--text-tertiary); }
|
|
dd { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; }
|
|
.load-error, .request-error { color: var(--state-danger); font-size: var(--text-sm); overflow-wrap: anywhere; }
|
|
.load-error { margin-top: var(--space-4); }
|
|
.request-error { margin-bottom: var(--space-3); }
|
|
.payload { font-size: var(--text-xs); }
|
|
summary { color: var(--text-secondary); cursor: pointer; width: fit-content; }
|
|
summary:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
|
|
.content-type { margin-top: var(--space-3); }
|
|
pre {
|
|
margin: var(--space-2) 0 0;
|
|
padding: var(--space-3);
|
|
max-height: 24rem;
|
|
overflow: auto;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
background: var(--surface-sunken);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-secondary);
|
|
line-height: 1.6;
|
|
}
|
|
.truncation { margin-top: var(--space-2); }
|
|
</style>
|