Enhance dashboard KPI components with dynamic styling and icons
- 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:
+177
-21
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user