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)}>
{#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>
{#if description}
<Card.Description class="text-xs sm:text-sm">{description}</Card.Description>
+2 -2
View File
@@ -62,13 +62,13 @@
<Card.Root
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
)}
>
<Card.Header class="space-y-0 pb-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}
<Badge
variant="secondary"
+58 -84
View File
@@ -29,7 +29,7 @@
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
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 DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
@@ -39,6 +39,12 @@
import MoreVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import SearchIcon from '@lucide/svelte/icons/search';
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';
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 {
let n = 0;
for (const v of Object.values(stats)) {
@@ -139,17 +133,6 @@
fetchAggFleetStatus({ aliases: aliasFilter === 'all' ? undefined : aliasFilter }),
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;
partial = !!(s.partial || f.partial || u.partial);
summary = s.data;
@@ -520,15 +503,13 @@
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<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>
{#if generatedAt}
<p class="mt-1 text-sm text-muted-foreground">
Снимок: {new Date(generatedAt).toLocaleString()}
</p>
{/if}
<p class="mt-1 text-sm text-muted-foreground">
Состояние нод и агрегированные метрики Telemt
{#if generatedAt}
<span class="hidden sm:inline"> · Снимок: {new Date(generatedAt).toLocaleString()}</span>
{/if}
</p>
</div>
<div class="flex items-center gap-2">
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
@@ -563,16 +544,7 @@
{#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 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>
<Skeleton class="h-28 rounded-xl" />
{/each}
</div>
<Skeleton class="mb-4 h-56 w-full rounded-lg" />
@@ -583,50 +555,52 @@
{@const okN = fleet?.servers_all_ok ?? summary.servers_ok}
{@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">
<KpiTrendCard
label="Ноды OK / всего"
valueDisplay="{okN}/{totN}"
prevValue={kpiPrev?.serversOk ?? null}
currentValue={okN}
insight="Успешный health + system/info. Динамика — по числу OK-нод к прошлому опросу."
/>
<KpiTrendCard
<KpiStatCard accent="violet" label="Ноды OK / всего" footer="health + system/info" valueClass="text-xl">
{#snippet icon()}
<ServerIcon class="size-5" aria-hidden="true" />
{/snippet}
{okN}/{totN}
</KpiStatCard>
<KpiStatCard
accent="info"
label="Соединения (флот)"
valueDisplay={String(summary.fleet_total_connections)}
prevValue={kpiPrev?.connections ?? null}
currentValue={summary.fleet_total_connections}
insight="Текущие соединения по агрегату stats/users."
/>
<KpiTrendCard
label="Bad connections"
valueDisplay={String(totalBad)}
prevValue={kpiPrev?.bad ?? null}
currentValue={totalBad}
invertTrend={true}
insight="Сумма connections_bad по нодам (stats/summary). Меньше — лучше."
/>
<KpiTrendCard
label="Уникальные IP"
valueDisplay={String(uniqueIpCount)}
prevValue={kpiPrev?.uniqueIp ?? null}
currentValue={uniqueIpCount}
insight="По снимку unique-ips на шлюзе."
/>
<KpiTrendCard
footer="агрегат stats/users"
valueClass="text-xl"
>
{#snippet icon()}
<PlugIcon class="size-5" aria-hidden="true" />
{/snippet}
{summary.fleet_total_connections}
</KpiStatCard>
<KpiStatCard accent="danger" label="Bad connections" footer="сумма по нодам" valueClass="text-xl">
{#snippet icon()}
<ShieldAlertIcon class="size-5" aria-hidden="true" />
{/snippet}
{totalBad}
</KpiStatCard>
<KpiStatCard accent="teal" label="Уникальные IP" footer="по снимку unique-ips" valueClass="text-xl">
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
{/snippet}
{uniqueIpCount}
</KpiStatCard>
<KpiStatCard
accent="warning"
label="Трафик флота (MiB)"
valueDisplay={formatMiB(summary.fleet_total_megabytes)}
prevValue={kpiPrev?.megabytes ?? null}
currentValue={summary.fleet_total_megabytes}
insight="Суммарный трафик по снимку summary."
/>
<KpiTrendCard
label="Read-only API (нод)"
valueDisplay={String(readOnlyNodes)}
prevValue={kpiPrev?.readOnly ?? null}
currentValue={readOnlyNodes}
invertTrend={true}
insight="Ноды с read_only в health. Меньше — лучше."
/>
footer="суммарный трафик summary"
valueClass="text-lg"
>
{#snippet icon()}
<CloudUploadIcon class="size-5" aria-hidden="true" />
{/snippet}
{formatMiB(summary.fleet_total_megabytes)}
</KpiStatCard>
<KpiStatCard accent="neutral" label="Read-only API (нод)" footer="health.read_only" valueClass="text-xl">
{#snippet icon()}
<LockIcon class="size-5" aria-hidden="true" />
{/snippet}
{readOnlyNodes}
</KpiStatCard>
</div>
<FleetOverviewCharts
+38
View File
@@ -10,6 +10,7 @@
import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js';
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 * as Tabs from '$lib/components/ui/tabs/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 CopyIcon from '@lucide/svelte/icons/copy';
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';
let loading = $state(true);
@@ -189,6 +194,12 @@
let countAll = $derived(rows.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 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(() => {
if (usersViewTab === 'traffic') return rows.filter((r) => (r.total_megabytes ?? 0) > 0);
@@ -389,6 +400,33 @@
</Card.Root>
{/snippet}
{#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>
{#snippet toolbar()}
<div class="border-b border-border">