Enhance Mihomo proxy groups component with new features and improved UI
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m12s

- Added new props to the MihomoProxyGroups component for better control over loading states and toolbar visibility.
- Implemented a refresh button in the Mihomo page to manually trigger proxy updates, enhancing user interaction.
- Updated the layout and styling of proxy groups for improved readability and user experience.
- Enhanced loading indicators to provide clearer feedback during data fetching operations.
This commit is contained in:
Denozordec
2026-03-31 17:42:21 +07:00
parent 86a5360f56
commit 2fd98f8c3d
2 changed files with 126 additions and 60 deletions
@@ -7,14 +7,24 @@
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { cn } from '$lib/utils.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ActivityIcon from '@lucide/svelte/icons/activity';
let {
alias,
/** Плотная сетка мелких ячеек — для флота и списков из многих нод. */
compact = false
compact = false,
/** Скрыть верхнюю панель «Обновить» (кнопка в Card.Action у родителя). */
hideToolbar = false,
/** Увеличьте, чтобы принудительно перезагрузить список снаружи. */
refreshNonce = 0,
/** Состояние загрузки для индикатора у родителя. */
onLoadingChange
}: {
alias: string;
compact?: boolean;
hideToolbar?: boolean;
refreshNonce?: number;
onLoadingChange?: (loading: boolean) => void;
} = $props();
const MIHOMO_DELAY_DEFAULT_URL = 'https://www.gstatic.com/generate_204';
@@ -36,6 +46,7 @@
const a = alias;
if (!a) return;
proxiesLoading = true;
onLoadingChange?.(true);
proxiesErr = null;
try {
proxiesData = await fetchMihomoJson<MihomoProxiesResponse>(a, 'proxies');
@@ -44,11 +55,13 @@
// не затираем успешный снимок при сбое обновления — без мерцания UI
} finally {
proxiesLoading = false;
onLoadingChange?.(false);
}
}
$effect(() => {
const a = alias;
void refreshNonce;
if (!a) return;
void loadProxies();
});
@@ -136,26 +149,28 @@
}
</script>
<div
class={cn(
'flex flex-wrap items-center justify-end gap-1.5',
compact ? 'mb-2' : 'mb-4'
)}
>
<Button
variant="ghost"
size="sm"
class={cn(compact && 'h-7 px-2 text-xs')}
onclick={() => loadProxies()}
disabled={proxiesLoading || !alias}
{#if !hideToolbar}
<div
class={cn(
'flex flex-wrap items-center justify-end gap-1.5',
compact ? 'mb-2' : 'mb-4'
)}
>
<RefreshCwIcon
class={cn('mr-1 size-3.5 shrink-0', proxiesLoading && 'animate-spin')}
aria-hidden="true"
/>
Обновить
</Button>
</div>
<Button
variant="outline"
size="sm"
class={cn(compact ? 'h-7 gap-1.5 px-2 text-xs' : 'gap-2')}
onclick={() => loadProxies()}
disabled={proxiesLoading || !alias}
>
<RefreshCwIcon
class={cn('size-3.5 shrink-0', proxiesLoading && 'animate-spin')}
aria-hidden="true"
/>
Обновить
</Button>
</div>
{/if}
<DataQueryState
err={proxiesErr}
@@ -165,9 +180,9 @@
>
{#snippet skeleton()}
{#if compact}
<div class="flex flex-wrap gap-1">
{#each Array.from({ length: 12 }) as _, i (i)}
<Skeleton class="h-7 w-24 rounded-md" />
<div class="flex flex-wrap gap-1.5">
{#each Array.from({ length: 10 }) as _, i (i)}
<Skeleton class="h-8 w-[5.5rem] rounded-md" />
{/each}
</div>
{:else}
@@ -180,77 +195,87 @@
{/snippet}
{#snippet children()}
{#if proxiesData?.proxies}
<div class={cn('flex flex-col', compact ? 'gap-3' : 'gap-8')}>
<div class={cn('flex flex-col', compact ? 'gap-2' : 'gap-8')}>
{#each selectableGroups as [gName, group] (gName)}
{#if compact}
<div class="bg-muted/20 rounded-lg border px-2 py-2 sm:px-3">
<div class="mb-1.5 flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
<span class="text-xs font-semibold tracking-wide uppercase text-muted-foreground"
>{gName}</span
>
<span class="text-muted-foreground text-[11px] sm:text-xs">
<span class="text-foreground">{group.now ?? '—'}</span>
<div
class="bg-card/40 ring-border/60 space-y-2 rounded-lg p-2 ring-1 sm:p-2.5"
role="group"
aria-label="Группа {gName}"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
<Badge variant="outline" class="h-5 px-1.5 font-mono text-[10px] uppercase tracking-wide">
{gName}
</Badge>
<span class="text-muted-foreground text-xs">
<span class="text-foreground/90 font-medium">{group.now ?? '—'}</span>
</span>
</div>
<Button
variant="secondary"
variant="outline"
size="sm"
class="h-6 shrink-0 px-2 text-[11px]"
class="h-7 shrink-0 px-2 text-xs"
onclick={() => pingGroup(gName)}
disabled={testingGroup === gName}
>
{testingGroup === gName ? '…' : 'Все delay'}
</Button>
</div>
<div
class="flex flex-wrap gap-1"
role="group"
aria-label="Узлы группы {gName}"
>
<div class="flex flex-wrap gap-1.5" role="list">
{#each group.all ?? [] as nodeName (nodeName)}
{@const node = proxiesData.proxies?.[nodeName]}
{@const d = node ? lastDelay(node) : null}
{@const active = group.now === nodeName}
<div
class={cn(
'bg-background/80 flex min-w-0 max-w-full items-stretch rounded border text-left text-xs shadow-sm',
active ? 'border-primary ring-primary/30 ring-1' : 'border-border'
'bg-background focus-within:ring-ring flex min-w-0 max-w-full items-stretch overflow-hidden rounded-md border text-left text-xs shadow-sm transition-colors focus-within:ring-2',
active
? 'border-primary bg-accent/25'
: 'border-border hover:border-border/80 hover:bg-muted/40'
)}
role="listitem"
>
<button
type="button"
class="hover:bg-muted/60 min-w-0 flex-1 px-2 py-1 text-left transition-colors disabled:opacity-50"
class="hover:bg-muted/50 min-w-0 flex-1 px-2 py-1.5 text-left transition-colors disabled:opacity-50"
onclick={() => selectProxy(gName, nodeName)}
disabled={switching === gName}
title="Выбрать {nodeName}"
>
<div class="truncate font-medium leading-tight">{nodeName}</div>
<div class="truncate font-medium leading-none">{nodeName}</div>
<div
class="text-muted-foreground mt-0.5 flex items-center justify-between gap-1 text-[10px] leading-none"
class="text-muted-foreground mt-1 flex items-center justify-between gap-2 text-[10px] leading-none"
>
<span class="truncate">{node?.type ?? '—'}</span>
<span class="truncate opacity-90">{node?.type ?? '—'}</span>
<span
class={cn(
d != null && d < 500 ? 'text-emerald-500 shrink-0' : 'shrink-0'
'tabular-nums',
d != null && d < 500 ? 'text-emerald-600 dark:text-emerald-400' : ''
)}
>
{d != null ? `${d}` : '—'}<span class="text-muted-foreground/80">ms</span>
{d != null ? `${d}` : '—'}<span class="text-muted-foreground/70">ms</span>
</span>
</div>
</button>
<button
type="button"
class="text-muted-foreground hover:bg-muted hover:text-foreground border-border/80 border-l px-1.5 py-1 text-[10px] font-medium leading-none tabular-nums"
<Button
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-foreground size-8 shrink-0 rounded-none border-l px-0"
onclick={(ev) => {
ev.stopPropagation();
void pingOne(nodeName);
}}
disabled={testing === nodeName}
aria-label="Задержка для {nodeName}"
title="Проверить задержку"
>
{testing === nodeName ? '…' : 'ping'}
</button>
{#if testing === nodeName}
<span class="text-[10px]">…</span>
{:else}
<ActivityIcon class="size-3.5 opacity-70" aria-hidden="true" />
{/if}
</Button>
</div>
{/each}
</div>
+49 -8
View File
@@ -17,6 +17,7 @@
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 { cn } from '$lib/utils.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ServerIcon from '@lucide/svelte/icons/server';
import PlugIcon from '@lucide/svelte/icons/plug';
@@ -48,6 +49,13 @@
/** Список alias с рабочим meta; обновляется только при полной перезагрузке. */
let mihomoOkAliases: string[] = [];
let pollTimer: ReturnType<typeof setInterval> | null = null;
/** Принудительная перезагрузка /proxies для карточки ноды. */
let proxyRefreshNonce = $state<Record<string, number>>({});
let nodeProxyLoading = $state<Record<string, boolean>>({});
function bumpProxyRefresh(a: string) {
proxyRefreshNonce = { ...proxyRefreshNonce, [a]: (proxyRefreshNonce[a] ?? 0) + 1 };
}
let withMihomo = $derived(rows.filter((r) => r.metaStatus === 'ok').length);
let aliveCount = $derived(rows.filter((r) => r.metaStatus === 'ok' && r.alive).length);
@@ -325,8 +333,9 @@
<div class="mt-8 space-y-3">
<div>
<h2 class="text-base font-semibold tracking-tight">Прокси по нодам</h2>
<p class="text-muted-foreground mt-0.5 text-xs">
Список групп подгружается автоматически. Клик по ячейке — выбор узла; «ping» — задержка.
<p class="text-muted-foreground mt-0.5 max-w-2xl text-xs leading-relaxed">
Автозагрузка групп. Клик по узлу — выбор; иконка справа — проверка задержки. Обновление списка — в
шапке карточки.
</p>
</div>
<div
@@ -336,18 +345,50 @@
<Card.Root
id="mihomo-proxies-{encodeURIComponent(r.alias)}"
data-size="sm"
class="border-l-4 border-l-primary/30 min-h-0 min-w-0 scroll-mt-20 shadow-sm"
class="bg-card/50 min-h-0 min-w-0 scroll-mt-20 gap-0 overflow-hidden pt-0 shadow-sm ring-1 ring-border/60"
>
<Card.Header class="space-y-0 pb-1 pt-3">
<Card.Title class="text-sm font-semibold">{r.alias}</Card.Title>
<Card.Header class="border-border/80 border-b px-3 pb-3 pt-3 sm:px-4">
<Card.Title class="font-mono text-sm font-semibold leading-none tracking-tight">
{r.alias}
</Card.Title>
{#if r.meta?.controller_base}
<Card.Description class="font-mono text-[11px] leading-snug">
<Card.Description
class="text-muted-foreground line-clamp-2 pt-1 font-mono text-[10px] leading-snug"
>
{r.meta.controller_base}
</Card.Description>
{/if}
<Card.Action>
<Button
variant="outline"
size="icon"
class="size-8 shrink-0"
aria-label="Обновить прокси для {r.alias}"
disabled={!!nodeProxyLoading[r.alias]}
onclick={() => bumpProxyRefresh(r.alias)}
>
<RefreshCwIcon
class={cn(
'size-3.5',
nodeProxyLoading[r.alias] ? 'animate-spin' : ''
)}
aria-hidden="true"
/>
</Button>
</Card.Action>
</Card.Header>
<Card.Content class="px-3 pb-3 pt-0 sm:px-4">
<MihomoProxyGroups alias={r.alias} compact={true} />
<Card.Content class="px-0 pb-0 pt-0">
<div class="px-2 py-2 sm:px-3 sm:py-2.5">
<MihomoProxyGroups
alias={r.alias}
compact={true}
hideToolbar={true}
refreshNonce={proxyRefreshNonce[r.alias] ?? 0}
onLoadingChange={(v) => {
nodeProxyLoading = { ...nodeProxyLoading, [r.alias]: v };
}}
/>
</div>
</Card.Content>
</Card.Root>
{/each}