Implement Mihomo external-controller support in configuration and gateway
Publish telemt-api gateway Docker image / test (push) Successful in 34s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m55s

- Added optional Mihomo configuration fields in `config.compose.yaml` and `config.example.yaml` for enhanced integration with the Mihomo external-controller.
- Updated the `Gateway` to handle Mihomo API requests, including proxying and error handling for Mihomo-specific endpoints.
- Enhanced the documentation in `GATEWAY_RUN.md` to guide users on configuring Mihomo integration.
- Introduced new utility functions in the web client for interacting with Mihomo API endpoints, improving the overall user experience.
- Updated the sidebar in the Svelte components to include a link to the Mihomo section, enhancing navigation.
This commit is contained in:
Denozordec
2026-03-31 00:32:19 +07:00
parent bbd9619290
commit 2d7b06260e
17 changed files with 1093 additions and 9 deletions
+58
View File
@@ -7,12 +7,70 @@ import type {
TelemtSuccess,
UserInfo
} from './telemt-v1.js';
import type { MihomoMetaResponse } from './mihomo-types.js';
export function gatewayBase(): string {
const u = PUBLIC_TELEMT_GATEWAY_URL || '';
return u.replace(/\/$/, '');
}
/** Путь к Mihomo external-controller через шлюз: без ведущего слэша. */
export function mihomoUrl(alias: string, path: string): string {
const p = path.replace(/^\/+/, '');
return `${gatewayBase()}/api/${encodeURIComponent(alias)}/mihomo/${p}`;
}
/** WebSocket к тому же origin (ws / wss). */
export function mihomoWsUrl(alias: string, path: string): string {
const p = path.replace(/^\/+/, '');
const base = gatewayBase();
const wsBase = base.replace(/^http/, 'ws');
return `${wsBase}/api/${encodeURIComponent(alias)}/mihomo/${p}`;
}
export async function fetchMihomoMeta(alias: string): Promise<MihomoMetaResponse> {
const res = await fetch(mihomoUrl(alias, 'meta'));
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (res.status === 404) {
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
}
if (!res.ok) {
throw new ApiError(`Mihomo meta HTTP ${res.status}`, res.status, body);
}
if (!body || body.ok !== true) {
throw new ApiError('Mihomo meta: неверный ответ', res.status, body);
}
return body as unknown as MihomoMetaResponse;
}
export async function fetchMihomoJson<T>(alias: string, path: string): Promise<T> {
const res = await fetch(mihomoUrl(alias, path));
const body = await parseJson(res);
if (res.status === 404) {
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
}
if (!res.ok) {
throw new ApiError(`Mihomo HTTP ${res.status}`, res.status, body);
}
return body as T;
}
export async function mihomoPut(alias: string, path: string, jsonBody: unknown): Promise<void> {
const res = await fetch(mihomoUrl(alias, path), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(jsonBody)
});
if (res.status === 404) {
const body = await parseJson(res);
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
}
if (!res.ok) {
const body = await parseJson(res);
throw new ApiError(`Mihomo PUT HTTP ${res.status}`, res.status, body);
}
}
export type AggEnvelope<T> = {
ok: true;
generated_at: string;
+35
View File
@@ -0,0 +1,35 @@
/** GET /proxies — фрагмент ответа Mihomo external-controller. */
export type MihomoProxiesResponse = {
proxies?: Record<string, MihomoProxyEntry>;
};
export type MihomoProxyEntry = {
type?: string;
name?: string;
now?: string;
all?: string[];
history?: { time: string; delay: number }[];
udp?: boolean;
[key: string]: unknown;
};
export type MihomoConnectionsResponse = {
total?: number;
connections?: MihomoConnection[];
};
export type MihomoConnection = {
metadata?: { network?: string; [k: string]: unknown };
chains?: string[];
[key: string]: unknown;
};
export type MihomoMetaResponse = {
ok: true;
controller_base: string;
};
export type MihomoDelayResponse = {
delay?: number;
message?: string;
};
+11
View File
@@ -9,6 +9,7 @@
import NetworkIcon from '@lucide/svelte/icons/network';
import SettingsIcon from '@lucide/svelte/icons/settings';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ZapIcon from '@lucide/svelte/icons/zap';
import SirenIcon from '@lucide/svelte/icons/siren';
import RadioIcon from '@lucide/svelte/icons/radio';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
@@ -136,6 +137,16 @@
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/mihomo')} tooltipContent="Mihomo">
{#snippet child({ props })}
<a href="{serverPath}/mihomo" {...props}>
<ZapIcon />
<span>Mihomo</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Runtime">
{#snippet child({ props })}
@@ -0,0 +1,224 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import Chart from 'chart.js/auto';
import * as Card from '$lib/components/ui/card/index.js';
import ActivityIcon from '@lucide/svelte/icons/activity';
import NetworkIcon from '@lucide/svelte/icons/network';
import GaugeIcon from '@lucide/svelte/icons/gauge';
let {
histUp,
histDown,
histMem,
histConn,
totalUp,
totalDown,
tcpN,
udpN,
topList
}: {
histUp: number[];
histDown: number[];
histMem: number[];
histConn: number[];
totalUp: number;
totalDown: number;
tcpN: number;
udpN: number;
topList: { name: string; n: number }[];
} = $props();
let elTraffic = $state<HTMLCanvasElement | null>(null);
let elFlow = $state<HTMLCanvasElement | null>(null);
let elMem = $state<HTMLCanvasElement | null>(null);
let elConn = $state<HTMLCanvasElement | null>(null);
let elNet = $state<HTMLCanvasElement | null>(null);
let elTop = $state<HTMLCanvasElement | null>(null);
let chartTraffic: Chart | null = null;
let chartFlow: Chart | null = null;
let chartMem: Chart | null = null;
let chartConn: Chart | null = null;
let chartNet: Chart | null = null;
let chartTop: Chart | null = null;
const grid = '#64748b';
function lineOpts() {
return {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
plugins: { legend: { labels: { color: grid } } },
scales: {
x: { ticks: { color: grid, maxTicksLimit: 8 }, grid: { color: '#33415555' } },
y: { ticks: { color: grid }, grid: { color: '#33415555' } }
}
};
}
onMount(() => {
if (!elTraffic || !elFlow || !elMem || !elConn || !elNet || !elTop) return;
const lab = histDown.map((_, i) => String(i));
chartTraffic = new Chart(elTraffic, {
type: 'line',
data: {
labels: lab,
datasets: [
{ label: 'Скачивание', data: [...histDown], borderColor: '#38bdf8', tension: 0.2, fill: true },
{ label: 'Загрузка', data: [...histUp], borderColor: '#f472b6', tension: 0.2, fill: true }
]
},
options: lineOpts()
});
chartFlow = new Chart(elFlow, {
type: 'doughnut',
data: {
labels: ['Скачано', 'Загружено'],
datasets: [{ data: [Math.max(totalDown, 0), Math.max(totalUp, 0)], backgroundColor: ['#38bdf8', '#f472b6'] }]
},
options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: grid } } } }
});
chartMem = new Chart(elMem, {
type: 'line',
data: {
labels: histMem.map((_, i) => String(i)),
datasets: [{ label: 'KB', data: [...histMem], borderColor: '#a78bfa', tension: 0.2, fill: true }]
},
options: lineOpts()
});
chartConn = new Chart(elConn, {
type: 'line',
data: {
labels: histConn.map((_, i) => String(i)),
datasets: [{ label: 'Соединения', data: [...histConn], borderColor: '#34d399', tension: 0.2, fill: true }]
},
options: lineOpts()
});
chartNet = new Chart(elNet, {
type: 'doughnut',
data: {
labels: ['TCP', 'UDP'],
datasets: [{ data: [tcpN, udpN], backgroundColor: ['#22c55e', '#eab308'] }]
},
options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: grid } } } }
});
chartTop = new Chart(elTop, {
type: 'bar',
data: {
labels: topList.map((x) => x.name),
datasets: [{ label: 'Сессии', data: topList.map((x) => x.n), backgroundColor: '#818cf8' }]
},
options: {
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: grid }, grid: { color: '#33415555' } },
y: { ticks: { color: grid }, grid: { display: false } }
}
}
});
});
$effect(() => {
const labels = histDown.map((_, i) => String(i));
if (chartTraffic) {
chartTraffic.data.labels = labels;
chartTraffic.data.datasets[0].data = [...histDown];
chartTraffic.data.datasets[1].data = [...histUp];
chartTraffic.update('none');
}
if (chartMem) {
chartMem.data.labels = histMem.map((_, i) => String(i));
chartMem.data.datasets[0].data = [...histMem];
chartMem.update('none');
}
if (chartConn) {
chartConn.data.labels = histConn.map((_, i) => String(i));
chartConn.data.datasets[0].data = [...histConn];
chartConn.update('none');
}
if (chartFlow) {
chartFlow.data.datasets[0].data = [Math.max(totalDown, 0), Math.max(totalUp, 0)];
chartFlow.update('none');
}
if (chartNet) {
chartNet.data.datasets[0].data = [tcpN, udpN];
chartNet.update('none');
}
if (chartTop) {
chartTop.data.labels = topList.map((x) => x.name);
chartTop.data.datasets[0].data = topList.map((x) => x.n);
chartTop.update('none');
}
});
onDestroy(() => {
chartTraffic?.destroy();
chartFlow?.destroy();
chartMem?.destroy();
chartConn?.destroy();
chartNet?.destroy();
chartTop?.destroy();
});
</script>
<div class="grid gap-4 lg:grid-cols-2">
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 text-base">
<ActivityIcon class="size-4" /> Трафик
</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Поток (накоплено)</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elFlow} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Память (KB)</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elMem} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Подключения</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elConn} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 text-base">
<NetworkIcon class="size-4" /> Типы сети
</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elNet} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 text-base">
<GaugeIcon class="size-4" /> Топ прокси (по сессиям)
</Card.Title>
</Card.Header>
<Card.Content class="h-56">
<canvas bind:this={elTop} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
</div>
+7
View File
@@ -1,3 +1,10 @@
/** Скорость в байтах/с (или кбит/с — подпись общая). */
export function formatRatePerSec(value: number): string {
if (!Number.isFinite(value) || value < 0) return '—';
if (value < 1024) return `${value < 10 ? value.toFixed(1) : Math.round(value)} B/s`;
return `${formatBytes(value)}/s`;
}
export function formatBytes(octets: number): string {
if (octets < 1024) return `${octets} B`;
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
@@ -0,0 +1,453 @@
<script lang="ts">
import { page } from '$app/state';
import { browser } from '$app/environment';
import { onDestroy } from 'svelte';
import {
ApiError,
fetchMihomoJson,
fetchMihomoMeta,
mihomoPut,
mihomoWsUrl
} from '$lib/api/client.js';
import type {
MihomoConnectionsResponse,
MihomoMetaResponse,
MihomoProxiesResponse,
MihomoProxyEntry
} from '$lib/api/mihomo-types.js';
import { formatBytes, formatRatePerSec } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import MihomoOverviewCharts from '$lib/components/mihomo/mihomo-overview-charts.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
const alias = $derived(page.params.alias ?? '');
let tab = $state<'overview' | 'proxies'>('overview');
let loadingMeta = $state(true);
let metaErr = $state<string | null>(null);
let meta = $state<MihomoMetaResponse | null>(null);
let upBps = $state(0);
let downBps = $state(0);
let totalUp = $state(0);
let totalDown = $state(0);
let memKB = $state(0);
let connTotal = $state(0);
let tcpN = $state(0);
let udpN = $state(0);
let histUp = $state<number[]>([]);
let histDown = $state<number[]>([]);
let histMem = $state<number[]>([]);
let histConn = $state<number[]>([]);
let topList = $state<{ name: string; n: number }[]>([]);
const MAX_POINTS = 72;
let lastTick = $state<number | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let wsTraffic: WebSocket | null = null;
let wsMemory: WebSocket | null = null;
let proxiesLoading = $state(false);
let proxiesErr = $state<string | null>(null);
let proxiesData = $state<MihomoProxiesResponse | null>(null);
let switching = $state<string | null>(null);
let testing = $state<string | null>(null);
let testingGroup = $state<string | null>(null);
function recordRates(now: number, up: number, down: number) {
if (lastTick != null) {
const dt = (now - lastTick) / 1000;
if (dt > 0 && dt < 5) {
totalUp += up * dt;
totalDown += down * dt;
}
}
lastTick = now;
upBps = up;
downBps = down;
histUp = [...histUp, up];
histDown = [...histDown, down];
while (histUp.length > MAX_POINTS) {
histUp.shift();
histDown.shift();
}
}
async function loadMeta(a: string) {
loadingMeta = true;
metaErr = null;
try {
meta = await fetchMihomoMeta(a);
} catch (e) {
meta = null;
metaErr = e instanceof ApiError ? e.message : String(e);
} finally {
loadingMeta = false;
}
}
function connectWs(a: string) {
if (!browser) return;
wsTraffic?.close();
wsMemory?.close();
lastTick = null;
try {
wsTraffic = new WebSocket(mihomoWsUrl(a, 'traffic'));
wsTraffic.onmessage = (ev) => {
try {
const o = JSON.parse(ev.data as string) as { up?: number; down?: number };
recordRates(performance.now(), Number(o.up) || 0, Number(o.down) || 0);
} catch {
/* ignore */
}
};
} catch {
/* ignore */
}
try {
wsMemory = new WebSocket(mihomoWsUrl(a, 'memory'));
wsMemory.onmessage = (ev) => {
try {
const o = JSON.parse(ev.data as string) as { inuse?: number };
memKB = Number(o.inuse) || 0;
histMem = [...histMem, memKB];
while (histMem.length > MAX_POINTS) histMem.shift();
} catch {
/* ignore */
}
};
} catch {
/* ignore */
}
}
function topProxyCounts(conns: MihomoConnectionsResponse['connections']): { name: string; n: number }[] {
const m = new Map<string, number>();
for (const c of conns ?? []) {
const ch = c.chains;
if (!ch?.length) continue;
const name = ch[ch.length - 1] ?? ch[0];
m.set(name, (m.get(name) ?? 0) + 1);
}
return [...m.entries()]
.map(([name, n]) => ({ name, n }))
.sort((a, b) => b.n - a.n)
.slice(0, 8);
}
async function pollConnections(a: string) {
try {
const j = await fetchMihomoJson<MihomoConnectionsResponse>(a, 'connections');
const list = j.connections ?? [];
connTotal = typeof j.total === 'number' ? j.total : list.length;
let t = 0,
u = 0;
for (const c of list) {
const net = (c.metadata?.network ?? '').toLowerCase();
if (net === 'tcp') t++;
else if (net === 'udp') u++;
}
tcpN = t;
udpN = u;
histConn = [...histConn, connTotal];
while (histConn.length > MAX_POINTS) histConn.shift();
topList = topProxyCounts(list);
} catch {
/* ignore */
}
}
$effect(() => {
const a = alias;
if (!a || !browser) return;
void loadMeta(a);
connectWs(a);
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(() => void pollConnections(a), 2000);
void pollConnections(a);
return () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
wsTraffic?.close();
wsMemory?.close();
wsTraffic = null;
wsMemory = null;
};
});
async function loadProxies() {
const a = alias;
if (!a) return;
proxiesLoading = true;
proxiesErr = null;
try {
proxiesData = await fetchMihomoJson<MihomoProxiesResponse>(a, 'proxies');
} catch (e) {
proxiesData = null;
proxiesErr = e instanceof ApiError ? e.message : String(e);
} finally {
proxiesLoading = false;
}
}
$effect(() => {
const a = alias;
if (!a || tab !== 'proxies') return;
void loadProxies();
});
function isSelectableGroup(t?: string) {
return t === 'Selector' || t === 'URLTest';
}
function lastDelay(p: MihomoProxyEntry): number | null {
const h = p.history;
if (!h?.length) return null;
return h[h.length - 1]?.delay ?? null;
}
async function selectProxy(groupName: string, nodeName: string) {
const a = alias;
if (!a) return;
switching = groupName;
try {
await mihomoPut(a, `proxies/${encodeURIComponent(groupName)}`, { name: nodeName });
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
} finally {
switching = null;
}
}
async function pingOne(name: string) {
const a = alias;
if (!a) return;
testing = name;
try {
await fetchMihomoJson(a, `proxies/${encodeURIComponent(name)}/delay?timeout=5000`);
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
} finally {
testing = null;
}
}
async function pingGroup(groupName: string) {
const a = alias;
if (!a) return;
testingGroup = groupName;
try {
await fetchMihomoJson(a, `group/${encodeURIComponent(groupName)}/delay?timeout=5000`);
await loadProxies();
} catch {
try {
const g = proxiesData?.proxies?.[groupName];
const names = g?.all ?? [];
for (const n of names) {
await fetchMihomoJson(a, `proxies/${encodeURIComponent(n)}/delay?timeout=5000`);
}
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
}
} finally {
testingGroup = null;
}
}
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
wsTraffic?.close();
wsMemory?.close();
});
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Mihomo</h1>
<p class="text-muted-foreground text-sm">Обзор и прокси (external-controller)</p>
</div>
<div class="flex gap-2">
<Button
variant={tab === 'overview' ? 'default' : 'outline'}
size="sm"
onclick={() => (tab = 'overview')}
>
Обзор
</Button>
<Button
variant={tab === 'proxies' ? 'default' : 'outline'}
size="sm"
onclick={() => (tab = 'proxies')}
>
Прокси
</Button>
</div>
</div>
<DataQueryState err={metaErr} showSkeleton={loadingMeta} isEmpty={false}>
{#snippet skeleton()}
<Skeleton class="mb-4 h-6 w-96 max-w-full" />
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-24 rounded-lg" />
{/each}
</div>
<div class="grid gap-4 lg:grid-cols-2">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-56 rounded-lg" />
{/each}
</div>
{/snippet}
{#snippet children()}
{#if meta}
<p class="text-muted-foreground mb-4 text-sm">
Подключено к: <span class="text-foreground font-mono">{meta.controller_base}</span>
</p>
{/if}
{#if tab === 'overview' && !metaErr}
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<Card.Root class="border-l-primary border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Загрузка</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">{formatRatePerSec(upBps)}</Card.Content>
</Card.Root>
<Card.Root class="border-l-sky-500 border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Скачивание</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">{formatRatePerSec(downBps)}</Card.Content>
</Card.Root>
<Card.Root class="border-l-amber-500 border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Всего загружено</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">{formatBytes(totalUp)}</Card.Content>
</Card.Root>
<Card.Root class="border-l-cyan-500 border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Всего скачано</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">{formatBytes(totalDown)}</Card.Content>
</Card.Root>
<Card.Root class="border-l-emerald-500 border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Активные соединения</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">{connTotal}</Card.Content>
</Card.Root>
<Card.Root class="border-l-violet-500 border-l-4 py-3">
<Card.Header class="pb-1">
<Card.Description>Память</Card.Description>
</Card.Header>
<Card.Content class="text-lg font-semibold">
{memKB > 0 ? `${(memKB / 1024).toFixed(1)} MiB` : '—'}
</Card.Content>
</Card.Root>
</div>
<MihomoOverviewCharts
histUp={histUp}
histDown={histDown}
histMem={histMem}
histConn={histConn}
totalUp={totalUp}
totalDown={totalDown}
tcpN={tcpN}
udpN={udpN}
topList={topList}
/>
{/if}
{/snippet}
</DataQueryState>
{#if tab === 'proxies'}
<div class="mb-4 flex justify-end">
<Button variant="outline" size="sm" onclick={() => loadProxies()} disabled={proxiesLoading}>
Обновить
</Button>
</div>
<DataQueryState err={proxiesErr} showSkeleton={proxiesLoading} isEmpty={false}>
{#snippet skeleton()}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-32 rounded-xl" />
{/each}
</div>
{/snippet}
{#snippet children()}
{#if proxiesData?.proxies}
<div class="flex flex-col gap-8">
{#each Object.entries(proxiesData.proxies).filter(([_, v]) => isSelectableGroup(v.type)) as [gName, group] (gName)}
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="text-lg font-semibold">{gName}</h2>
<p class="text-muted-foreground text-sm">
Сейчас: <span class="text-foreground">{group.now ?? '—'}</span>
</p>
</div>
<Button
variant="secondary"
size="sm"
onclick={() => pingGroup(gName)}
disabled={testingGroup === gName}
>
{testingGroup === gName ? 'Проверка…' : 'Проверить все'}
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each group.all ?? [] as nodeName (nodeName)}
{@const node = proxiesData.proxies?.[nodeName]}
{@const d = node ? lastDelay(node) : null}
{@const active = group.now === nodeName}
<div
class="bg-card text-card-foreground rounded-xl border p-4 transition-shadow {active
? 'ring-primary ring-2'
: ''}"
>
<button
type="button"
class="w-full text-left"
onclick={() => selectProxy(gName, nodeName)}
disabled={switching === gName}
>
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{nodeName}</span>
<Badge variant="outline">{node?.type ?? '—'}</Badge>
</div>
<div
class="mt-2 text-sm {d != null && d < 500 ? 'text-emerald-400' : 'text-muted-foreground'}"
>
{d != null ? `${d} ms` : '—'}
</div>
</button>
<div class="mt-2 flex justify-end">
<Button
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => pingOne(nodeName)}
disabled={testing === nodeName}
>
Ping
</Button>
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
{/snippet}
</DataQueryState>
{/if}