Standardize server dashboard KPI style to match Mihomo.
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m16s

Switch server overview KPI block to KpiStatCard with accent icons, align header copy/layout, and sync skeleton grid density with the Mihomo pattern.

Made-with: Cursor
This commit is contained in:
Denozordec
2026-04-09 23:20:37 +07:00
parent 5564ab8a62
commit 7c731b93c1
+77 -50
View File
@@ -3,11 +3,14 @@
import { ApiError, fetchStatsSummary, fetchTelemt } from '$lib/api/client.js';
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
import { formatDurationSeconds } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiTrendCard from '$lib/components/kpi-trend-card.svelte';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ClockIcon from '@lucide/svelte/icons/clock';
import CpuIcon from '@lucide/svelte/icons/cpu';
import PlugIcon from '@lucide/svelte/icons/plug';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import TimerOffIcon from '@lucide/svelte/icons/timer-off';
import UsersIcon from '@lucide/svelte/icons/users';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
@@ -41,6 +44,18 @@
return 0;
}
function trendFooter(current: number | null, prev: number | null, invert = false): string {
if (current == null || prev == null || !Number.isFinite(current) || !Number.isFinite(prev)) {
return 'первое обновление';
}
const delta = current - prev;
if (delta === 0) return 'к прошлому обновлению: без изменений';
const sign = delta > 0 ? '+' : '-';
const absDelta = Math.abs(delta);
const mood = invert ? (delta < 0 ? 'лучше' : 'хуже') : delta > 0 ? 'рост' : 'снижение';
return `к прошлому обновлению: ${sign}${absDelta} (${mood})`;
}
async function loadFor(a: string) {
loading = true;
err = null;
@@ -93,10 +108,12 @@
let handshakeT = $derived(num(summary?.handshake_timeouts_total));
</script>
<div class="mb-6 flex items-center justify-between gap-4">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p class="font-mono text-sm text-muted-foreground">{alias}</p>
<h1 class="text-2xl font-semibold tracking-tight">Сервер: обзор</h1>
<p class="text-sm text-muted-foreground">
Ключевые метрики и состояние Telemt для <span class="font-mono">{alias}</span>.
</p>
</div>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
@@ -112,21 +129,12 @@
partial={false}
>
{#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each Array.from({ length: 4 }) as _, i (i)}
<Card.Root class="border border-border shadow-sm">
<Card.Header class="space-y-2 pb-2">
<Skeleton class="h-3 w-24" />
<Skeleton class="h-8 w-20" />
</Card.Header>
<Skeleton class="mx-4 h-px" />
<Card.Content class="pt-3">
<Skeleton class="h-3 w-full" />
</Card.Content>
</Card.Root>
<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-40 w-full rounded-lg" />
<Skeleton class="h-64 w-full rounded-lg" />
{/snippet}
{#snippet children()}
{#if health?.status === 'ok'}
@@ -139,49 +147,68 @@
{/if}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<KpiTrendCard
<KpiStatCard
accent="violet"
label="Аптайм"
valueDisplay={sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
prevValue={kpiPrev?.uptime ?? null}
currentValue={sys ? uptimeSec : null}
insight="Секунды uptime с ноды; к прошлому обновлению страницы."
/>
<KpiTrendCard
footer={trendFooter(sys ? uptimeSec : null, kpiPrev?.uptime ?? null)}
valueClass="text-xl"
>
{#snippet icon()}
<ClockIcon class="size-5" aria-hidden="true" />
{/snippet}
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
</KpiStatCard>
<KpiStatCard
accent="info"
label="Соединения (всего)"
valueDisplay={summary ? String(connTotal) : '—'}
prevValue={kpiPrev?.conn ?? null}
currentValue={summary ? connTotal : null}
insight="stats/summary с ноды."
/>
<KpiTrendCard
footer={trendFooter(summary ? connTotal : null, kpiPrev?.conn ?? null)}
valueClass="text-xl"
>
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary ? String(connTotal) : '—'}
</KpiStatCard>
<KpiStatCard
accent="danger"
label="Bad connections"
valueDisplay={summary ? String(badTotal) : '—'}
prevValue={kpiPrev?.bad ?? null}
currentValue={summary ? badTotal : null}
invertTrend={true}
insight="Счётчик на ноде; меньше — лучше."
/>
<KpiTrendCard
footer={trendFooter(summary ? badTotal : null, kpiPrev?.bad ?? null, true)}
valueClass="text-xl"
>
{#snippet icon()}
<ShieldAlertIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary ? String(badTotal) : '—'}
</KpiStatCard>
<KpiStatCard
accent="warning"
label="Handshake timeouts"
valueDisplay={summary ? String(handshakeT) : '—'}
prevValue={kpiPrev?.handshake ?? null}
currentValue={summary ? handshakeT : null}
invertTrend={true}
insight="handshake_timeouts_total; меньше — лучше."
/>
<KpiTrendCard
footer={trendFooter(summary ? handshakeT : null, kpiPrev?.handshake ?? null, true)}
valueClass="text-xl"
>
{#snippet icon()}
<TimerOffIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary ? String(handshakeT) : '—'}
</KpiStatCard>
<KpiStatCard
accent="neutral"
label="Пользователей в конфиге"
valueDisplay={summary ? String(cfgUsers) : '—'}
prevValue={kpiPrev?.users ?? null}
currentValue={summary ? cfgUsers : null}
insight="configured_users."
/>
footer={trendFooter(summary ? cfgUsers : null, kpiPrev?.users ?? null)}
valueClass="text-xl"
>
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary ? String(cfgUsers) : '—'}
</KpiStatCard>
</div>
{#if sys}
<KpiPanelCard
accent="neutral"
title="Система"
description="Информация о версии, платформе и конфигурации ноды"
contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3"
>
{#snippet icon()}