Implement Mihomo external-controller support in configuration and gateway
- 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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user