Files
telemt-api/web/src/lib/components/mihomo/mihomo-proxy-groups.svelte
T
Denozordec e2d89f2d11
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m35s
Add Mihomo menu item and refactor page component
- 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.
2026-03-31 16:51:26 +07:00

218 lines
6.9 KiB
Svelte

<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>