Enhance dashboard KPI components with dynamic styling and icons
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m12s

- Introduced a new `kpiAccent` configuration to manage dynamic styling for various KPI components based on their status (success, warning, danger, etc.).
- Added multiple icons to represent different metrics visually, improving user experience and accessibility.
- Updated the dashboard layout to utilize the new styling and icons, ensuring a consistent and informative presentation of server health, connections, and traffic metrics.
- Implemented derived states for health and connection metrics to reflect real-time data changes effectively.
This commit is contained in:
Denozordec
2026-03-30 12:09:34 +07:00
parent 42cb85308e
commit fe718b7e21
+177 -21
View File
@@ -15,8 +15,56 @@
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
import ServerIcon from '@lucide/svelte/icons/server';
import UsersIcon from '@lucide/svelte/icons/users';
import UnplugIcon from '@lucide/svelte/icons/unplug';
import GlobeIcon from '@lucide/svelte/icons/globe';
import ActivityIcon from '@lucide/svelte/icons/activity';
import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole';
/** Семантика дашборда: граница + иконка + при необходимости цвет числа (WCAG: акцент не только цветом — иконка + подпись). */
const kpiAccent = {
success: {
border: 'border-l-emerald-500',
iconWrap: 'bg-emerald-500/15 text-emerald-400',
value: ''
},
warning: {
border: 'border-l-amber-500',
iconWrap: 'bg-amber-500/15 text-amber-400',
value: 'text-amber-400'
},
danger: {
border: 'border-l-red-500',
iconWrap: 'bg-red-500/15 text-red-400',
value: 'text-red-400'
},
info: {
border: 'border-l-sky-500',
iconWrap: 'bg-sky-500/15 text-sky-400',
value: ''
},
violet: {
border: 'border-l-violet-500',
iconWrap: 'bg-violet-500/15 text-violet-400',
value: ''
},
teal: {
border: 'border-l-teal-500',
iconWrap: 'bg-teal-500/15 text-teal-400',
value: ''
},
neutral: {
border: 'border-l-muted-foreground/35',
iconWrap: 'bg-muted text-muted-foreground',
value: ''
}
} as const;
type KpiAccentKey = keyof typeof kpiAccent;
let loading = $state(true);
let err = $state<string | null>(null);
@@ -120,6 +168,26 @@
return n;
});
let nodesHealthAccent = $derived.by((): KpiAccentKey => {
const t = (fleet?.servers_total ?? summary?.servers_total) ?? 0;
const ok = (fleet?.servers_all_ok ?? summary?.servers_ok) ?? 0;
if (t === 0) return 'neutral';
if (ok === t) return 'success';
if (ok === 0) return 'danger';
return 'warning';
});
/** Пороги условные: 0 — норма; дальше — внимание и «сильное» внимание к метрике bad. */
let badConnectionsAccent = $derived.by((): KpiAccentKey => {
if (totalBad === 0) return 'success';
if (totalBad < 10_000) return 'warning';
return 'danger';
});
let readOnlyAccent = $derived.by((): KpiAccentKey =>
readOnlyNodes === 0 ? 'success' : 'warning'
);
function chipLabel(ip: components['schemas']['IPAssignments']): string {
const cc = ip.country_code?.trim();
const flag = flagEmoji(cc ?? undefined);
@@ -165,47 +233,135 @@
<p class="text-muted-foreground">Загрузка…</p>
{:else if summary}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<Card.Root>
<Card.Root
class={cn('border-l-4 shadow-sm transition-colors', kpiAccent[nodesHealthAccent].border)}
>
<Card.Header class="pb-2">
<Card.Description>Ноды</Card.Description>
<Card.Title class="text-2xl tabular-nums">
{fleet?.servers_all_ok ?? summary.servers_ok}/{fleet?.servers_total ?? summary.servers_total}
</Card.Title>
<div class="flex gap-3">
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent[nodesHealthAccent].iconWrap
)}
>
<ServerIcon class="size-5" aria-hidden="true" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<Card.Description>Ноды</Card.Description>
<Card.Title
class={cn(
'text-2xl tabular-nums',
kpiAccent[nodesHealthAccent].value
)}
>
{fleet?.servers_all_ok ?? summary.servers_ok}/{fleet?.servers_total ??
summary.servers_total}
</Card.Title>
</div>
</div>
</Card.Header>
<Card.Content class="text-xs text-muted-foreground">успешный опрос</Card.Content>
</Card.Root>
<Card.Root>
<Card.Root class={cn('border-l-4 shadow-sm', kpiAccent.info.border)}>
<Card.Header class="pb-2">
<Card.Description>Соединения (флот)</Card.Description>
<Card.Title class="text-2xl tabular-nums">{summary.fleet_total_connections}</Card.Title>
<div class="flex gap-3">
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent.info.iconWrap
)}
>
<UsersIcon class="size-5" aria-hidden="true" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<Card.Description>Соединения (флот)</Card.Description>
<Card.Title class="text-2xl tabular-nums">{summary.fleet_total_connections}</Card.Title>
</div>
</div>
</Card.Header>
<Card.Content class="text-xs text-muted-foreground">текущие по stats/users</Card.Content>
</Card.Root>
<Card.Root>
<Card.Root class={cn('border-l-4 shadow-sm', kpiAccent[badConnectionsAccent].border)}>
<Card.Header class="pb-2">
<Card.Description>Bad connections</Card.Description>
<Card.Title class="text-2xl tabular-nums">{totalBad}</Card.Title>
<div class="flex gap-3">
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent[badConnectionsAccent].iconWrap
)}
>
<UnplugIcon class="size-5" aria-hidden="true" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<Card.Description>Bad connections</Card.Description>
<Card.Title
class={cn('text-2xl tabular-nums', kpiAccent[badConnectionsAccent].value)}
>
{totalBad}
</Card.Title>
</div>
</div>
</Card.Header>
<Card.Content class="text-xs text-muted-foreground">сумма по нодам (stats/summary)</Card.Content>
</Card.Root>
<Card.Root>
<Card.Root class={cn('border-l-4 shadow-sm', kpiAccent.violet.border)}>
<Card.Header class="pb-2">
<Card.Description>Уникальные IP</Card.Description>
<Card.Title class="text-2xl tabular-nums">{uniqueIpCount}</Card.Title>
<div class="flex gap-3">
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent.violet.iconWrap
)}
>
<GlobeIcon class="size-5" aria-hidden="true" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<Card.Description>Уникальные IP</Card.Description>
<Card.Title class="text-2xl tabular-nums">{uniqueIpCount}</Card.Title>
</div>
</div>
</Card.Header>
<Card.Content class="text-xs text-muted-foreground">по снимку unique-ips</Card.Content>
</Card.Root>
<Card.Root>
<Card.Root class={cn('border-l-4 shadow-sm', kpiAccent.teal.border)}>
<Card.Header class="pb-2">
<Card.Description>Трафик флота</Card.Description>
<Card.Title class="text-2xl tabular-nums">{formatMiB(summary.fleet_total_megabytes)}</Card.Title>
<div class="flex gap-3">
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent.teal.iconWrap
)}
>
<ActivityIcon class="size-5" aria-hidden="true" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<Card.Description>Трафик флота</Card.Description>
<Card.Title class="text-2xl tabular-nums">{formatMiB(summary.fleet_total_megabytes)}</Card.Title>
</div>
</div>
</Card.Header>
<Card.Content class="text-xs text-muted-foreground">сумма MiB</Card.Content>
</Card.Root>
<Card.Root>
<Card.Root class={cn('border-l-4 shadow-sm', kpiAccent[readOnlyAccent].border)}>
<Card.Header class="pb-2">
<Card.Description>Read-only API</Card.Description>
<Card.Title class="text-2xl tabular-nums">{readOnlyNodes}</Card.Title>
<div class="flex gap-3">
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent[readOnlyAccent].iconWrap
)}
>
<LockKeyholeIcon class="size-5" aria-hidden="true" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<Card.Description>Read-only API</Card.Description>
<Card.Title
class={cn('text-2xl tabular-nums', kpiAccent[readOnlyAccent].value)}
>
{readOnlyNodes}
</Card.Title>
</div>
</div>
</Card.Header>
<Card.Content class="text-xs text-muted-foreground">нод с read_only</Card.Content>
</Card.Root>
@@ -280,7 +436,7 @@
{@const st = nodeStats[srv.alias]}
{#if !st}
<span title="Нет ответа stats/summary для этой ноды"></span>
{:else if 'ok' in st && st.ok === false}
{:else if st && 'ok' in st && !st.ok && 'error' in st}
<span class="text-xs text-muted-foreground" title={st.error}>—</span>
{:else if st && 'bad' in st && st.ok}
{st.bad ?? 0}