Refactor Mihomo overview charts and statistics display
Publish telemt-api gateway Docker image / test (push) Successful in 31s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m15s

- Replaced Card components with KpiPanelCard and KpiStatCard for a more consistent and visually appealing layout.
- Updated chart and statistics sections to include descriptive footers and icons, enhancing user experience and clarity.
- Improved grid layout for better responsiveness and spacing in the overview section.
This commit is contained in:
Denozordec
2026-03-31 13:31:55 +07:00
parent 8da50bd254
commit deb12e72c0
2 changed files with 149 additions and 97 deletions
@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import Chart from 'chart.js/auto'; import Chart from 'chart.js/auto';
import * as Card from '$lib/components/ui/card/index.js'; import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ActivityIcon from '@lucide/svelte/icons/activity'; import ActivityIcon from '@lucide/svelte/icons/activity';
import ChartPieIcon from '@lucide/svelte/icons/chart-pie';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
import PlugIcon from '@lucide/svelte/icons/plug';
import NetworkIcon from '@lucide/svelte/icons/network'; import NetworkIcon from '@lucide/svelte/icons/network';
import GaugeIcon from '@lucide/svelte/icons/gauge'; import GaugeIcon from '@lucide/svelte/icons/gauge';
@@ -167,58 +170,76 @@
</script> </script>
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<Card.Root> <KpiPanelCard
<Card.Header> accent="teal"
<Card.Title class="flex items-center gap-2 text-base"> title="Трафик"
<ActivityIcon class="size-4" /> Трафик description="Скорость загрузки и скачивания (история из /traffic)"
</Card.Title> headerClass="pb-2"
</Card.Header> contentClass="h-56 px-4 pb-4 pt-0"
<Card.Content class="h-56"> >
{#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas> <canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root> <KpiPanelCard
<Card.Root> accent="info"
<Card.Header> title="Поток (накоплено)"
<Card.Title class="text-base">Поток (накоплено)</Card.Title> description="Соотношение накопленного down / up"
</Card.Header> headerClass="pb-2"
<Card.Content class="h-56"> contentClass="h-56 px-4 pb-4 pt-0"
>
{#snippet icon()}
<ChartPieIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elFlow} class="max-h-full w-full"></canvas> <canvas bind:this={elFlow} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root> <KpiPanelCard
<Card.Root> accent="violet"
<Card.Header> title="Память (KiB)"
<Card.Title class="text-base">Память (KB)</Card.Title> description="Использование по снимкам (ось — KiB)"
</Card.Header> headerClass="pb-2"
<Card.Content class="h-56"> contentClass="h-56 px-4 pb-4 pt-0"
>
{#snippet icon()}
<MemoryStickIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elMem} class="max-h-full w-full"></canvas> <canvas bind:this={elMem} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root> <KpiPanelCard
<Card.Root> accent="success"
<Card.Header> title="Подключения"
<Card.Title class="text-base">Подключения</Card.Title> description="Число активных соединений во времени"
</Card.Header> headerClass="pb-2"
<Card.Content class="h-56"> contentClass="h-56 px-4 pb-4 pt-0"
>
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elConn} class="max-h-full w-full"></canvas> <canvas bind:this={elConn} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root> <KpiPanelCard
<Card.Root> accent="warning"
<Card.Header> title="Типы сети"
<Card.Title class="flex items-center gap-2 text-base"> description="TCP и UDP по текущему снимку"
<NetworkIcon class="size-4" /> Типы сети headerClass="pb-2"
</Card.Title> contentClass="h-56 px-4 pb-4 pt-0"
</Card.Header> >
<Card.Content class="h-56"> {#snippet icon()}
<NetworkIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elNet} class="max-h-full w-full"></canvas> <canvas bind:this={elNet} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root> <KpiPanelCard
<Card.Root> accent="neutral"
<Card.Header> title="Топ прокси (по сессиям)"
<Card.Title class="flex items-center gap-2 text-base"> description="Распределение по имени прокси"
<GaugeIcon class="size-4" /> Топ прокси (по сессиям) headerClass="pb-2"
</Card.Title> contentClass="h-56 px-4 pb-4 pt-0"
</Card.Header> >
<Card.Content class="h-56"> {#snippet icon()}
<GaugeIcon class="size-5" aria-hidden="true" />
{/snippet}
<canvas bind:this={elTop} class="max-h-full w-full"></canvas> <canvas bind:this={elTop} class="max-h-full w-full"></canvas>
</Card.Content> </KpiPanelCard>
</Card.Root>
</div> </div>
@@ -18,7 +18,14 @@
} from '$lib/api/mihomo-types.js'; } 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 { Button } from '$lib/components/ui/button/index.js'; 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 { 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';
@@ -629,14 +636,40 @@
<DataQueryState err={metaErr} showSkeleton={loadingMeta} isEmpty={false}> <DataQueryState err={metaErr} showSkeleton={loadingMeta} isEmpty={false}>
{#snippet skeleton()} {#snippet skeleton()}
<Skeleton class="mb-4 h-6 w-96 max-w-full" /> <Skeleton class="mb-4 h-6 w-96 max-w-full" />
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{#each Array.from({ length: 6 }) as _, i (i)} {#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-24 rounded-lg" /> <Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<div class="flex gap-3">
<Skeleton class="size-10 shrink-0 rounded-lg" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-3 w-24" />
<Skeleton class="h-8 w-20" />
</div>
</div>
</Card.Header>
<Card.Content class="pt-0">
<Skeleton class="h-3 w-32" />
</Card.Content>
</Card.Root>
{/each} {/each}
</div> </div>
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
{#each Array.from({ length: 6 }) as _, i (i)} {#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-56 rounded-lg" /> <Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<div class="flex gap-3">
<Skeleton class="size-10 shrink-0 rounded-lg" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-4 w-40" />
<Skeleton class="h-3 w-full max-w-xs" />
</div>
</div>
</Card.Header>
<Card.Content class="h-56 px-4 pb-4 pt-0">
<Skeleton class="h-full w-full rounded-md" />
</Card.Content>
</Card.Root>
{/each} {/each}
</div> </div>
{/snippet} {/snippet}
@@ -647,45 +680,43 @@
</p> </p>
{/if} {/if}
{#if tab === 'overview' && !metaErr} {#if tab === 'overview' && !metaErr}
<div class="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<Card.Root class="border-l-primary border-l-4 py-3"> <KpiStatCard accent="violet" label="Загрузка" footer="мгновенная скорость, /traffic" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Загрузка</Card.Description> <ArrowBigUpIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold">{formatRatePerSec(upBps)}</Card.Content> {formatRatePerSec(upBps)}
</Card.Root> </KpiStatCard>
<Card.Root class="border-l-sky-500 border-l-4 py-3"> <KpiStatCard accent="info" label="Скачивание" footer="мгновенная скорость, /traffic" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Скачивание</Card.Description> <ArrowBigDownIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold">{formatRatePerSec(downBps)}</Card.Content> {formatRatePerSec(downBps)}
</Card.Root> </KpiStatCard>
<Card.Root class="border-l-amber-500 border-l-4 py-3"> <KpiStatCard accent="warning" label="Всего загружено" footer="uploadTotal (снимок)" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Всего загружено</Card.Description> <CloudUploadIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold">{formatBytes(totalUp)}</Card.Content> {formatBytes(totalUp)}
</Card.Root> </KpiStatCard>
<Card.Root class="border-l-cyan-500 border-l-4 py-3"> <KpiStatCard accent="teal" label="Всего скачано" footer="downloadTotal (снимок)" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Всего скачано</Card.Description> <CloudDownloadIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold">{formatBytes(totalDown)}</Card.Content> {formatBytes(totalDown)}
</Card.Root> </KpiStatCard>
<Card.Root class="border-l-emerald-500 border-l-4 py-3"> <KpiStatCard accent="info" label="Активные соединения" footer="список /connections" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Активные соединения</Card.Description> <PlugIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold">{connTotal}</Card.Content> {connTotal}
</Card.Root> </KpiStatCard>
<Card.Root class="border-l-violet-500 border-l-4 py-3"> <KpiStatCard accent="neutral" label="Память" footer="/memory или поле memory в WS" valueClass="text-xl">
<Card.Header class="pb-1"> {#snippet icon()}
<Card.Description>Память</Card.Description> <MemoryStickIcon class="size-5" aria-hidden="true" />
</Card.Header> {/snippet}
<Card.Content class="text-lg font-semibold">
{memKiB > 0 ? `${(memKiB / 1024).toFixed(1)} MiB` : '—'} {memKiB > 0 ? `${(memKiB / 1024).toFixed(1)} MiB` : '—'}
</Card.Content> </KpiStatCard>
</Card.Root>
</div> </div>
<MihomoOverviewCharts <MihomoOverviewCharts
histUp={histUp} histUp={histUp}