Implement radar-telemt-dcs aggregation endpoint and UI integration
- 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:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user