Unify overview and users pages with GT1 visual style.
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m15s

Align headers and card rhythm, add accent KPI cards to users, and tune shared table/trend card styling for consistent UI language.

Made-with: Cursor
This commit is contained in:
Denozordec
2026-04-09 23:46:05 +07:00
parent ba9d2fc374
commit 297fb79f11
4 changed files with 99 additions and 87 deletions
@@ -21,7 +21,7 @@
<Card.Root density="compact" class={cn('overflow-hidden rounded-lg border border-border shadow-sm', className)}> <Card.Root density="compact" class={cn('overflow-hidden rounded-lg border border-border shadow-sm', className)}>
{#if title} {#if title}
<Card.Header class="border-b border-border bg-muted/40 px-4 py-3"> <Card.Header class="border-b border-border bg-muted/30 px-4 py-3.5">
<Card.Title class="text-base font-semibold">{title}</Card.Title> <Card.Title class="text-base font-semibold">{title}</Card.Title>
{#if description} {#if description}
<Card.Description class="text-xs sm:text-sm">{description}</Card.Description> <Card.Description class="text-xs sm:text-sm">{description}</Card.Description>
+2 -2
View File
@@ -62,13 +62,13 @@
<Card.Root <Card.Root
class={cn( class={cn(
'border-border bg-card shadow-sm transition-colors dark:bg-card/80', 'border-l-4 border-l-muted-foreground/35 bg-card shadow-sm transition-colors dark:bg-card/80',
className className
)} )}
> >
<Card.Header class="space-y-0 pb-2"> <Card.Header class="space-y-0 pb-2">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<Card.Description class="text-xs font-medium uppercase tracking-wide">{label}</Card.Description> <Card.Description class="text-xs sm:text-sm">{label}</Card.Description>
{#if trend} {#if trend}
<Badge <Badge
variant="secondary" variant="secondary"
+56 -82
View File
@@ -29,7 +29,7 @@
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert'; import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
import InfoIcon from '@lucide/svelte/icons/info'; import InfoIcon from '@lucide/svelte/icons/info';
import KpiTrendCard from '$lib/components/kpi-trend-card.svelte'; import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte'; import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte'; import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte'; import SortableTh from '$lib/components/data-table/sortable-th.svelte';
@@ -39,6 +39,12 @@
import MoreVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; import MoreVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import SearchIcon from '@lucide/svelte/icons/search'; import SearchIcon from '@lucide/svelte/icons/search';
import { Badge } from '$lib/components/ui/badge/index.js'; import { Badge } from '$lib/components/ui/badge/index.js';
import ServerIcon from '@lucide/svelte/icons/server';
import PlugIcon from '@lucide/svelte/icons/plug';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import GlobeIcon from '@lucide/svelte/icons/globe';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import LockIcon from '@lucide/svelte/icons/lock';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js'; import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
let loading = $state(true); let loading = $state(true);
@@ -63,18 +69,6 @@
> >
>({}); >({});
type FleetKpiPrev = {
serversOk: number;
serversTotal: number;
connections: number;
bad: number;
uniqueIp: number;
megabytes: number;
readOnly: number;
};
let kpiPrev = $state<FleetKpiPrev | null>(null);
function sumBad(stats: typeof nodeStats): number { function sumBad(stats: typeof nodeStats): number {
let n = 0; let n = 0;
for (const v of Object.values(stats)) { for (const v of Object.values(stats)) {
@@ -139,17 +133,6 @@
fetchAggFleetStatus({ aliases: aliasFilter === 'all' ? undefined : aliasFilter }), fetchAggFleetStatus({ aliases: aliasFilter === 'all' ? undefined : aliasFilter }),
fetchAggUniqueIps({ aliases: aliasFilter === 'all' ? undefined : aliasFilter, geo: true }) fetchAggUniqueIps({ aliases: aliasFilter === 'all' ? undefined : aliasFilter, geo: true })
]); ]);
if (summary !== null && fleet !== null) {
kpiPrev = {
serversOk: fleet.servers_all_ok ?? summary.servers_ok,
serversTotal: fleet.servers_total ?? summary.servers_total,
connections: summary.fleet_total_connections,
bad: sumBad(nodeStats),
uniqueIp: countUniqueIps(uniqueIps),
megabytes: summary.fleet_total_megabytes,
readOnly: countReadOnlyNodes(fleet)
};
}
generatedAt = s.generated_at; generatedAt = s.generated_at;
partial = !!(s.partial || f.partial || u.partial); partial = !!(s.partial || f.partial || u.partial);
summary = s.data; summary = s.data;
@@ -520,15 +503,13 @@
<div class="mb-6 flex flex-wrap items-center justify-between gap-4"> <div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Telemt multi-server overview
</p>
<h1 class="text-2xl font-semibold tracking-tight">Обзор флота</h1> <h1 class="text-2xl font-semibold tracking-tight">Обзор флота</h1>
{#if generatedAt}
<p class="mt-1 text-sm text-muted-foreground"> <p class="mt-1 text-sm text-muted-foreground">
Снимок: {new Date(generatedAt).toLocaleString()} Состояние нод и агрегированные метрики Telemt
</p> {#if generatedAt}
<span class="hidden sm:inline"> · Снимок: {new Date(generatedAt).toLocaleString()}</span>
{/if} {/if}
</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} /> <FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
@@ -563,16 +544,7 @@
{#snippet skeleton()} {#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <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)} {#each Array.from({ length: 6 }) as _, i (i)}
<Card.Root class="border border-border shadow-sm"> <Skeleton class="h-28 rounded-xl" />
<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>
{/each} {/each}
</div> </div>
<Skeleton class="mb-4 h-56 w-full rounded-lg" /> <Skeleton class="mb-4 h-56 w-full rounded-lg" />
@@ -583,50 +555,52 @@
{@const okN = fleet?.servers_all_ok ?? summary.servers_ok} {@const okN = fleet?.servers_all_ok ?? summary.servers_ok}
{@const totN = fleet?.servers_total ?? summary.servers_total} {@const totN = fleet?.servers_total ?? summary.servers_total}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<KpiTrendCard <KpiStatCard accent="violet" label="Ноды OK / всего" footer="health + system/info" valueClass="text-xl">
label="Ноды OK / всего" {#snippet icon()}
valueDisplay="{okN}/{totN}" <ServerIcon class="size-5" aria-hidden="true" />
prevValue={kpiPrev?.serversOk ?? null} {/snippet}
currentValue={okN} {okN}/{totN}
insight="Успешный health + system/info. Динамика — по числу OK-нод к прошлому опросу." </KpiStatCard>
/> <KpiStatCard
<KpiTrendCard accent="info"
label="Соединения (флот)" label="Соединения (флот)"
valueDisplay={String(summary.fleet_total_connections)} footer="агрегат stats/users"
prevValue={kpiPrev?.connections ?? null} valueClass="text-xl"
currentValue={summary.fleet_total_connections} >
insight="Текущие соединения по агрегату stats/users." {#snippet icon()}
/> <PlugIcon class="size-5" aria-hidden="true" />
<KpiTrendCard {/snippet}
label="Bad connections" {summary.fleet_total_connections}
valueDisplay={String(totalBad)} </KpiStatCard>
prevValue={kpiPrev?.bad ?? null} <KpiStatCard accent="danger" label="Bad connections" footer="сумма по нодам" valueClass="text-xl">
currentValue={totalBad} {#snippet icon()}
invertTrend={true} <ShieldAlertIcon class="size-5" aria-hidden="true" />
insight="Сумма connections_bad по нодам (stats/summary). Меньше — лучше." {/snippet}
/> {totalBad}
<KpiTrendCard </KpiStatCard>
label="Уникальные IP" <KpiStatCard accent="teal" label="Уникальные IP" footer="по снимку unique-ips" valueClass="text-xl">
valueDisplay={String(uniqueIpCount)} {#snippet icon()}
prevValue={kpiPrev?.uniqueIp ?? null} <GlobeIcon class="size-5" aria-hidden="true" />
currentValue={uniqueIpCount} {/snippet}
insight="По снимку unique-ips на шлюзе." {uniqueIpCount}
/> </KpiStatCard>
<KpiTrendCard <KpiStatCard
accent="warning"
label="Трафик флота (MiB)" label="Трафик флота (MiB)"
valueDisplay={formatMiB(summary.fleet_total_megabytes)} footer="суммарный трафик summary"
prevValue={kpiPrev?.megabytes ?? null} valueClass="text-lg"
currentValue={summary.fleet_total_megabytes} >
insight="Суммарный трафик по снимку summary." {#snippet icon()}
/> <CloudUploadIcon class="size-5" aria-hidden="true" />
<KpiTrendCard {/snippet}
label="Read-only API (нод)" {formatMiB(summary.fleet_total_megabytes)}
valueDisplay={String(readOnlyNodes)} </KpiStatCard>
prevValue={kpiPrev?.readOnly ?? null} <KpiStatCard accent="neutral" label="Read-only API (нод)" footer="health.read_only" valueClass="text-xl">
currentValue={readOnlyNodes} {#snippet icon()}
invertTrend={true} <LockIcon class="size-5" aria-hidden="true" />
insight="Ноды с read_only в health. Меньше — лучше." {/snippet}
/> {readOnlyNodes}
</KpiStatCard>
</div> </div>
<FleetOverviewCharts <FleetOverviewCharts
+38
View File
@@ -10,6 +10,7 @@
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte'; import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte'; import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import * as Tabs from '$lib/components/ui/tabs/index.js'; import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
@@ -31,6 +32,10 @@
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte'; import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import CopyIcon from '@lucide/svelte/icons/copy'; import CopyIcon from '@lucide/svelte/icons/copy';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import UsersIcon from '@lucide/svelte/icons/users';
import ActivityIcon from '@lucide/svelte/icons/activity';
import GlobeIcon from '@lucide/svelte/icons/globe';
import CloudUploadIcon from '@lucide/svelte/icons/cloud-upload';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
let loading = $state(true); let loading = $state(true);
@@ -189,6 +194,12 @@
let countAll = $derived(rows.length); let countAll = $derived(rows.length);
let countTraffic = $derived(rows.filter((r) => (r.total_megabytes ?? 0) > 0).length); let countTraffic = $derived(rows.filter((r) => (r.total_megabytes ?? 0) > 0).length);
let countActive = $derived(rows.filter((r) => (r.active_unique_ips ?? 0) > 0).length); let countActive = $derived(rows.filter((r) => (r.active_unique_ips ?? 0) > 0).length);
let sumTraffic = $derived.by(() =>
rows.reduce((acc, row) => acc + (row.total_megabytes ?? 0), 0)
);
let sumActiveIps = $derived.by(() =>
rows.reduce((acc, row) => acc + (row.active_unique_ips ?? 0), 0)
);
let tabFilteredRows = $derived.by(() => { let tabFilteredRows = $derived.by(() => {
if (usersViewTab === 'traffic') return rows.filter((r) => (r.total_megabytes ?? 0) > 0); if (usersViewTab === 'traffic') return rows.filter((r) => (r.total_megabytes ?? 0) > 0);
@@ -389,6 +400,33 @@
</Card.Root> </Card.Root>
{/snippet} {/snippet}
{#snippet children()} {#snippet children()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiStatCard accent="violet" label="Пользователей всего" footer="в текущем фильтре" valueClass="text-xl">
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
{/snippet}
{countAll}
</KpiStatCard>
<KpiStatCard accent="success" label="С трафиком" footer="total_megabytes > 0" valueClass="text-xl">
{#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" />
{/snippet}
{countTraffic}
</KpiStatCard>
<KpiStatCard accent="info" label="Активных IP (сумма)" footer="active_unique_ips" valueClass="text-xl">
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
{/snippet}
{sumActiveIps}
</KpiStatCard>
<KpiStatCard accent="warning" label="Трафик (MiB)" footer="агрегат по пользователям" valueClass="text-lg">
{#snippet icon()}
<CloudUploadIcon class="size-5" aria-hidden="true" />
{/snippet}
{formatMiB(sumTraffic)}
</KpiStatCard>
</div>
<DataTableCard> <DataTableCard>
{#snippet toolbar()} {#snippet toolbar()}
<div class="border-b border-border"> <div class="border-b border-border">