|
|
|
@@ -0,0 +1,348 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { onMount } from 'svelte';
|
|
|
|
|
import { fetchRadarPingDC, fetchRadarStatuses, 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';
|
|
|
|
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
|
|
|
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
|
|
|
|
import RadarIcon from '@lucide/svelte/icons/radar';
|
|
|
|
|
import ActivityIcon from '@lucide/svelte/icons/activity';
|
|
|
|
|
import InfoIcon from '@lucide/svelte/icons/info';
|
|
|
|
|
|
|
|
|
|
type RadarEp = {
|
|
|
|
|
group?: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
results?: { success?: boolean }[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const dcs = ['DC1', 'DC2', 'DC3', 'DC4', 'DC5'] as const;
|
|
|
|
|
const locs: Record<(typeof dcs)[number], string> = {
|
|
|
|
|
DC1: '🇺🇸 США',
|
|
|
|
|
DC2: '🇳🇱 NL',
|
|
|
|
|
DC3: '🇺🇸 США',
|
|
|
|
|
DC4: '🇳🇱 NL',
|
|
|
|
|
DC5: '🇸🇬 SG'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const legendRows = [
|
|
|
|
|
{ dc: 'DC1', loc: '🇺🇸 США, Майами', desc: 'Основной для пользователей Америки. Сообщения, звонки.' },
|
|
|
|
|
{ dc: 'DC2', loc: '🇳🇱 Нидерланды', desc: 'Основной для большинства RU/EU. Сообщения, файлы, медиа.' },
|
|
|
|
|
{ dc: 'DC3', loc: '🇺🇸 США, Майами', desc: 'Резервный для DC1. Медиафайлы и CDN.' },
|
|
|
|
|
{ dc: 'DC4', loc: '🇳🇱 Нидерланды', desc: 'Резервный для DC2. Медиа и файлы EU пользователей.' },
|
|
|
|
|
{ dc: 'DC5', loc: '🇸🇬 Сингапур', desc: 'Основной для пользователей Азии и Океании.' },
|
|
|
|
|
{ dc: 'DC203', loc: '🌐 CDN', desc: 'Специальный CDN узел для ускорения загрузки медиа.' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let radarLoading = $state(false);
|
|
|
|
|
let radarErr = $state<string | null>(null);
|
|
|
|
|
let radarUpdated = $state<string | null>(null);
|
|
|
|
|
let summaryHtml = $state<string>('');
|
|
|
|
|
let matrixHtml = $state<string>('');
|
|
|
|
|
|
|
|
|
|
let diagLoading = $state(false);
|
|
|
|
|
let pingByDc = $state<Record<number, RadarPingRow | null>>({
|
|
|
|
|
1: null,
|
|
|
|
|
2: null,
|
|
|
|
|
3: null,
|
|
|
|
|
4: null,
|
|
|
|
|
5: null
|
|
|
|
|
});
|
|
|
|
|
let pingFrom = $state<string | null>(null);
|
|
|
|
|
|
|
|
|
|
function buildMatrix(data: unknown[]) {
|
|
|
|
|
const regions: Record<string, { ok: number; total: number }> = {
|
|
|
|
|
SE: { ok: 0, total: 0 },
|
|
|
|
|
DE: { ok: 0, total: 0 },
|
|
|
|
|
RU: { ok: 0, total: 0 }
|
|
|
|
|
};
|
|
|
|
|
const matrix: Record<string, Record<string, boolean | null>> = {
|
|
|
|
|
SE: {},
|
|
|
|
|
DE: {},
|
|
|
|
|
RU: {}
|
|
|
|
|
};
|
|
|
|
|
const ruSeen: Record<string, boolean> = {};
|
|
|
|
|
|
|
|
|
|
for (const raw of data) {
|
|
|
|
|
const ep = raw as RadarEp;
|
|
|
|
|
const g = ep.group ?? '';
|
|
|
|
|
const name = ep.name ?? '';
|
|
|
|
|
const results = ep.results;
|
|
|
|
|
const last =
|
|
|
|
|
results && results.length > 0 ? results[results.length - 1] : null;
|
|
|
|
|
const ok = last ? last.success : null;
|
|
|
|
|
|
|
|
|
|
if (!g.includes('TCP 443 IPv4') || name.includes('IPv6')) continue;
|
|
|
|
|
|
|
|
|
|
const dcM = name.match(/DC(\d+)/);
|
|
|
|
|
if (!dcM) continue;
|
|
|
|
|
const dcNum = 'DC' + dcM[1];
|
|
|
|
|
|
|
|
|
|
let region: string | null = null;
|
|
|
|
|
if (g.includes('from SE')) region = 'SE';
|
|
|
|
|
else if (g.includes('from DE')) region = 'DE';
|
|
|
|
|
else if (g.includes('from RU')) region = 'RU';
|
|
|
|
|
if (!region) continue;
|
|
|
|
|
|
|
|
|
|
if (region === 'RU') {
|
|
|
|
|
if (ruSeen[dcNum]) continue;
|
|
|
|
|
ruSeen[dcNum] = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
matrix[region][dcNum] = ok ?? null;
|
|
|
|
|
regions[region].total++;
|
|
|
|
|
if (ok) regions[region].ok++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const info: Record<string, { flag: string; name: string; note: string }> = {
|
|
|
|
|
SE: {
|
|
|
|
|
flag: '🇸🇪',
|
|
|
|
|
name: 'Швеция',
|
|
|
|
|
note: 'Референс из мониторинга radar.telemt.top (from SE)'
|
|
|
|
|
},
|
|
|
|
|
DE: { flag: '🇩🇪', name: 'Германия', note: 'Для сравнения (from DE)' },
|
|
|
|
|
RU: { flag: '🇷🇺', name: 'Россия', note: 'Напрямую (from RU)' }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
summaryHtml = (['SE', 'DE', 'RU'] as const)
|
|
|
|
|
.map((code) => {
|
|
|
|
|
const i = info[code];
|
|
|
|
|
const ok = regions[code].ok || 0;
|
|
|
|
|
const total = regions[code].total || 5;
|
|
|
|
|
const color = ok === total ? 'text-emerald-600' : ok === 0 ? 'text-red-600' : 'text-amber-600';
|
|
|
|
|
return `<div class="rounded-lg border border-border bg-card p-3 text-sm">
|
|
|
|
|
<div class="font-medium">${i.flag} ${i.name}</div>
|
|
|
|
|
<div class="mt-1 text-2xl font-semibold ${color}">${ok}/${total}</div>
|
|
|
|
|
<div class="text-muted-foreground mt-1 text-xs">${i.note}</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
})
|
|
|
|
|
.join('');
|
|
|
|
|
|
|
|
|
|
let tbl = `<div class="overflow-x-auto"><table class="w-full min-w-[520px] border-collapse text-sm">
|
|
|
|
|
<thead><tr class="border-b border-border">
|
|
|
|
|
<th class="p-2 text-start font-medium">Из страны</th>
|
|
|
|
|
${dcs.map((dc) => `<th class="p-2 text-center font-medium">${dc}<br/><span class="text-muted-foreground text-xs font-normal">${locs[dc]}</span></th>`).join('')}
|
|
|
|
|
</tr></thead><tbody>`;
|
|
|
|
|
|
|
|
|
|
for (const row of [
|
|
|
|
|
{ code: 'SE', flag: '🇸🇪', label: 'Швеция' },
|
|
|
|
|
{ code: 'DE', flag: '🇩🇪', label: 'Германия' },
|
|
|
|
|
{ code: 'RU', flag: '🇷🇺', label: 'Россия' }
|
|
|
|
|
] as const) {
|
|
|
|
|
tbl += `<tr class="border-b border-border"><td class="p-2 whitespace-nowrap">${row.flag} ${row.label}</td>`;
|
|
|
|
|
for (const dc of dcs) {
|
|
|
|
|
const v = matrix[row.code][dc];
|
|
|
|
|
const color =
|
|
|
|
|
v === true ? 'text-emerald-600' : v === false ? 'text-red-600' : 'text-muted-foreground';
|
|
|
|
|
const title = v === true ? 'доступен' : v === false ? 'заблокирован' : 'нет данных';
|
|
|
|
|
tbl += `<td class="p-2 text-center ${color}" title="${title}">●</td>`;
|
|
|
|
|
}
|
|
|
|
|
tbl += '</tr>';
|
|
|
|
|
}
|
|
|
|
|
tbl += '</tbody></table></div>';
|
|
|
|
|
matrixHtml = tbl;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadRadar() {
|
|
|
|
|
radarLoading = true;
|
|
|
|
|
radarErr = null;
|
|
|
|
|
try {
|
|
|
|
|
const data = await fetchRadarStatuses();
|
|
|
|
|
buildMatrix(data);
|
|
|
|
|
radarUpdated = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
radarErr = e instanceof Error ? e.message : String(e);
|
|
|
|
|
summaryHtml = '';
|
|
|
|
|
matrixHtml = '';
|
|
|
|
|
} finally {
|
|
|
|
|
radarLoading = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runDiag() {
|
|
|
|
|
diagLoading = true;
|
|
|
|
|
for (let i = 1; i <= 5; i++) {
|
|
|
|
|
pingByDc = { ...pingByDc, [i]: null };
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const data = await fetchRadarPingDC();
|
|
|
|
|
pingFrom = data.from;
|
|
|
|
|
const next: Record<number, RadarPingRow | null> = { ...pingByDc };
|
|
|
|
|
for (let i = 1; i <= 5; i++) {
|
|
|
|
|
const r = data.results[String(i)];
|
|
|
|
|
next[i] = r ?? null;
|
|
|
|
|
}
|
|
|
|
|
pingByDc = next;
|
|
|
|
|
} catch {
|
|
|
|
|
for (let i = 1; i <= 5; i++) {
|
|
|
|
|
pingByDc = { ...pingByDc, [i]: { ok: false, ms: null } };
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
diagLoading = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pingStyle(ms: number | null, ok: boolean): { text: string; barPct: number; color: string } {
|
|
|
|
|
if (!ok || ms === null) {
|
|
|
|
|
return { text: 'блок.', barPct: 100, color: 'bg-red-500' };
|
|
|
|
|
}
|
|
|
|
|
const color = ms < 100 ? 'bg-emerald-500' : ms < 300 ? 'bg-amber-500' : 'bg-red-500';
|
|
|
|
|
return { text: `${ms} мс`, barPct: Math.min(ms / 3, 100), color };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMount(() => {
|
|
|
|
|
void loadRadar();
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="mx-auto flex max-w-5xl 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" />
|
|
|
|
|
<div>
|
|
|
|
|
<h1 class="text-xl font-semibold tracking-tight">Радар DC Telegram</h1>
|
|
|
|
|
<p class="text-muted-foreground text-sm">
|
|
|
|
|
Источник матрицы: <span class="text-foreground">radar.telemt.top</span>
|
|
|
|
|
{#if radarUpdated}
|
|
|
|
|
· обновлено {radarUpdated}
|
|
|
|
|
{/if}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button variant="outline" size="sm" disabled={radarLoading} onclick={() => loadRadar()}>
|
|
|
|
|
<RefreshCwIcon class="mr-1 size-4 {radarLoading ? 'animate-spin' : ''}" />
|
|
|
|
|
Обновить
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if radarErr}
|
|
|
|
|
<Alert variant="destructive">
|
|
|
|
|
<AlertTitle>Ошибка загрузки радара</AlertTitle>
|
|
|
|
|
<AlertDescription>{radarErr}</AlertDescription>
|
|
|
|
|
</Alert>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<Card.Root>
|
|
|
|
|
<Card.Header>
|
|
|
|
|
<Card.Title class="text-base">Сводка по регионам (TCP 443 IPv4)</Card.Title>
|
|
|
|
|
<Card.Description>
|
|
|
|
|
Матрица ниже строится из ответа шлюза <code class="text-xs">/api/radar/statuses</code> (прокси к
|
|
|
|
|
radar.telemt.top).
|
|
|
|
|
</Card.Description>
|
|
|
|
|
</Card.Header>
|
|
|
|
|
<Card.Content>
|
|
|
|
|
{#if radarLoading && !summaryHtml}
|
|
|
|
|
<p class="text-muted-foreground text-sm">Загрузка…</p>
|
|
|
|
|
{:else if summaryHtml}
|
|
|
|
|
<div class="grid gap-3 sm:grid-cols-3">{@html summaryHtml}</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</Card.Content>
|
|
|
|
|
</Card.Root>
|
|
|
|
|
|
|
|
|
|
<Card.Root>
|
|
|
|
|
<Card.Header>
|
|
|
|
|
<Card.Title class="text-base">Матрица доступности</Card.Title>
|
|
|
|
|
<Card.Description>
|
|
|
|
|
<span class="text-emerald-600">●</span> доступен
|
|
|
|
|
<span class="text-red-600 ms-2">●</span> заблокирован
|
|
|
|
|
<span class="text-muted-foreground ms-2">●</span> нет данных
|
|
|
|
|
</Card.Description>
|
|
|
|
|
</Card.Header>
|
|
|
|
|
<Card.Content>
|
|
|
|
|
{#if radarLoading && !matrixHtml}
|
|
|
|
|
<p class="text-muted-foreground text-sm">Загрузка…</p>
|
|
|
|
|
{:else if matrixHtml}
|
|
|
|
|
{@html matrixHtml}
|
|
|
|
|
{/if}
|
|
|
|
|
<Alert class="mt-4">
|
|
|
|
|
<InfoIcon class="size-4" />
|
|
|
|
|
<AlertTitle class="text-sm">Про ICMP и TCP 443</AlertTitle>
|
|
|
|
|
<AlertDescription class="text-xs">
|
|
|
|
|
ICMP может проходить даже при блокировке MTProto по TCP 443. Для Telegram важна доступность
|
|
|
|
|
TCP 443 до DC — её отражает radar и тест ниже.
|
|
|
|
|
</AlertDescription>
|
|
|
|
|
</Alert>
|
|
|
|
|
</Card.Content>
|
|
|
|
|
</Card.Root>
|
|
|
|
|
|
|
|
|
|
<Card.Root>
|
|
|
|
|
<Card.Header>
|
|
|
|
|
<Card.Title class="flex items-center gap-2 text-base">
|
|
|
|
|
<ActivityIcon class="size-4" />
|
|
|
|
|
Диагностика 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» в ответе: источник метки на
|
|
|
|
|
сервере.
|
|
|
|
|
{#if pingFrom}
|
|
|
|
|
<span class="mt-1 block text-foreground">from: {pingFrom}</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</Card.Description>
|
|
|
|
|
</Card.Header>
|
|
|
|
|
<Card.Content class="space-y-4">
|
|
|
|
|
<Button disabled={diagLoading} onclick={() => runDiag()}>
|
|
|
|
|
{diagLoading ? 'Тестирование…' : 'Запустить тест'}
|
|
|
|
|
</Button>
|
|
|
|
|
<div class="space-y-3">
|
|
|
|
|
{#each [1, 2, 3, 4, 5] as n (n)}
|
|
|
|
|
{@const row = pingByDc[n]}
|
|
|
|
|
{@const st = row ? pingStyle(row.ms, row.ok) : { text: '—', barPct: 0, color: 'bg-muted' }}
|
|
|
|
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
|
|
|
|
|
<div class="w-24 shrink-0 text-sm font-medium">DC{n}</div>
|
|
|
|
|
<div class="min-w-0 flex-1">
|
|
|
|
|
<div class="bg-muted h-2 overflow-hidden rounded-full">
|
|
|
|
|
<div
|
|
|
|
|
class="h-2 rounded-full transition-all {st.color}"
|
|
|
|
|
style="width: {row ? st.barPct : 0}%"
|
|
|
|
|
></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="w-28 shrink-0 text-sm tabular-nums {row?.ok ? '' : 'text-red-600'}">
|
|
|
|
|
{diagLoading && !row ? '…' : st.text}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="text-muted-foreground w-32 shrink-0 text-xs">
|
|
|
|
|
{#if row}
|
|
|
|
|
{row.ok ? 'доступен' : 'заблокирован'}
|
|
|
|
|
{:else if !diagLoading}
|
|
|
|
|
ожидание
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</Card.Content>
|
|
|
|
|
</Card.Root>
|
|
|
|
|
|
|
|
|
|
<Card.Root>
|
|
|
|
|
<Card.Header>
|
|
|
|
|
<Card.Title class="text-base">Справка — датацентры Telegram</Card.Title>
|
|
|
|
|
</Card.Header>
|
|
|
|
|
<Card.Content>
|
|
|
|
|
<div class="rounded-md border border-border">
|
|
|
|
|
<Table.Root>
|
|
|
|
|
<Table.Header>
|
|
|
|
|
<Table.Row>
|
|
|
|
|
<Table.Head class="w-24">DC</Table.Head>
|
|
|
|
|
<Table.Head>Локация</Table.Head>
|
|
|
|
|
<Table.Head>Назначение</Table.Head>
|
|
|
|
|
</Table.Row>
|
|
|
|
|
</Table.Header>
|
|
|
|
|
<Table.Body>
|
|
|
|
|
{#each legendRows as row (row.dc)}
|
|
|
|
|
<Table.Row>
|
|
|
|
|
<Table.Cell class="font-mono text-sm">{row.dc}</Table.Cell>
|
|
|
|
|
<Table.Cell class="text-sm">{row.loc}</Table.Cell>
|
|
|
|
|
<Table.Cell class="text-muted-foreground text-sm">{row.desc}</Table.Cell>
|
|
|
|
|
</Table.Row>
|
|
|
|
|
{/each}
|
|
|
|
|
</Table.Body>
|
|
|
|
|
</Table.Root>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="text-muted-foreground mt-3 text-xs">
|
|
|
|
|
Telegram назначает DC при регистрации; при блокировке своего DC нужен прокси в регионе, где DC
|
|
|
|
|
доступен по TCP 443.
|
|
|
|
|
</p>
|
|
|
|
|
</Card.Content>
|
|
|
|
|
</Card.Root>
|
|
|
|
|
</div>
|