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}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</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.Menu>
|
||||||
</Sidebar.Group>
|
</Sidebar.Group>
|
||||||
{#if serverAliases.length > 0}
|
{#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 { page } from '$app/state';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import {
|
import { ApiError, fetchMihomoJson, fetchMihomoMeta, mihomoUrl, mihomoWsUrl } from '$lib/api/client.js';
|
||||||
ApiError,
|
import type { MihomoConnectionsResponse, MihomoMetaResponse } from '$lib/api/mihomo-types.js';
|
||||||
fetchMihomoJson,
|
|
||||||
fetchMihomoMeta,
|
|
||||||
mihomoPut,
|
|
||||||
mihomoUrl,
|
|
||||||
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 { formatBytes, formatRatePerSec } from '$lib/format.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
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 ArrowBigUpIcon from '@lucide/svelte/icons/arrow-big-up';
|
||||||
import ArrowBigDownIcon from '@lucide/svelte/icons/arrow-big-down';
|
import ArrowBigDownIcon from '@lucide/svelte/icons/arrow-big-down';
|
||||||
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
|
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
|
||||||
import CloudDownloadIcon from '@lucide/svelte/icons/cloud-download';
|
import CloudDownloadIcon from '@lucide/svelte/icons/cloud-download';
|
||||||
import PlugIcon from '@lucide/svelte/icons/plug';
|
import PlugIcon from '@lucide/svelte/icons/plug';
|
||||||
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
|
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 * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||||
import MihomoOverviewCharts from '$lib/components/mihomo/mihomo-overview-charts.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';
|
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||||
|
|
||||||
const alias = $derived(page.params.alias ?? '');
|
const alias = $derived(page.params.alias ?? '');
|
||||||
@@ -62,19 +49,9 @@
|
|||||||
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
|
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
|
||||||
return bytes / 1024;
|
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 lastTick = $state<number | null>(null);
|
||||||
let pollTimer: ReturnType<typeof setInterval> | 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. */
|
/** Mihomo отдаёт upTotal/downTotal в потоке /traffic — используем их; иначе накапливаем из up/down. */
|
||||||
function recordRates(
|
function recordRates(
|
||||||
now: number,
|
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> {
|
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal.aborted) {
|
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(() => {
|
onDestroy(() => {
|
||||||
if (pollTimer) clearInterval(pollTimer);
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
});
|
});
|
||||||
@@ -734,84 +610,5 @@
|
|||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|
||||||
{#if tab === 'proxies'}
|
{#if tab === 'proxies'}
|
||||||
<div class="mb-4 flex justify-end">
|
<MihomoProxyGroups alias={alias} />
|
||||||
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user