Files
electric-cherry/src/lib/ui/components/TelemetryDiagnostics.svelte
T

335 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>