Standardize server dashboard KPI style to match Mihomo.
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:
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user