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)}>
|
<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>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user