Refactor Svelte components to utilize KpiStatCard and KpiPanelCard for improved UI consistency
- 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:
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user