Add Mihomo menu item and refactor page component
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m35s

- Introduced a new menu item for Mihomo in the app sidebar for easier navigation.
- Refactored the Mihomo page component by removing unused imports and state variables, streamlining the code for better performance and readability.
- Enhanced the overall structure of the Mihomo page to improve maintainability and user experience.
This commit is contained in:
Denozordec
2026-03-31 16:51:26 +07:00
parent 8bf4bb7f35
commit e2d89f2d11
4 changed files with 574 additions and 207 deletions
+10
View File
@@ -104,6 +104,16 @@
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active('/mihomo')} tooltipContent="Mihomo (флот)">
{#snippet child({ props })}
<a href="/mihomo" {...props}>
<ZapIcon />
<span>Mihomo</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>
{#if serverAliases.length > 0}
@@ -0,0 +1,217 @@
<script lang="ts">
import { ApiError, fetchMihomoJson, mihomoPut } from '$lib/api/client.js';
import type { MihomoProxiesResponse, MihomoProxyEntry } from '$lib/api/mihomo-types.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 { Skeleton } from '$lib/components/ui/skeleton/index.js';
let {
alias,
lazy = false
}: {
alias: string;
/** Не запрашивать /proxies до явного нажатия «Загрузить» / «Обновить». */
lazy?: boolean;
} = $props();
/** Mihomo hub/route/proxies.go: без query `url` URLTest часто даёт delay=0 → HTTP 503. */
const MIHOMO_DELAY_DEFAULT_URL = 'https://www.gstatic.com/generate_204';
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 delayQuery(testUrl?: string) {
const u =
testUrl && String(testUrl).trim() !== '' ? String(testUrl).trim() : MIHOMO_DELAY_DEFAULT_URL;
return `timeout=5000&url=${encodeURIComponent(u)}`;
}
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 || lazy) return;
void loadProxies();
});
/** Группы, у которых есть `all` + `now` и PUT /proxies/{name} переключает узел (как в Clash/Mihomo). */
function isSelectableGroup(p: MihomoProxyEntry) {
const t = (p.type ?? '').toLowerCase();
if (t === 'selector' || t === 'urltest' || t === 'fallback') return true;
if (t === 'loadbalance' && Array.isArray(p.all) && p.all.length > 0) return true;
return false;
}
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 {
const testUrl = proxiesData?.proxies?.[name]?.testUrl;
await fetchMihomoJson(
a,
`proxies/${encodeURIComponent(name)}/delay?${delayQuery(testUrl)}`
);
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;
const groupTestUrl = proxiesData?.proxies?.[groupName]?.testUrl;
try {
await fetchMihomoJson(
a,
`group/${encodeURIComponent(groupName)}/delay?${delayQuery(groupTestUrl)}`
);
await loadProxies();
} catch {
try {
const g = proxiesData?.proxies?.[groupName];
const names = g?.all ?? [];
for (const n of names) {
const u = proxiesData?.proxies?.[n]?.testUrl;
await fetchMihomoJson(a, `proxies/${encodeURIComponent(n)}/delay?${delayQuery(u)}`);
}
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
}
} finally {
testingGroup = null;
}
}
</script>
<div class="mb-4 flex flex-wrap items-center justify-end gap-2">
{#if lazy && !proxiesData && !proxiesLoading}
<Button variant="default" size="sm" onclick={() => loadProxies()}>Загрузить прокси</Button>
{/if}
<Button variant="outline" size="sm" onclick={() => loadProxies()} disabled={proxiesLoading || !alias}>
Обновить
</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)) 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>
{:else if !lazy || proxiesLoading}
<!-- lazy без данных: ждём клика; скелетон выше -->
{:else}
<p class="text-muted-foreground text-sm">Нажмите «Загрузить прокси», чтобы получить список групп.</p>
{/if}
{/snippet}
</DataQueryState>
+343
View File
@@ -0,0 +1,343 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import {
ApiError,
fetchAggSummary,
fetchMihomoJson,
fetchMihomoMeta
} from '$lib/api/client.js';
import type { MihomoConnectionsResponse, MihomoMetaResponse } from '$lib/api/mihomo-types.js';
import { formatBytes } from '$lib/format.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Table from '$lib/components/ui/table/index.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 KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import MihomoProxyGroups from '$lib/components/mihomo/mihomo-proxy-groups.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ServerIcon from '@lucide/svelte/icons/server';
import PlugIcon from '@lucide/svelte/icons/plug';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import CheckCircleIcon from '@lucide/svelte/icons/circle-check';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
type RowStatus = 'ok' | 'no_mihomo' | 'error';
type FleetRow = {
alias: string;
metaStatus: RowStatus;
meta: MihomoMetaResponse | null;
metaMessage?: string;
alive?: boolean;
connections?: number;
memory?: number;
uploadTotal?: number;
downloadTotal?: number;
pollErr?: string | null;
};
const POLL_MS = 8000;
let tab = $state<string>('summary');
let loading = $state(true);
let err = $state<string | null>(null);
let rows = $state<FleetRow[]>([]);
/** Список alias с рабочим meta; обновляется только при полной перезагрузке. */
let mihomoOkAliases: string[] = [];
let pollTimer: ReturnType<typeof setInterval> | null = null;
let withMihomo = $derived(rows.filter((r) => r.metaStatus === 'ok').length);
let aliveCount = $derived(rows.filter((r) => r.metaStatus === 'ok' && r.alive).length);
let sumConn = $derived(
rows.reduce((acc, r) => acc + (r.metaStatus === 'ok' && typeof r.connections === 'number' ? r.connections : 0), 0)
);
let sumUpload = $derived(
rows.reduce((acc, r) => acc + (typeof r.uploadTotal === 'number' ? r.uploadTotal : 0), 0)
);
let sumDownload = $derived(
rows.reduce((acc, r) => acc + (typeof r.downloadTotal === 'number' ? r.downloadTotal : 0), 0)
);
let sumMemory = $derived(
rows.reduce((acc, r) => acc + (typeof r.memory === 'number' && r.memory > 0 ? r.memory : 0), 0)
);
async function probeMeta(alias: string): Promise<FleetRow> {
try {
const meta = await fetchMihomoMeta(alias);
return {
alias,
metaStatus: 'ok',
meta,
alive: false,
pollErr: null
};
} catch (e) {
const ae = e instanceof ApiError ? e : null;
if (ae?.status === 404) {
return {
alias,
metaStatus: 'no_mihomo',
meta: null,
metaMessage: ae.message
};
}
return {
alias,
metaStatus: 'error',
meta: null,
metaMessage: ae?.message ?? String(e)
};
}
}
async function pollMetrics() {
if (mihomoOkAliases.length === 0) return;
await Promise.all(
mihomoOkAliases.map(async (alias) => {
try {
const j = await fetchMihomoJson<MihomoConnectionsResponse>(alias, 'connections');
const list = j.connections ?? [];
const connTotal = typeof j.total === 'number' ? j.total : list.length;
rows = rows.map((r) =>
r.alias !== alias
? r
: {
...r,
alive: true,
connections: connTotal,
memory: typeof j.memory === 'number' ? j.memory : undefined,
uploadTotal: typeof j.uploadTotal === 'number' ? j.uploadTotal : undefined,
downloadTotal: typeof j.downloadTotal === 'number' ? j.downloadTotal : undefined,
pollErr: null
}
);
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e);
rows = rows.map((r) =>
r.alias !== alias ? r : { ...r, alive: false, pollErr: msg }
);
}
})
);
}
async function reloadAll() {
if (!browser) return;
loading = true;
err = null;
try {
const env = await fetchAggSummary({ top_n: 1 });
const set = new Set<string>();
for (const s of env.data.servers ?? []) {
if (s.alias) set.add(s.alias);
}
const aliases = [...set].sort((a, b) => a.localeCompare(b));
if (aliases.length === 0) {
rows = [];
mihomoOkAliases = [];
return;
}
const probed = await Promise.all(aliases.map((a) => probeMeta(a)));
probed.sort((a, b) => a.alias.localeCompare(b.alias));
rows = probed;
mihomoOkAliases = probed.filter((r) => r.metaStatus === 'ok').map((r) => r.alias);
await pollMetrics();
} catch (e) {
err = e instanceof ApiError ? e.message : String(e);
rows = [];
mihomoOkAliases = [];
} finally {
loading = false;
}
}
onMount(() => {
if (!browser) return;
void reloadAll();
pollTimer = setInterval(() => void pollMetrics(), POLL_MS);
return () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};
});
function statusBadge(r: FleetRow) {
if (r.metaStatus === 'no_mihomo') return { label: 'Нет Mihomo', variant: 'secondary' as const };
if (r.metaStatus === 'error') return { label: 'Meta ошибка', variant: 'destructive' as const };
if (r.alive === false && r.pollErr) return { label: 'Недоступен', variant: 'destructive' as const };
if (r.alive) return { label: 'Онлайн', variant: 'default' as const };
return { label: 'Ожидание', variant: 'outline' as const };
}
</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">
Сводка по нодам и управление прокси. Опрос соединений каждые {POLL_MS / 1000} с.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Tabs.Root bind:value={tab} class="w-fit shrink-0">
<Tabs.List class="min-w-[240px] gap-0.5 p-1">
<Tabs.Trigger value="summary" class="flex-1 px-3">Сводка</Tabs.Trigger>
<Tabs.Trigger value="proxies" class="flex-1 px-3">Прокси по нодам</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<Button variant="outline" size="sm" onclick={() => void reloadAll()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
</div>
<DataQueryState {err} showSkeleton={loading} isEmpty={!loading && rows.length === 0 && !err}>
{#snippet empty()}
<p class="text-muted-foreground text-sm">
В снимке агрегата нет серверов. Проверьте шлюз и конфигурацию <code class="text-xs">servers</code>.
</p>
{/snippet}
{#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{#each Array.from({ length: 5 }) as _, i (i)}
<Skeleton class="h-28 rounded-xl" />
{/each}
</div>
<Skeleton class="h-64 w-full rounded-lg" />
{/snippet}
{#snippet children()}
{#if tab === 'summary' && rows.length > 0}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<KpiStatCard accent="violet" label="Нод с Mihomo" footer="meta OK" valueClass="text-xl">
{#snippet icon()}
<ServerIcon class="size-5" aria-hidden="true" />
{/snippet}
{withMihomo}
</KpiStatCard>
<KpiStatCard accent="success" label="Онлайн (опрос)" footer="успешный /connections" valueClass="text-xl">
{#snippet icon()}
<CheckCircleIcon class="size-5" aria-hidden="true" />
{/snippet}
{aliveCount}/{withMihomo}
</KpiStatCard>
<KpiStatCard accent="info" label="Соединения ∑" footer="по снимку" valueClass="text-xl">
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
{sumConn}
</KpiStatCard>
<KpiStatCard accent="neutral" label="Память ∑" footer="поле memory" valueClass="text-xl">
{#snippet icon()}
<MemoryStickIcon class="size-5" aria-hidden="true" />
{/snippet}
{sumMemory > 0 ? formatBytes(sumMemory) : '—'}
</KpiStatCard>
<KpiStatCard accent="warning" label="Трафик ∑" footer="upload / download" valueClass="text-lg">
{#snippet icon()}
<CloudUploadIcon class="size-5" aria-hidden="true" />
{/snippet}
<span class="block truncate" title="{formatBytes(sumUpload)} ↑ · {formatBytes(sumDownload)} ↓">
{formatBytes(sumUpload)} ↑ · {formatBytes(sumDownload)}
</span>
</KpiStatCard>
</div>
<Card.Root class="border-l-4 border-l-violet-500/40 shadow-sm">
<Card.Header class="pb-2">
<Card.Title class="text-base">Ноды</Card.Title>
<Card.Description>Контроллер, статус опроса, ссылка на детальную панель.</Card.Description>
</Card.Header>
<Card.Content class="overflow-x-auto px-2 sm:px-6">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="text-xs sm:text-sm">Alias</Table.Head>
<Table.Head class="text-xs sm:text-sm">Контроллер</Table.Head>
<Table.Head class="text-xs sm:text-sm">Статус</Table.Head>
<Table.Head class="text-xs sm:text-sm text-right">Conn</Table.Head>
<Table.Head class="text-xs sm:text-sm text-right">Память</Table.Head>
<Table.Head class="text-xs sm:text-sm text-right">Трафик</Table.Head>
<Table.Head class="text-xs sm:text-sm w-[1%]"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as r (r.alias)}
{@const b = statusBadge(r)}
<Table.Row>
<Table.Cell class="font-medium text-sm">{r.alias}</Table.Cell>
<Table.Cell class="max-w-[200px] truncate font-mono text-xs sm:text-sm">
{r.meta?.controller_base ?? '—'}
</Table.Cell>
<Table.Cell>
<Badge variant={b.variant}>{b.label}</Badge>
{#if r.metaStatus !== 'ok' && r.metaMessage}
<p class="text-muted-foreground mt-1 max-w-xs text-xs">{r.metaMessage}</p>
{/if}
{#if r.pollErr}
<p class="text-destructive mt-1 max-w-xs text-xs">{r.pollErr}</p>
{/if}
</Table.Cell>
<Table.Cell class="text-right text-sm">
{r.metaStatus === 'ok' ? (r.connections ?? '—') : '—'}
</Table.Cell>
<Table.Cell class="text-right text-sm">
{r.memory != null && r.memory > 0 ? formatBytes(r.memory) : '—'}
</Table.Cell>
<Table.Cell class="text-right font-mono text-xs sm:text-sm">
{#if r.metaStatus === 'ok' && (r.uploadTotal != null || r.downloadTotal != null)}
{formatBytes(r.uploadTotal ?? 0)}<br />
{formatBytes(r.downloadTotal ?? 0)}
{:else}
{/if}
</Table.Cell>
<Table.Cell class="text-right">
{#if r.metaStatus === 'ok'}
<a
href="/servers/{encodeURIComponent(r.alias)}/mihomo"
class="text-primary inline-flex items-center gap-1 text-sm hover:underline"
>
Детально
<ExternalLinkIcon class="size-3.5 opacity-70" aria-hidden="true" />
</a>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
{:else if tab === 'proxies'}
<div class="flex flex-col gap-8">
{#each rows.filter((r) => r.metaStatus === 'ok') as r (r.alias)}
<Card.Root class="border-l-4 border-l-primary/30 shadow-sm">
<Card.Header class="pb-2">
<Card.Title class="text-lg">{r.alias}</Card.Title>
{#if r.meta?.controller_base}
<Card.Description class="font-mono text-xs">
{r.meta.controller_base}
</Card.Description>
{/if}
</Card.Header>
<Card.Content>
<MihomoProxyGroups alias={r.alias} lazy={true} />
</Card.Content>
</Card.Root>
{/each}
{#if rows.filter((r) => r.metaStatus === 'ok').length === 0}
<p class="text-muted-foreground text-sm">
Нет нод с настроенным Mihomo. Проверьте конфиг шлюза и вкладку «Сводка».
</p>
{/if}
</div>
{/if}
{/snippet}
</DataQueryState>
@@ -2,34 +2,21 @@
import { page } from '$app/state';
import { browser } from '$app/environment';
import { onDestroy } from 'svelte';
import {
ApiError,
fetchMihomoJson,
fetchMihomoMeta,
mihomoPut,
mihomoUrl,
mihomoWsUrl
} from '$lib/api/client.js';
import type {
MihomoConnectionsResponse,
MihomoMetaResponse,
MihomoProxiesResponse,
MihomoProxyEntry
} from '$lib/api/mihomo-types.js';
import { ApiError, fetchMihomoJson, fetchMihomoMeta, mihomoUrl, mihomoWsUrl } from '$lib/api/client.js';
import type { MihomoConnectionsResponse, MihomoMetaResponse } from '$lib/api/mihomo-types.js';
import { formatBytes, formatRatePerSec } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import ArrowBigUpIcon from '@lucide/svelte/icons/arrow-big-up';
import ArrowBigDownIcon from '@lucide/svelte/icons/arrow-big-down';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import CloudDownloadIcon from '@lucide/svelte/icons/cloud-download';
import PlugIcon from '@lucide/svelte/icons/plug';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import MihomoOverviewCharts from '$lib/components/mihomo/mihomo-overview-charts.svelte';
import MihomoProxyGroups from '$lib/components/mihomo/mihomo-proxy-groups.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
const alias = $derived(page.params.alias ?? '');
@@ -62,19 +49,9 @@
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
return bytes / 1024;
}
/** Mihomo hub/route/proxies.go: без query `url` URLTest часто даёт delay=0 → HTTP 503. */
const MIHOMO_DELAY_DEFAULT_URL = 'https://www.gstatic.com/generate_204';
let lastTick = $state<number | null>(null);
let pollTimer: ReturnType<typeof setInterval> | 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);
/** Mihomo отдаёт upTotal/downTotal в потоке /traffic — используем их; иначе накапливаем из up/down. */
function recordRates(
now: number,
@@ -116,12 +93,6 @@
}
}
function delayQuery(testUrl?: string) {
const u =
testUrl && String(testUrl).trim() !== '' ? String(testUrl).trim() : MIHOMO_DELAY_DEFAULT_URL;
return `timeout=5000&url=${encodeURIComponent(u)}`;
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
@@ -520,101 +491,6 @@
};
});
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();
});
/** Группы, у которых есть `all` + `now` и PUT /proxies/{name} переключает узел (как в Clash/Mihomo). */
function isSelectableGroup(p: MihomoProxyEntry) {
const t = (p.type ?? '').toLowerCase();
if (t === 'selector' || t === 'urltest' || t === 'fallback') return true;
if (t === 'loadbalance' && Array.isArray(p.all) && p.all.length > 0) return true;
return false;
}
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 {
const testUrl = proxiesData?.proxies?.[name]?.testUrl;
await fetchMihomoJson(
a,
`proxies/${encodeURIComponent(name)}/delay?${delayQuery(testUrl)}`
);
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;
const groupTestUrl = proxiesData?.proxies?.[groupName]?.testUrl;
try {
await fetchMihomoJson(
a,
`group/${encodeURIComponent(groupName)}/delay?${delayQuery(groupTestUrl)}`
);
await loadProxies();
} catch {
try {
const g = proxiesData?.proxies?.[groupName];
const names = g?.all ?? [];
for (const n of names) {
const u = proxiesData?.proxies?.[n]?.testUrl;
await fetchMihomoJson(a, `proxies/${encodeURIComponent(n)}/delay?${delayQuery(u)}`);
}
await loadProxies();
} catch (e) {
proxiesErr = e instanceof ApiError ? e.message : String(e);
}
} finally {
testingGroup = null;
}
}
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
@@ -734,84 +610,5 @@
</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)) 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>
<MihomoProxyGroups alias={alias} />
{/if}