Unify overview and users pages with GT1 visual style.
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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user