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.
This commit is contained in:
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user