Refactor Svelte components to utilize KpiStatCard and KpiPanelCard for improved UI consistency
Publish telemt-api gateway Docker image / test (push) Successful in 31s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m52s

- Replaced Card components with KpiStatCard and KpiPanelCard across various routes to enhance the visual presentation of key performance indicators and data summaries.
- Updated the structure of the dashboard and incident pages to streamline the display of critical information, improving user experience and readability.
- Enhanced loading states and skeleton displays for better feedback during data fetching processes.
This commit is contained in:
Denozordec
2026-03-31 00:12:28 +07:00
parent 992eebd842
commit bbd9619290
16 changed files with 441 additions and 384 deletions
@@ -0,0 +1,51 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { cn } from '$lib/utils.js';
import type { KpiAccentKey } from '$lib/kpi-accents.js';
import { kpiAccent } from '$lib/kpi-accents.js';
import type { Snippet } from 'svelte';
let {
accent,
title,
description,
class: className,
contentClass,
headerClass,
icon,
children
}: {
accent: KpiAccentKey;
title: string;
description?: string;
class?: string;
contentClass?: string;
headerClass?: string;
icon: Snippet;
children: Snippet;
} = $props();
</script>
<Card.Root class={cn('border-l-4 shadow-sm', kpiAccent[accent].border, className)}>
<Card.Header class={cn(headerClass)}>
<div class="flex gap-3">
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent[accent].iconWrap
)}
>
{@render icon()}
</div>
<div class="min-w-0 flex-1 space-y-1">
<Card.Title>{title}</Card.Title>
{#if description}
<Card.Description>{description}</Card.Description>
{/if}
</div>
</div>
</Card.Header>
<Card.Content class={contentClass}>
{@render children()}
</Card.Content>
</Card.Root>
@@ -0,0 +1,50 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { cn } from '$lib/utils.js';
import type { KpiAccentKey } from '$lib/kpi-accents.js';
import { kpiAccent } from '$lib/kpi-accents.js';
import type { Snippet } from 'svelte';
let {
accent,
label,
footer,
class: className,
valueClass,
icon,
children
}: {
accent: KpiAccentKey;
label: string;
footer?: string;
class?: string;
/** Дополнительные классы для значения (например `text-xl`). */
valueClass?: string;
icon: Snippet;
children: Snippet;
} = $props();
</script>
<Card.Root class={cn('border-l-4 shadow-sm transition-colors', kpiAccent[accent].border, className)}>
<Card.Header class="pb-2">
<div class="flex gap-3">
<div
class={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg',
kpiAccent[accent].iconWrap
)}
>
{@render icon()}
</div>
<div class="min-w-0 flex-1 space-y-1">
<Card.Description>{label}</Card.Description>
<Card.Title class={cn('text-2xl tabular-nums', kpiAccent[accent].value, valueClass)}>
{@render children()}
</Card.Title>
</div>
</div>
</Card.Header>
{#if footer}
<Card.Content class="text-xs text-muted-foreground">{footer}</Card.Content>
{/if}
</Card.Root>
+40
View File
@@ -0,0 +1,40 @@
/** Семантика KPI: граница + иконка + при необходимости цвет числа (WCAG: акцент не только цветом — иконка + подпись). */
export 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;
export type KpiAccentKey = keyof typeof kpiAccent;
+73 -201
View File
@@ -34,47 +34,10 @@
import ActivityIcon from '@lucide/svelte/icons/activity';
import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole';
import InfoIcon from '@lucide/svelte/icons/info';
/** Семантика дашборда: граница + иконка + при необходимости цвет числа (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;
import type { KpiAccentKey } from '$lib/kpi-accents.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
let loading = $state(true);
let err = $state<string | null>(null);
@@ -457,11 +420,19 @@
{#snippet skeleton()}
<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)}
<Card.Root>
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<Skeleton class="h-4 w-24" />
<Skeleton class="mt-2 h-8 w-16" />
<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-20" />
<Skeleton class="h-8 w-14" />
</div>
</div>
</Card.Header>
<Card.Content class="pt-0">
<Skeleton class="h-3 w-28" />
</Card.Content>
</Card.Root>
{/each}
</div>
@@ -470,147 +441,54 @@
{#snippet children()}
{#if summary}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<Card.Root
class={cn('border-l-4 shadow-sm transition-colors', kpiAccent[nodesHealthAccent].border)}
>
<Card.Header class="pb-2">
<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 class={cn('border-l-4 shadow-sm', kpiAccent.info.border)}>
<Card.Header class="pb-2">
<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 class={cn('border-l-4 shadow-sm', kpiAccent[badConnectionsAccent].border)}>
<Card.Header class="pb-2">
<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 class={cn('border-l-4 shadow-sm', kpiAccent.violet.border)}>
<Card.Header class="pb-2">
<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 class={cn('border-l-4 shadow-sm', kpiAccent.teal.border)}>
<Card.Header class="pb-2">
<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 class={cn('border-l-4 shadow-sm', kpiAccent[readOnlyAccent].border)}>
<Card.Header class="pb-2">
<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>
<KpiStatCard accent={nodesHealthAccent} label="Ноды" footer="успешный опрос">
{#snippet icon()}
<ServerIcon class="size-5" aria-hidden="true" />
{/snippet}
{fleet?.servers_all_ok ?? summary.servers_ok}/{fleet?.servers_total ?? summary.servers_total}
</KpiStatCard>
<KpiStatCard accent="info" label="Соединения (флот)" footer="текущие по stats/users">
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary.fleet_total_connections}
</KpiStatCard>
<KpiStatCard accent={badConnectionsAccent} label="Bad connections" footer="сумма по нодам (stats/summary)">
{#snippet icon()}
<UnplugIcon class="size-5" aria-hidden="true" />
{/snippet}
{totalBad}
</KpiStatCard>
<KpiStatCard accent="violet" label="Уникальные IP" footer="по снимку unique-ips">
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
{/snippet}
{uniqueIpCount}
</KpiStatCard>
<KpiStatCard accent="teal" label="Трафик флота" footer="сумма MiB">
{#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" />
{/snippet}
{formatMiB(summary.fleet_total_megabytes)}
</KpiStatCard>
<KpiStatCard accent={readOnlyAccent} label="Read-only API" footer="нод с read_only">
{#snippet icon()}
<LockKeyholeIcon class="size-5" aria-hidden="true" />
{/snippet}
{readOnlyNodes}
</KpiStatCard>
</div>
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Активные IP (агрегат)</Card.Title>
<Card.Description>С GeoIP при включённой базе на шлюзе</Card.Description>
</Card.Header>
<Card.Content>
<div class="overflow-x-auto">
<KpiPanelCard
class="mb-6"
accent="violet"
title="Активные IP (агрегат)"
description="С GeoIP при включённой базе на шлюзе"
>
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
{/snippet}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
@@ -670,16 +548,13 @@
{/if}
</Table.Body>
</Table.Root>
</div>
</Card.Content>
</Card.Root>
</div>
</KpiPanelCard>
<Card.Root>
<Card.Header>
<Card.Title>По нодам</Card.Title>
<Card.Description>Health, версия, аптайм, трафик ноды</Card.Description>
</Card.Header>
<Card.Content class="p-0">
<KpiPanelCard accent="info" title="По нодам" description="Health, версия, аптайм, трафик ноды" contentClass="p-0">
{#snippet icon()}
<ServerIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
@@ -737,14 +612,12 @@
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
</KpiPanelCard>
<Card.Root class="mt-6">
<Card.Header>
<Card.Title>Топ пользователей по трафику</Card.Title>
</Card.Header>
<Card.Content>
<KpiPanelCard class="mt-6" accent="teal" title="Топ пользователей по трафику" contentClass="">
{#snippet icon()}
<ChartNoAxesCombinedIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
@@ -769,8 +642,7 @@
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
</KpiPanelCard>
{/if}
{/snippet}
</DataQueryState>
+29 -22
View File
@@ -10,6 +10,12 @@
type IncidentsData
} from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import InfoSmallIcon from '@lucide/svelte/icons/info';
import ClipboardListIcon from '@lucide/svelte/icons/clipboard-list';
import * as Table from '$lib/components/ui/table/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
@@ -271,28 +277,30 @@
{/snippet}
{#snippet children()}
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Critical</Card.Description>
<Card.Title class="text-2xl text-red-500 tabular-nums">{data?.critical_total ?? 0}</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Warning</Card.Description>
<Card.Title class="text-2xl text-amber-500 tabular-nums">{data?.warning_total ?? 0}</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Info</Card.Description>
<Card.Title class="text-2xl tabular-nums">{data?.info_total ?? 0}</Card.Title>
</Card.Header>
</Card.Root>
<KpiStatCard accent="danger" label="Critical" footer="открытые critical">
{#snippet icon()}
<CircleAlertIcon class="size-5" aria-hidden="true" />
{/snippet}
{data?.critical_total ?? 0}
</KpiStatCard>
<KpiStatCard accent="warning" label="Warning" footer="открытые warning">
{#snippet icon()}
<TriangleAlertIcon class="size-5" aria-hidden="true" />
{/snippet}
{data?.warning_total ?? 0}
</KpiStatCard>
<KpiStatCard accent="info" label="Info" footer="открытые info">
{#snippet icon()}
<InfoSmallIcon class="size-5" aria-hidden="true" />
{/snippet}
{data?.info_total ?? 0}
</KpiStatCard>
</div>
<Card.Root>
<Card.Content class="p-0">
<KpiPanelCard accent="neutral" title="Инциденты" description="Список и triage" contentClass="p-0">
{#snippet icon()}
<ClipboardListIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
@@ -400,7 +408,6 @@
{/if}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
</KpiPanelCard>
{/snippet}
</DataQueryState>
+13 -12
View File
@@ -8,6 +8,9 @@
import 'leaflet/dist/leaflet.css';
import { flagEmoji } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import MapIcon from '@lucide/svelte/icons/map';
import GlobeIcon from '@lucide/svelte/icons/globe';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
@@ -502,12 +505,10 @@
{/snippet}
{#snippet children()}
{#if geo}
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Карта подключений</Card.Title>
<Card.Description>GeoIP координаты из снимка unique-ips</Card.Description>
</Card.Header>
<Card.Content class="p-0">
<KpiPanelCard class="mb-6" accent="teal" title="Карта подключений" description="GeoIP координаты из снимка unique-ips" contentClass="p-0">
{#snippet icon()}
<MapIcon class="size-5" aria-hidden="true" />
{/snippet}
<div class="h-[420px] w-full rounded-md" bind:this={mapEl}></div>
{#if mapPointsCount > 0}
<div class="px-4 pb-3 pt-2 text-xs text-muted-foreground">
@@ -519,8 +520,7 @@
{mapStatus}
</div>
{/if}
</Card.Content>
</Card.Root>
</KpiPanelCard>
{:else}
<Alert class="mb-6">
<AlertTitle>GeoIP выключен</AlertTitle>
@@ -528,8 +528,10 @@
</Alert>
{/if}
<Card.Root>
<Card.Content class="p-0">
<KpiPanelCard accent="violet" title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" contentClass="p-0">
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
@@ -588,8 +590,7 @@
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
</KpiPanelCard>
{/snippet}
</DataQueryState>
+41 -33
View File
@@ -9,7 +9,12 @@
type IncidentSeverity,
type IncidentStatus
} from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ActivityIcon from '@lucide/svelte/icons/activity';
import LayersIcon from '@lucide/svelte/icons/layers';
import ClockIcon from '@lucide/svelte/icons/clock';
import BellIcon from '@lucide/svelte/icons/bell';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
@@ -302,38 +307,42 @@
{/if}
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Общий статус</Card.Description>
<Card.Title>
<Badge variant={statusVariant(snapshot?.status)}>
{snapshot?.status ?? 'healthy'}
</Badge>
</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Partial</Card.Description>
<Card.Title class="text-2xl tabular-nums">{snapshot?.partial ? 'yes' : 'no'}</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Timestamp</Card.Description>
<Card.Title class="text-sm">
{snapshot?.timestamp ? new Date(snapshot.timestamp).toLocaleString() : '—'}
</Card.Title>
</Card.Header>
</Card.Root>
<KpiStatCard accent="teal" label="Общий статус" footer="агрегат snapshot">
{#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" />
{/snippet}
<Badge variant={statusVariant(snapshot?.status)}>
{snapshot?.status ?? 'healthy'}
</Badge>
</KpiStatCard>
<KpiStatCard accent="warning" label="Partial" footer="неполный снимок">
{#snippet icon()}
<LayersIcon class="size-5" aria-hidden="true" />
{/snippet}
{snapshot?.partial ? 'yes' : 'no'}
</KpiStatCard>
<KpiStatCard
accent="info"
label="Timestamp"
footer="время snapshot"
valueClass="text-base font-normal tabular-nums leading-tight"
>
{#snippet icon()}
<ClockIcon class="size-5" aria-hidden="true" />
{/snippet}
{snapshot?.timestamp ? new Date(snapshot.timestamp).toLocaleString() : '—'}
</KpiStatCard>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Последние incidents из snapshot</Card.Title>
<Card.Description>Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}</Card.Description>
</Card.Header>
<Card.Content class="p-0">
<KpiPanelCard
accent="danger"
title="Последние incidents из snapshot"
description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}"
contentClass="p-0"
>
{#snippet icon()}
<BellIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
@@ -381,5 +390,4 @@
{/if}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
</KpiPanelCard>
+53 -36
View File
@@ -8,6 +8,13 @@
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
import { formatDurationSeconds, formatMiB } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
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 PlugIcon from '@lucide/svelte/icons/plug';
import UnplugIcon from '@lucide/svelte/icons/unplug';
import UsersIcon from '@lucide/svelte/icons/users';
import CpuIcon from '@lucide/svelte/icons/cpu';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
@@ -56,6 +63,12 @@
let hasStaleData = $derived(err != null && (health != null || summary != null));
let showSkeleton = $derived(loading && !err && health == null && summary == null);
let badConnAccent = $derived.by((): 'danger' | 'success' => {
const n = summary?.connections_bad_total;
if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) return 'success';
return 'danger';
});
</script>
<div class="mb-6 flex items-center justify-between gap-4">
@@ -79,11 +92,19 @@
{#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>
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<Skeleton class="h-4 w-24" />
<Skeleton class="mt-2 h-8 w-16" />
<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}
</div>
@@ -100,40 +121,37 @@
{/if}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Аптайм</Card.Description>
<Card.Title class="text-xl tabular-nums">
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Соединения (всего)</Card.Description>
<Card.Title class="text-xl tabular-nums">{summary?.connections_total ?? '—'}</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Bad connections</Card.Description>
<Card.Title class="text-xl tabular-nums">{summary?.connections_bad_total ?? '—'}</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Пользователей в конфиге</Card.Description>
<Card.Title class="text-xl tabular-nums">{summary?.configured_users ?? '—'}</Card.Title>
</Card.Header>
</Card.Root>
<KpiStatCard accent="teal" label="Аптайм" footer="uptime с ноды" valueClass="text-xl">
{#snippet icon()}
<ClockIcon class="size-5" aria-hidden="true" />
{/snippet}
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
</KpiStatCard>
<KpiStatCard accent="info" label="Соединения (всего)" footer="stats/summary" valueClass="text-xl">
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary?.connections_total ?? '—'}
</KpiStatCard>
<KpiStatCard accent={badConnAccent} label="Bad connections" footer="счётчик на ноде" valueClass="text-xl">
{#snippet icon()}
<UnplugIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary?.connections_bad_total ?? '—'}
</KpiStatCard>
<KpiStatCard accent="success" label="Пользователей в конфиге" footer="configured_users" valueClass="text-xl">
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary?.configured_users ?? '—'}
</KpiStatCard>
</div>
{#if sys}
<Card.Root>
<Card.Header>
<Card.Title>Система</Card.Title>
</Card.Header>
<Card.Content class="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
<KpiPanelCard accent="neutral" title="Система" contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
{#snippet icon()}
<CpuIcon class="size-5" aria-hidden="true" />
{/snippet}
<div><span class="text-muted-foreground">version</span> {sys.version}</div>
<div><span class="text-muted-foreground">OS / arch</span> {sys.target_os} / {sys.target_arch}</div>
<div><span class="text-muted-foreground">build</span> {sys.build_profile}</div>
@@ -144,8 +162,7 @@
<span class="text-muted-foreground">hash</span> {sys.config_hash}
</div>
<div><span class="text-muted-foreground">reload count</span> {sys.config_reload_count}</div>
</Card.Content>
</Card.Root>
</KpiPanelCard>
{/if}
{/snippet}
</DataQueryState>
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import SettingsIcon from '@lucide/svelte/icons/settings';
const alias = $derived(page.params.alias ?? '');
</script>
@@ -12,8 +13,10 @@
— правьте <code class="rounded bg-muted px-1">config.toml</code> на сервере.
</p>
</div>
<Card.Root>
<Card.Content class="text-sm text-muted-foreground">
<KpiPanelCard accent="neutral" title="Конфигурация" description="Настройка на сервере" contentClass="text-sm text-muted-foreground">
{#snippet icon()}
<SettingsIcon class="size-5" aria-hidden="true" />
{/snippet}
См. <a
class="text-primary underline"
href="https://github.com/telemt/telemt/blob/main/docs/API.md"
@@ -21,5 +24,4 @@
rel="noreferrer">docs/API.md</a
>
(Telemt Control API).
</Card.Content>
</Card.Root>
</KpiPanelCard>
@@ -2,7 +2,9 @@
import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import type { DcStatusData, RuntimeGatesData } from '$lib/api/telemt-v1.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import BuildingIcon from '@lucide/svelte/icons/building-2';
import BracesIcon from '@lucide/svelte/icons/braces';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
@@ -81,11 +83,10 @@
</div>
{#if dcs && dcs.middle_proxy_enabled && dcs.dcs}
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Datacenter status</Card.Title>
</Card.Header>
<Card.Content class="p-0">
<KpiPanelCard class="mb-6" accent="info" title="Datacenter status" contentClass="p-0">
{#snippet icon()}
<BuildingIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
@@ -110,25 +111,21 @@
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
</KpiPanelCard>
{:else if dcs?.reason}
<Alert class="mb-4">
<AlertDescription>DC status: {dcs.reason}</AlertDescription>
</Alert>
{/if}
<Card.Root>
<Card.Header>
<Card.Title>ME pool state</Card.Title>
<Card.Description>Сырой JSON</Card.Description>
</Card.Header>
<Card.Content>
<KpiPanelCard accent="violet" title="ME pool state" description="Сырой JSON" contentClass="">
{#snippet icon()}
<BracesIcon class="size-5" aria-hidden="true" />
{/snippet}
<pre class="max-h-[480px] overflow-auto rounded-md border border-border bg-muted/40 p-3 text-xs">{JSON.stringify(
pool,
null,
2
)}</pre>
</Card.Content>
</Card.Root>
</KpiPanelCard>
{/if}
@@ -1,7 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ShieldIcon from '@lucide/svelte/icons/shield';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
@@ -54,13 +55,14 @@
{:else if loading}
<p class="text-muted-foreground">Загрузка…</p>
{:else if data}
<Card.Root>
<Card.Content class="p-0">
<KpiPanelCard accent="danger" title="Security posture" description="POST /security/posture" contentClass="p-0">
{#snippet icon()}
<ShieldIcon class="size-5" aria-hidden="true" />
{/snippet}
<pre class="overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify(
data,
null,
2
)}</pre>
</Card.Content>
</Card.Root>
</KpiPanelCard>
{/if}
@@ -2,7 +2,8 @@
import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import type { SystemInfoData } from '$lib/api/telemt-v1.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import PackageIcon from '@lucide/svelte/icons/package';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
@@ -55,15 +56,13 @@
{:else if loading}
<p class="text-muted-foreground">Загрузка…</p>
{:else if sys}
<Card.Root>
<Card.Header>
<Card.Title>Версия бинарника</Card.Title>
</Card.Header>
<Card.Content class="space-y-2 text-sm">
<KpiPanelCard accent="teal" title="Версия бинарника" description="system/info" contentClass="space-y-2 text-sm">
{#snippet icon()}
<PackageIcon class="size-5" aria-hidden="true" />
{/snippet}
<p><span class="text-muted-foreground">version</span> {sys.version}</p>
<p><span class="text-muted-foreground">git</span> {sys.git_commit ?? '—'}</p>
<p><span class="text-muted-foreground">build</span> {sys.build_time_utc ?? '—'}</p>
<p><span class="text-muted-foreground">rustc</span> {sys.rustc_version ?? '—'}</p>
</Card.Content>
</Card.Root>
</KpiPanelCard>
{/if}
@@ -1,7 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { ApiError, fetchTelemt } from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import Share2Icon from '@lucide/svelte/icons/share-2';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
@@ -54,13 +55,14 @@
{:else if loading}
<p class="text-muted-foreground">Загрузка…</p>
{:else if data}
<Card.Root>
<Card.Content class="p-0">
<KpiPanelCard accent="info" title="Upstreams &amp; DCs" description="stats/upstreams" contentClass="p-0">
{#snippet icon()}
<Share2Icon class="size-5" aria-hidden="true" />
{/snippet}
<pre class="max-h-[70vh] overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify(
data,
null,
2
)}</pre>
</Card.Content>
</Card.Root>
</KpiPanelCard>
{/if}
@@ -14,7 +14,8 @@
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import UsersIcon from '@lucide/svelte/icons/users';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
@@ -226,8 +227,10 @@
{:else if loading && users.length === 0}
<p class="text-muted-foreground">Загрузка…</p>
{:else}
<Card.Root>
<Card.Content class="p-0">
<KpiPanelCard accent="info" title="Пользователи ноды" description="Локальный список Telemt" contentClass="p-0">
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
@@ -282,8 +285,7 @@
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
</KpiPanelCard>
{/if}
<Dialog.Root bind:open={createOpen}>
+12 -4
View File
@@ -8,6 +8,8 @@
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import UsersIcon from '@lucide/svelte/icons/users';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { Label } from '$lib/components/ui/label/index.js';
@@ -279,8 +281,15 @@
</Card.Root>
{/snippet}
{#snippet children()}
<Card.Root>
<Card.Content class="p-0">
<KpiPanelCard
accent="info"
title="Список пользователей"
description="Слияние по имени между серверами; клик по строке — карточка пользователя"
contentClass="p-0"
>
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
@@ -378,7 +387,6 @@
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
</KpiPanelCard>
{/snippet}
</DataQueryState>
+31 -32
View File
@@ -4,7 +4,11 @@
import { fetchAggUniqueIps, fetchAggUser, ApiError } from '$lib/api/client.js';
import type { components } from '$lib/api/aggregate.gen.js';
import { formatBytes, formatMiB, flagEmoji } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
import ServerIcon from '@lucide/svelte/icons/server';
import LinkIcon from '@lucide/svelte/icons/link';
import GlobeIcon from '@lucide/svelte/icons/globe';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
@@ -122,11 +126,10 @@
<p class="text-muted-foreground">Загрузка…</p>
{:else if row}
<div class="grid gap-4 md:grid-cols-2">
<Card.Root>
<Card.Header>
<Card.Title>Лимиты (смержены)</Card.Title>
</Card.Header>
<Card.Content class="space-y-2 text-sm">
<KpiPanelCard accent="info" title="Лимиты (смержены)" description="Сумма и ограничения по флоту" contentClass="space-y-2 text-sm">
{#snippet icon()}
<SlidersHorizontalIcon class="size-5" aria-hidden="true" />
{/snippet}
<div class="flex justify-between gap-4">
<span class="text-muted-foreground">Трафик (сумма)</span>
<span class="tabular-nums">{formatMiB(row.total_megabytes ?? 0)}</span>
@@ -151,13 +154,11 @@
<span class="text-muted-foreground">Истекает</span>
<span>{row.expiration_rfc3339 ?? '—'}</span>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>По серверам</Card.Title>
</Card.Header>
<Card.Content class="space-y-2">
</KpiPanelCard>
<KpiPanelCard accent="neutral" title="По серверам" description="Трафик и соединения по нодам" contentClass="space-y-2">
{#snippet icon()}
<ServerIcon class="size-5" aria-hidden="true" />
{/snippet}
{#each Object.entries(row.by_server ?? {}) as [alias, st] (alias)}
<div class="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border px-3 py-2 text-sm">
<ServerAliasBadges aliases={[alias]} variant="secondary" />
@@ -166,14 +167,12 @@
</span>
</div>
{/each}
</Card.Content>
</Card.Root>
</KpiPanelCard>
</div>
<Card.Root class="mt-4">
<Card.Header>
<Card.Title>Ссылки</Card.Title>
</Card.Header>
<Card.Content class="flex flex-wrap gap-2">
<KpiPanelCard class="mt-4" accent="teal" title="Ссылки" description="tg://proxy из агрегатора" contentClass="flex flex-wrap gap-2">
{#snippet icon()}
<LinkIcon class="size-5" aria-hidden="true" />
{/snippet}
{#each row.links?.tls ?? [] as link (link)}
<Button variant="outline" size="sm" onclick={() => copy(link)}>
TLS
@@ -186,16 +185,17 @@
<CopyIcon class="size-3" />
</Button>
{/each}
</Card.Content>
</Card.Root>
<Card.Root class="mt-4">
<Card.Header>
<Card.Title>IP и подключения</Card.Title>
<Card.Description>
Снимок шлюза: активные и недавние списки по серверам; Geo/ASN при доступности данных.
</Card.Description>
</Card.Header>
<Card.Content class="p-0">
</KpiPanelCard>
<KpiPanelCard
class="mt-4"
accent="violet"
title="IP и подключения"
description="Снимок шлюза: активные и недавние списки по серверам; Geo/ASN при доступности данных."
contentClass="p-0"
>
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
{/snippet}
{#if ipErr}
<Alert variant="destructive" class="m-4">
<AlertTitle>Ошибка IP данных</AlertTitle>
@@ -280,6 +280,5 @@
</Table.Body>
</Table.Root>
</div>
</Card.Content>
</Card.Root>
</KpiPanelCard>
{/if}