335 lines
11 KiB
Svelte
335 lines
11 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 { serverTitle } from '$ui/server-labels.js';
|
||
import Card from './primitives/Card.svelte';
|
||
import Badge from './primitives/Badge.svelte';
|
||
import Freshness from './Freshness.svelte';
|
||
import RelativeTime from './RelativeTime.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<number | null>(null);
|
||
let limit = $state(25);
|
||
let active = false;
|
||
let controller: AbortController | null = null;
|
||
|
||
async function refresh(signal?: AbortSignal): Promise<void | false> {
|
||
if (!active || refreshing) return;
|
||
const currentServer = server;
|
||
const requestController = new AbortController();
|
||
const abort = () => requestController.abort();
|
||
signal?.addEventListener('abort', abort, { once: true });
|
||
if (signal?.aborted) requestController.abort();
|
||
controller = requestController;
|
||
refreshing = true;
|
||
try {
|
||
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 || requestController.signal.aborted) return;
|
||
requests = body.requests;
|
||
retention = body.retention;
|
||
loaded = true;
|
||
error = '';
|
||
refreshedAt = Date.now();
|
||
} catch (cause) {
|
||
if (active && server === currentServer && !requestController.signal.aborted) {
|
||
error =
|
||
cause instanceof Error ? cause.message : 'Could not load telemetry requests.';
|
||
}
|
||
return false;
|
||
} finally {
|
||
signal?.removeEventListener('abort', abort);
|
||
if (active && server === currentServer) refreshing = false;
|
||
if (controller === requestController) controller = null;
|
||
}
|
||
}
|
||
|
||
onMount(() => {
|
||
active = true;
|
||
const stopPolling = pollVisible(refresh, 5000);
|
||
return () => {
|
||
active = false;
|
||
stopPolling();
|
||
controller?.abort();
|
||
};
|
||
});
|
||
|
||
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">{serverTitle(server)}</p>
|
||
</div>
|
||
<Freshness
|
||
updatedAt={refreshedAt}
|
||
error={error ? 'Telemetry request updates interrupted.' : ''}
|
||
intervalLabel="Updates every 5 seconds"
|
||
onRetry={() => void refresh()}
|
||
retryDisabled={refreshing}
|
||
/>
|
||
</div>
|
||
|
||
{#if error}
|
||
<p class="load-error" role="alert">
|
||
Couldn’t load telemetry requests. {#if loaded}
|
||
Previously loaded requests are shown below.{/if}
|
||
</p>
|
||
<details class="payload"><summary>Technical details</summary>{error}</details>
|
||
{/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>
|
||
<span class="received"><RelativeTime value={request.receivedAt} /></span>
|
||
</div>
|
||
<dl class="request-meta">
|
||
<div>
|
||
<dt>Map</dt>
|
||
<dd><code>{request.map ?? 'Not provided'}</code></dd>
|
||
</div>
|
||
<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}
|
||
|
||
<details class="request-help">
|
||
<summary>About request logging</summary>
|
||
<p class="intro">
|
||
Incoming <code>POST /api/telemetry</code> requests with the exact server name
|
||
<code>{server}</code>. Fresh game telemetry supplies player counts when available; RCON
|
||
provides a fallback. The scoreboard uses telemetry.
|
||
</p>
|
||
<p class="retention">
|
||
Newest first · {retention.maxPerServer
|
||
? `up to ${retention.maxPerServer} requests per server`
|
||
: 'no per-server retention cap'} · {retention.maxAgeDays
|
||
? `last ${retention.maxAgeDays} days`
|
||
: 'no age expiry'}. Fleet-wide limits also apply.
|
||
</p>
|
||
|
||
<p class="attribution">
|
||
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>
|
||
</details>
|
||
</Card>
|
||
|
||
<style>
|
||
.request-help {
|
||
margin-top: 16px;
|
||
font-size: var(--text-xs);
|
||
}
|
||
.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);
|
||
}
|
||
.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>
|