Implement radar-telemt-dcs aggregation endpoint and UI integration
Publish telemt-api gateway Docker image / test (push) Successful in 9s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 1m53s

- Added new API route `/api/agg/radar-telemt-dcs` to aggregate DC status data from multiple upstreams, including metrics like coverage percentage and RTT.
- Implemented handler logic in `handlers.go` and corresponding tests in `handlers_test.go` to ensure correct data retrieval and response formatting.
- Updated the frontend to fetch and display radar DC data, enhancing the user interface with a new section for Telemt ME snapshots.
- Enhanced documentation in `AGGREGATE.md` and `README.md` to reflect the new functionality and usage details.
This commit is contained in:
Denozordec
2026-04-12 13:07:14 +07:00
parent d021e4b1d7
commit ea2ecb44d2
8 changed files with 357 additions and 9 deletions
+44
View File
@@ -180,6 +180,50 @@ export async function fetchAggFleetStatus(params?: { aliases?: string }): Promis
return body as AggEnvelope<components['schemas']['FleetStatusData']>;
}
/** Payload GET /api/agg/radar-telemt-dcs (снимок GET /v1/stats/dcs с каждой ноды). */
export type AggRadarTelemtDcsRow = {
alias: string;
ok: boolean;
http_status?: number;
latency_ms?: number;
error?: string;
revision?: string;
data?: {
middle_proxy_enabled: boolean;
reason?: string | null;
generated_at_epoch_secs?: number;
dcs?: {
dc: number;
rtt_ms?: number | null;
coverage_pct: number;
alive_writers: number;
required_writers: number;
load: number;
}[];
};
};
export type AggRadarTelemtDcsData = {
servers: AggRadarTelemtDcsRow[];
};
export async function fetchAggRadarTelemtDcs(params?: {
aliases?: string;
}): Promise<AggEnvelope<AggRadarTelemtDcsData>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
const url = `${gatewayBase()}/api/agg/radar-telemt-dcs${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) {
throw new ApiError(`radar-telemt-dcs HTTP ${res.status}`, res.status, body);
}
if (!body || body.ok !== true) {
throw new ApiError('radar-telemt-dcs: ok !== true', res.status, body);
}
return body as AggEnvelope<AggRadarTelemtDcsData>;
}
export async function fetchAggUniqueIps(params?: {
aliases?: string;
geo?: boolean;
+152 -8
View File
@@ -1,6 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchRadarPingDC, fetchRadarStatuses, type RadarPingRow } from '$lib/api/client.js';
import {
fetchAggRadarTelemtDcs,
fetchRadarPingDC,
fetchRadarStatuses,
type AggRadarTelemtDcsRow,
type RadarPingRow
} from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
@@ -50,6 +56,25 @@
});
let pingFrom = $state<string | null>(null);
let telemtLoading = $state(false);
let telemtErr = $state<string | null>(null);
let telemtPartial = $state(false);
let telemtUpdated = $state<string | null>(null);
let telemtServers = $state<AggRadarTelemtDcsRow[]>([]);
const telemtDcNums = [1, 2, 3, 4, 5] as const;
function dcRowFind(row: AggRadarTelemtDcsRow, dc: number) {
return row.data?.dcs?.find((d) => d.dc === dc);
}
function telemtCoverageClass(cov: number | undefined): string {
if (cov === undefined) return 'text-muted-foreground';
if (cov >= 90) return 'text-emerald-600 font-medium';
if (cov >= 50) return 'text-amber-600 font-medium';
return 'text-red-600 font-medium';
}
function buildMatrix(data: unknown[]) {
const regions: Record<string, { ok: number; total: number }> = {
SE: { ok: 0, total: 0 },
@@ -190,12 +215,33 @@
return { text: `${ms} мс`, barPct: Math.min(ms / 3, 100), color };
}
async function loadTelemtDcs() {
telemtLoading = true;
telemtErr = null;
try {
const env = await fetchAggRadarTelemtDcs();
telemtServers = env.data.servers ?? [];
telemtPartial = !!env.partial;
telemtUpdated = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
} catch (e) {
telemtErr = e instanceof Error ? e.message : String(e);
telemtServers = [];
telemtPartial = false;
} finally {
telemtLoading = false;
}
}
async function refreshRadarAndTelemt() {
await Promise.all([loadRadar(), loadTelemtDcs()]);
}
onMount(() => {
void loadRadar();
void refreshRadarAndTelemt();
});
</script>
<div class="mx-auto flex max-w-5xl flex-col gap-6">
<div class="flex w-full flex-col gap-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<RadarIcon class="text-primary size-8" />
@@ -209,8 +255,15 @@
</p>
</div>
</div>
<Button variant="outline" size="sm" disabled={radarLoading} onclick={() => loadRadar()}>
<RefreshCwIcon class="mr-1 size-4 {radarLoading ? 'animate-spin' : ''}" />
<Button
variant="outline"
size="sm"
disabled={radarLoading || telemtLoading}
onclick={() => void refreshRadarAndTelemt()}
>
<RefreshCwIcon
class="mr-1 size-4 {radarLoading || telemtLoading ? 'animate-spin' : ''}"
/>
Обновить
</Button>
</div>
@@ -265,6 +318,96 @@
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Telemt ME — снимок по нодам</Card.Title>
<Card.Description>
<code class="text-xs">GET /api/agg/radar-telemt-dcs</code> — параллельно
<code class="text-xs">GET /v1/stats/dcs</code> на каждом upstream (как на странице «Состояние» ноды).
Требуется включённый minimal runtime API на Telemt; иначе в ячейках будет причина отключения.
{#if telemtUpdated}
<span class="text-foreground"> · обновлено {telemtUpdated}</span>
{/if}
</Card.Description>
</Card.Header>
<Card.Content>
{#if telemtErr}
<Alert variant="destructive">
<AlertTitle>Ошибка загрузки stats/dcs</AlertTitle>
<AlertDescription>{telemtErr}</AlertDescription>
</Alert>
{:else if telemtLoading && telemtServers.length === 0}
<p class="text-muted-foreground text-sm">Загрузка…</p>
{:else if telemtServers.length === 0}
<p class="text-muted-foreground text-sm">Нет серверов в конфигурации шлюза.</p>
{:else}
{#if telemtPartial}
<Alert class="mb-4">
<InfoIcon class="size-4" />
<AlertTitle class="text-sm">Частичные данные</AlertTitle>
<AlertDescription class="text-xs">
Не все ноды ответили успешно — смотрите ошибки в строках.
</AlertDescription>
</Alert>
{/if}
<div class="overflow-x-auto rounded-md border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-28">Нода</Table.Head>
{#each telemtDcNums as dc (dc)}
<Table.Head class="min-w-[5.5rem] text-center">DC{dc}</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each telemtServers as srv (srv.alias)}
<Table.Row>
<Table.Cell class="font-mono text-sm font-medium">{srv.alias}</Table.Cell>
{#each telemtDcNums as dc (dc)}
<Table.Cell class="align-top text-center text-sm">
{#if !srv.ok}
<span class="text-destructive text-xs leading-tight" title={srv.error ?? ''}
>ошибка</span>
{:else if !srv.data?.middle_proxy_enabled}
<span
class="text-muted-foreground text-xs leading-tight"
title={srv.data?.reason ?? ''}
>
{srv.data?.reason === 'feature_disabled'
? 'ME API off'
: (srv.data?.reason ?? 'нет данных')}
</span>
{:else}
{@const cell = dcRowFind(srv, dc)}
{#if cell}
<div class={telemtCoverageClass(cell.coverage_pct)}>
{cell.coverage_pct.toFixed(0)}%
</div>
{#if cell.rtt_ms != null && cell.rtt_ms !== undefined}
<div class="text-muted-foreground mt-0.5 text-xs tabular-nums">
{Number(cell.rtt_ms).toFixed(0)} ms
</div>
{/if}
{:else}
<span class="text-muted-foreground"></span>
{/if}
{/if}
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<p class="text-muted-foreground mt-3 text-xs">
Покрытие — доля alive writers к required для DC (Telemt). Это не сырой TCP-пинг с интернета, а
состояние middle proxy на процессе Telemt.
</p>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 text-base">
@@ -272,9 +415,10 @@
Диагностика TCP с хоста шлюза
</Card.Title>
<Card.Description>
Запрос <code class="text-xs">/api/radar/ping-dc</code> — TCP :443 до каждого DC с таймаутом 2 с
(как <code class="text-xs">ping_proxy.php</code>). Поле «from» в ответе: источник метки на
сервере.
Запрос <code class="text-xs">/api/radar/ping-dc</code> выполняется на <strong>процессе шлюза</strong>
(не на каждой ноде Telemt): TCP :443 до каждого DC, таймаут 2 с (как
<code class="text-xs">ping_proxy.php</code>). Для вида «с каждой ноды» используйте таблицу ME выше.
Поле «from» в ответе — метка источника.
{#if pingFrom}
<span class="mt-1 block text-foreground">from: {pingFrom}</span>
{/if}