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 ActivityIcon from '@lucide/svelte/icons/activity';
|
||||||
import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole';
|
import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole';
|
||||||
import InfoIcon from '@lucide/svelte/icons/info';
|
import InfoIcon from '@lucide/svelte/icons/info';
|
||||||
|
import type { KpiAccentKey } from '$lib/kpi-accents.js';
|
||||||
/** Семантика дашборда: граница + иконка + при необходимости цвет числа (WCAG: акцент не только цветом — иконка + подпись). */
|
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
||||||
const kpiAccent = {
|
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||||
success: {
|
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
|
||||||
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;
|
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
@@ -457,11 +420,19 @@
|
|||||||
{#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>
|
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
|
||||||
<Card.Header class="pb-2">
|
<Card.Header class="pb-2">
|
||||||
<Skeleton class="h-4 w-24" />
|
<div class="flex gap-3">
|
||||||
<Skeleton class="mt-2 h-8 w-16" />
|
<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.Header>
|
||||||
|
<Card.Content class="pt-0">
|
||||||
|
<Skeleton class="h-3 w-28" />
|
||||||
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -470,147 +441,54 @@
|
|||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
{#if summary}
|
{#if summary}
|
||||||
<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">
|
||||||
<Card.Root
|
<KpiStatCard accent={nodesHealthAccent} label="Ноды" footer="успешный опрос">
|
||||||
class={cn('border-l-4 shadow-sm transition-colors', kpiAccent[nodesHealthAccent].border)}
|
{#snippet icon()}
|
||||||
>
|
<ServerIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Header class="pb-2">
|
{/snippet}
|
||||||
<div class="flex gap-3">
|
{fleet?.servers_all_ok ?? summary.servers_ok}/{fleet?.servers_total ?? summary.servers_total}
|
||||||
<div
|
</KpiStatCard>
|
||||||
class={cn(
|
<KpiStatCard accent="info" label="Соединения (флот)" footer="текущие по stats/users">
|
||||||
'flex size-10 shrink-0 items-center justify-center rounded-lg',
|
{#snippet icon()}
|
||||||
kpiAccent[nodesHealthAccent].iconWrap
|
<UsersIcon class="size-5" aria-hidden="true" />
|
||||||
)}
|
{/snippet}
|
||||||
>
|
{summary.fleet_total_connections}
|
||||||
<ServerIcon class="size-5" aria-hidden="true" />
|
</KpiStatCard>
|
||||||
</div>
|
<KpiStatCard accent={badConnectionsAccent} label="Bad connections" footer="сумма по нодам (stats/summary)">
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
{#snippet icon()}
|
||||||
<Card.Description>Ноды</Card.Description>
|
<UnplugIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Title
|
{/snippet}
|
||||||
class={cn(
|
{totalBad}
|
||||||
'text-2xl tabular-nums',
|
</KpiStatCard>
|
||||||
kpiAccent[nodesHealthAccent].value
|
<KpiStatCard accent="violet" label="Уникальные IP" footer="по снимку unique-ips">
|
||||||
)}
|
{#snippet icon()}
|
||||||
>
|
<GlobeIcon class="size-5" aria-hidden="true" />
|
||||||
{fleet?.servers_all_ok ?? summary.servers_ok}/{fleet?.servers_total ??
|
{/snippet}
|
||||||
summary.servers_total}
|
{uniqueIpCount}
|
||||||
</Card.Title>
|
</KpiStatCard>
|
||||||
</div>
|
<KpiStatCard accent="teal" label="Трафик флота" footer="сумма MiB">
|
||||||
</div>
|
{#snippet icon()}
|
||||||
</Card.Header>
|
<ActivityIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Content class="text-xs text-muted-foreground">успешный опрос</Card.Content>
|
{/snippet}
|
||||||
</Card.Root>
|
{formatMiB(summary.fleet_total_megabytes)}
|
||||||
<Card.Root class={cn('border-l-4 shadow-sm', kpiAccent.info.border)}>
|
</KpiStatCard>
|
||||||
<Card.Header class="pb-2">
|
<KpiStatCard accent={readOnlyAccent} label="Read-only API" footer="нод с read_only">
|
||||||
<div class="flex gap-3">
|
{#snippet icon()}
|
||||||
<div
|
<LockKeyholeIcon class="size-5" aria-hidden="true" />
|
||||||
class={cn(
|
{/snippet}
|
||||||
'flex size-10 shrink-0 items-center justify-center rounded-lg',
|
{readOnlyNodes}
|
||||||
kpiAccent.info.iconWrap
|
</KpiStatCard>
|
||||||
)}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card.Root class="mb-6">
|
<KpiPanelCard
|
||||||
<Card.Header>
|
class="mb-6"
|
||||||
<Card.Title>Активные IP (агрегат)</Card.Title>
|
accent="violet"
|
||||||
<Card.Description>С GeoIP при включённой базе на шлюзе</Card.Description>
|
title="Активные IP (агрегат)"
|
||||||
</Card.Header>
|
description="С GeoIP при включённой базе на шлюзе"
|
||||||
<Card.Content>
|
>
|
||||||
<div class="overflow-x-auto">
|
{#snippet icon()}
|
||||||
|
<GlobeIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
@@ -670,16 +548,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
|
|
||||||
<Card.Root>
|
<KpiPanelCard accent="info" title="По нодам" description="Health, версия, аптайм, трафик ноды" contentClass="p-0">
|
||||||
<Card.Header>
|
{#snippet icon()}
|
||||||
<Card.Title>По нодам</Card.Title>
|
<ServerIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Description>Health, версия, аптайм, трафик ноды</Card.Description>
|
{/snippet}
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="p-0">
|
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
@@ -737,14 +612,12 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
|
|
||||||
<Card.Root class="mt-6">
|
<KpiPanelCard class="mt-6" accent="teal" title="Топ пользователей по трафику" contentClass="">
|
||||||
<Card.Header>
|
{#snippet icon()}
|
||||||
<Card.Title>Топ пользователей по трафику</Card.Title>
|
<ChartNoAxesCombinedIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content>
|
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
@@ -769,8 +642,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
type IncidentsData
|
type IncidentsData
|
||||||
} from '$lib/api/client.js';
|
} from '$lib/api/client.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.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 * as Table from '$lib/components/ui/table/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
@@ -271,28 +277,30 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
||||||
<Card.Root>
|
<KpiStatCard accent="danger" label="Critical" footer="открытые critical">
|
||||||
<Card.Header class="pb-2">
|
{#snippet icon()}
|
||||||
<Card.Description>Critical</Card.Description>
|
<CircleAlertIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Title class="text-2xl text-red-500 tabular-nums">{data?.critical_total ?? 0}</Card.Title>
|
{/snippet}
|
||||||
</Card.Header>
|
{data?.critical_total ?? 0}
|
||||||
</Card.Root>
|
</KpiStatCard>
|
||||||
<Card.Root>
|
<KpiStatCard accent="warning" label="Warning" footer="открытые warning">
|
||||||
<Card.Header class="pb-2">
|
{#snippet icon()}
|
||||||
<Card.Description>Warning</Card.Description>
|
<TriangleAlertIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Title class="text-2xl text-amber-500 tabular-nums">{data?.warning_total ?? 0}</Card.Title>
|
{/snippet}
|
||||||
</Card.Header>
|
{data?.warning_total ?? 0}
|
||||||
</Card.Root>
|
</KpiStatCard>
|
||||||
<Card.Root>
|
<KpiStatCard accent="info" label="Info" footer="открытые info">
|
||||||
<Card.Header class="pb-2">
|
{#snippet icon()}
|
||||||
<Card.Description>Info</Card.Description>
|
<InfoSmallIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Title class="text-2xl tabular-nums">{data?.info_total ?? 0}</Card.Title>
|
{/snippet}
|
||||||
</Card.Header>
|
{data?.info_total ?? 0}
|
||||||
</Card.Root>
|
</KpiStatCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card.Root>
|
<KpiPanelCard accent="neutral" title="Инциденты" description="Список и triage" contentClass="p-0">
|
||||||
<Card.Content class="p-0">
|
{#snippet icon()}
|
||||||
|
<ClipboardListIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
@@ -400,7 +408,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { flagEmoji } from '$lib/format.js';
|
import { flagEmoji } from '$lib/format.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.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 { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
@@ -502,12 +505,10 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
{#if geo}
|
{#if geo}
|
||||||
<Card.Root class="mb-6">
|
<KpiPanelCard class="mb-6" accent="teal" title="Карта подключений" description="GeoIP координаты из снимка unique-ips" contentClass="p-0">
|
||||||
<Card.Header>
|
{#snippet icon()}
|
||||||
<Card.Title>Карта подключений</Card.Title>
|
<MapIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Description>GeoIP координаты из снимка unique-ips</Card.Description>
|
{/snippet}
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="p-0">
|
|
||||||
<div class="h-[420px] w-full rounded-md" bind:this={mapEl}></div>
|
<div class="h-[420px] w-full rounded-md" bind:this={mapEl}></div>
|
||||||
{#if mapPointsCount > 0}
|
{#if mapPointsCount > 0}
|
||||||
<div class="px-4 pb-3 pt-2 text-xs text-muted-foreground">
|
<div class="px-4 pb-3 pt-2 text-xs text-muted-foreground">
|
||||||
@@ -519,8 +520,7 @@
|
|||||||
{mapStatus}
|
{mapStatus}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Alert class="mb-6">
|
<Alert class="mb-6">
|
||||||
<AlertTitle>GeoIP выключен</AlertTitle>
|
<AlertTitle>GeoIP выключен</AlertTitle>
|
||||||
@@ -528,8 +528,10 @@
|
|||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Card.Root>
|
<KpiPanelCard accent="violet" title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" contentClass="p-0">
|
||||||
<Card.Content class="p-0">
|
{#snippet icon()}
|
||||||
|
<GlobeIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
@@ -588,8 +590,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@
|
|||||||
type IncidentSeverity,
|
type IncidentSeverity,
|
||||||
type IncidentStatus
|
type IncidentStatus
|
||||||
} from '$lib/api/client.js';
|
} 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 { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
@@ -302,38 +307,42 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
||||||
<Card.Root>
|
<KpiStatCard accent="teal" label="Общий статус" footer="агрегат snapshot">
|
||||||
<Card.Header class="pb-2">
|
{#snippet icon()}
|
||||||
<Card.Description>Общий статус</Card.Description>
|
<ActivityIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Title>
|
{/snippet}
|
||||||
<Badge variant={statusVariant(snapshot?.status)}>
|
<Badge variant={statusVariant(snapshot?.status)}>
|
||||||
{snapshot?.status ?? 'healthy'}
|
{snapshot?.status ?? 'healthy'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Card.Title>
|
</KpiStatCard>
|
||||||
</Card.Header>
|
<KpiStatCard accent="warning" label="Partial" footer="неполный снимок">
|
||||||
</Card.Root>
|
{#snippet icon()}
|
||||||
<Card.Root>
|
<LayersIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Header class="pb-2">
|
{/snippet}
|
||||||
<Card.Description>Partial</Card.Description>
|
{snapshot?.partial ? 'yes' : 'no'}
|
||||||
<Card.Title class="text-2xl tabular-nums">{snapshot?.partial ? 'yes' : 'no'}</Card.Title>
|
</KpiStatCard>
|
||||||
</Card.Header>
|
<KpiStatCard
|
||||||
</Card.Root>
|
accent="info"
|
||||||
<Card.Root>
|
label="Timestamp"
|
||||||
<Card.Header class="pb-2">
|
footer="время snapshot"
|
||||||
<Card.Description>Timestamp</Card.Description>
|
valueClass="text-base font-normal tabular-nums leading-tight"
|
||||||
<Card.Title class="text-sm">
|
>
|
||||||
{snapshot?.timestamp ? new Date(snapshot.timestamp).toLocaleString() : '—'}
|
{#snippet icon()}
|
||||||
</Card.Title>
|
<ClockIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
</Card.Root>
|
{snapshot?.timestamp ? new Date(snapshot.timestamp).toLocaleString() : '—'}
|
||||||
|
</KpiStatCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card.Root>
|
<KpiPanelCard
|
||||||
<Card.Header>
|
accent="danger"
|
||||||
<Card.Title>Последние incidents из snapshot</Card.Title>
|
title="Последние incidents из snapshot"
|
||||||
<Card.Description>Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}</Card.Description>
|
description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}"
|
||||||
</Card.Header>
|
contentClass="p-0"
|
||||||
<Card.Content class="p-0">
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<BellIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
@@ -381,5 +390,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
|
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
|
||||||
import { formatDurationSeconds, formatMiB } from '$lib/format.js';
|
import { formatDurationSeconds, formatMiB } from '$lib/format.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.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 { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/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 hasStaleData = $derived(err != null && (health != null || summary != null));
|
||||||
let showSkeleton = $derived(loading && !err && 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>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between gap-4">
|
<div class="mb-6 flex items-center justify-between gap-4">
|
||||||
@@ -79,11 +92,19 @@
|
|||||||
{#snippet skeleton()}
|
{#snippet skeleton()}
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{#each Array.from({ length: 4 }) as _, i (i)}
|
{#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">
|
<Card.Header class="pb-2">
|
||||||
<Skeleton class="h-4 w-24" />
|
<div class="flex gap-3">
|
||||||
<Skeleton class="mt-2 h-8 w-16" />
|
<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.Header>
|
||||||
|
<Card.Content class="pt-0">
|
||||||
|
<Skeleton class="h-3 w-32" />
|
||||||
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -100,40 +121,37 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card.Root>
|
<KpiStatCard accent="teal" label="Аптайм" footer="uptime с ноды" valueClass="text-xl">
|
||||||
<Card.Header class="pb-2">
|
{#snippet icon()}
|
||||||
<Card.Description>Аптайм</Card.Description>
|
<ClockIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Title class="text-xl tabular-nums">
|
{/snippet}
|
||||||
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
|
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
|
||||||
</Card.Title>
|
</KpiStatCard>
|
||||||
</Card.Header>
|
<KpiStatCard accent="info" label="Соединения (всего)" footer="stats/summary" valueClass="text-xl">
|
||||||
</Card.Root>
|
{#snippet icon()}
|
||||||
<Card.Root>
|
<PlugIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Header class="pb-2">
|
{/snippet}
|
||||||
<Card.Description>Соединения (всего)</Card.Description>
|
{summary?.connections_total ?? '—'}
|
||||||
<Card.Title class="text-xl tabular-nums">{summary?.connections_total ?? '—'}</Card.Title>
|
</KpiStatCard>
|
||||||
</Card.Header>
|
<KpiStatCard accent={badConnAccent} label="Bad connections" footer="счётчик на ноде" valueClass="text-xl">
|
||||||
</Card.Root>
|
{#snippet icon()}
|
||||||
<Card.Root>
|
<UnplugIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Header class="pb-2">
|
{/snippet}
|
||||||
<Card.Description>Bad connections</Card.Description>
|
{summary?.connections_bad_total ?? '—'}
|
||||||
<Card.Title class="text-xl tabular-nums">{summary?.connections_bad_total ?? '—'}</Card.Title>
|
</KpiStatCard>
|
||||||
</Card.Header>
|
<KpiStatCard accent="success" label="Пользователей в конфиге" footer="configured_users" valueClass="text-xl">
|
||||||
</Card.Root>
|
{#snippet icon()}
|
||||||
<Card.Root>
|
<UsersIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Header class="pb-2">
|
{/snippet}
|
||||||
<Card.Description>Пользователей в конфиге</Card.Description>
|
{summary?.configured_users ?? '—'}
|
||||||
<Card.Title class="text-xl tabular-nums">{summary?.configured_users ?? '—'}</Card.Title>
|
</KpiStatCard>
|
||||||
</Card.Header>
|
|
||||||
</Card.Root>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if sys}
|
{#if sys}
|
||||||
<Card.Root>
|
<KpiPanelCard accent="neutral" title="Система" contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card.Header>
|
{#snippet icon()}
|
||||||
<Card.Title>Система</Card.Title>
|
<CpuIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content class="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div><span class="text-muted-foreground">version</span> {sys.version}</div>
|
<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">OS / arch</span> {sys.target_os} / {sys.target_arch}</div>
|
||||||
<div><span class="text-muted-foreground">build</span> {sys.build_profile}</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}
|
<span class="text-muted-foreground">hash</span> {sys.config_hash}
|
||||||
</div>
|
</div>
|
||||||
<div><span class="text-muted-foreground">reload count</span> {sys.config_reload_count}</div>
|
<div><span class="text-muted-foreground">reload count</span> {sys.config_reload_count}</div>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
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 ?? '');
|
const alias = $derived(page.params.alias ?? '');
|
||||||
</script>
|
</script>
|
||||||
@@ -12,8 +13,10 @@
|
|||||||
— правьте <code class="rounded bg-muted px-1">config.toml</code> на сервере.
|
— правьте <code class="rounded bg-muted px-1">config.toml</code> на сервере.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Card.Root>
|
<KpiPanelCard accent="neutral" title="Конфигурация" description="Настройка на сервере" contentClass="text-sm text-muted-foreground">
|
||||||
<Card.Content class="text-sm text-muted-foreground">
|
{#snippet icon()}
|
||||||
|
<SettingsIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
См. <a
|
См. <a
|
||||||
class="text-primary underline"
|
class="text-primary underline"
|
||||||
href="https://github.com/telemt/telemt/blob/main/docs/API.md"
|
href="https://github.com/telemt/telemt/blob/main/docs/API.md"
|
||||||
@@ -21,5 +24,4 @@
|
|||||||
rel="noreferrer">docs/API.md</a
|
rel="noreferrer">docs/API.md</a
|
||||||
>
|
>
|
||||||
(Telemt Control API).
|
(Telemt Control API).
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||||
import type { DcStatusData, RuntimeGatesData } from '$lib/api/telemt-v1.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 { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
@@ -81,11 +83,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if dcs && dcs.middle_proxy_enabled && dcs.dcs}
|
{#if dcs && dcs.middle_proxy_enabled && dcs.dcs}
|
||||||
<Card.Root class="mb-6">
|
<KpiPanelCard class="mb-6" accent="info" title="Datacenter status" contentClass="p-0">
|
||||||
<Card.Header>
|
{#snippet icon()}
|
||||||
<Card.Title>Datacenter status</Card.Title>
|
<BuildingIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content class="p-0">
|
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
@@ -110,25 +111,21 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{:else if dcs?.reason}
|
{:else if dcs?.reason}
|
||||||
<Alert class="mb-4">
|
<Alert class="mb-4">
|
||||||
<AlertDescription>DC status: {dcs.reason}</AlertDescription>
|
<AlertDescription>DC status: {dcs.reason}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Card.Root>
|
<KpiPanelCard accent="violet" title="ME pool state" description="Сырой JSON" contentClass="">
|
||||||
<Card.Header>
|
{#snippet icon()}
|
||||||
<Card.Title>ME pool state</Card.Title>
|
<BracesIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Description>Сырой JSON</Card.Description>
|
{/snippet}
|
||||||
</Card.Header>
|
|
||||||
<Card.Content>
|
|
||||||
<pre class="max-h-[480px] overflow-auto rounded-md border border-border bg-muted/40 p-3 text-xs">{JSON.stringify(
|
<pre class="max-h-[480px] overflow-auto rounded-md border border-border bg-muted/40 p-3 text-xs">{JSON.stringify(
|
||||||
pool,
|
pool,
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
)}</pre>
|
)}</pre>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
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 { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
@@ -54,13 +55,14 @@
|
|||||||
{:else if loading}
|
{:else if loading}
|
||||||
<p class="text-muted-foreground">Загрузка…</p>
|
<p class="text-muted-foreground">Загрузка…</p>
|
||||||
{:else if data}
|
{:else if data}
|
||||||
<Card.Root>
|
<KpiPanelCard accent="danger" title="Security posture" description="POST /security/posture" contentClass="p-0">
|
||||||
<Card.Content class="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(
|
<pre class="overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify(
|
||||||
data,
|
data,
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
)}</pre>
|
)}</pre>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
||||||
import type { SystemInfoData } from '$lib/api/telemt-v1.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 { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
@@ -55,15 +56,13 @@
|
|||||||
{:else if loading}
|
{:else if loading}
|
||||||
<p class="text-muted-foreground">Загрузка…</p>
|
<p class="text-muted-foreground">Загрузка…</p>
|
||||||
{:else if sys}
|
{:else if sys}
|
||||||
<Card.Root>
|
<KpiPanelCard accent="teal" title="Версия бинарника" description="system/info" contentClass="space-y-2 text-sm">
|
||||||
<Card.Header>
|
{#snippet icon()}
|
||||||
<Card.Title>Версия бинарника</Card.Title>
|
<PackageIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content class="space-y-2 text-sm">
|
|
||||||
<p><span class="text-muted-foreground">version</span> {sys.version}</p>
|
<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">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">build</span> {sys.build_time_utc ?? '—'}</p>
|
||||||
<p><span class="text-muted-foreground">rustc</span> {sys.rustc_version ?? '—'}</p>
|
<p><span class="text-muted-foreground">rustc</span> {sys.rustc_version ?? '—'}</p>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { ApiError, fetchTelemt } from '$lib/api/client.js';
|
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 { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
@@ -54,13 +55,14 @@
|
|||||||
{:else if loading}
|
{:else if loading}
|
||||||
<p class="text-muted-foreground">Загрузка…</p>
|
<p class="text-muted-foreground">Загрузка…</p>
|
||||||
{:else if data}
|
{:else if data}
|
||||||
<Card.Root>
|
<KpiPanelCard accent="info" title="Upstreams & DCs" description="stats/upstreams" contentClass="p-0">
|
||||||
<Card.Content class="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(
|
<pre class="max-h-[70vh] overflow-auto rounded-md border border-border bg-muted/40 p-4 text-xs">{JSON.stringify(
|
||||||
data,
|
data,
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
)}</pre>
|
)}</pre>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/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 { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
@@ -226,8 +227,10 @@
|
|||||||
{:else if loading && users.length === 0}
|
{:else if loading && users.length === 0}
|
||||||
<p class="text-muted-foreground">Загрузка…</p>
|
<p class="text-muted-foreground">Загрузка…</p>
|
||||||
{:else}
|
{:else}
|
||||||
<Card.Root>
|
<KpiPanelCard accent="info" title="Пользователи ноды" description="Локальный список Telemt" contentClass="p-0">
|
||||||
<Card.Content class="p-0">
|
{#snippet icon()}
|
||||||
|
<UsersIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
@@ -282,8 +285,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Dialog.Root bind:open={createOpen}>
|
<Dialog.Root bind:open={createOpen}>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
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 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 { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
@@ -279,8 +281,15 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
<Card.Root>
|
<KpiPanelCard
|
||||||
<Card.Content class="p-0">
|
accent="info"
|
||||||
|
title="Список пользователей"
|
||||||
|
description="Слияние по имени между серверами; клик по строке — карточка пользователя"
|
||||||
|
contentClass="p-0"
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<UsersIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
@@ -378,7 +387,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
import { fetchAggUniqueIps, fetchAggUser, ApiError } from '$lib/api/client.js';
|
import { fetchAggUniqueIps, fetchAggUser, ApiError } from '$lib/api/client.js';
|
||||||
import type { components } from '$lib/api/aggregate.gen.js';
|
import type { components } from '$lib/api/aggregate.gen.js';
|
||||||
import { formatBytes, formatMiB, flagEmoji } from '$lib/format.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 { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
@@ -122,11 +126,10 @@
|
|||||||
<p class="text-muted-foreground">Загрузка…</p>
|
<p class="text-muted-foreground">Загрузка…</p>
|
||||||
{:else if row}
|
{:else if row}
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<Card.Root>
|
<KpiPanelCard accent="info" title="Лимиты (смержены)" description="Сумма и ограничения по флоту" contentClass="space-y-2 text-sm">
|
||||||
<Card.Header>
|
{#snippet icon()}
|
||||||
<Card.Title>Лимиты (смержены)</Card.Title>
|
<SlidersHorizontalIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content class="space-y-2 text-sm">
|
|
||||||
<div class="flex justify-between gap-4">
|
<div class="flex justify-between gap-4">
|
||||||
<span class="text-muted-foreground">Трафик (сумма)</span>
|
<span class="text-muted-foreground">Трафик (сумма)</span>
|
||||||
<span class="tabular-nums">{formatMiB(row.total_megabytes ?? 0)}</span>
|
<span class="tabular-nums">{formatMiB(row.total_megabytes ?? 0)}</span>
|
||||||
@@ -151,13 +154,11 @@
|
|||||||
<span class="text-muted-foreground">Истекает</span>
|
<span class="text-muted-foreground">Истекает</span>
|
||||||
<span>{row.expiration_rfc3339 ?? '—'}</span>
|
<span>{row.expiration_rfc3339 ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
<KpiPanelCard accent="neutral" title="По серверам" description="Трафик и соединения по нодам" contentClass="space-y-2">
|
||||||
<Card.Root>
|
{#snippet icon()}
|
||||||
<Card.Header>
|
<ServerIcon class="size-5" aria-hidden="true" />
|
||||||
<Card.Title>По серверам</Card.Title>
|
{/snippet}
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="space-y-2">
|
|
||||||
{#each Object.entries(row.by_server ?? {}) as [alias, st] (alias)}
|
{#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">
|
<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" />
|
<ServerAliasBadges aliases={[alias]} variant="secondary" />
|
||||||
@@ -166,14 +167,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
</div>
|
</div>
|
||||||
<Card.Root class="mt-4">
|
<KpiPanelCard class="mt-4" accent="teal" title="Ссылки" description="tg://proxy из агрегатора" contentClass="flex flex-wrap gap-2">
|
||||||
<Card.Header>
|
{#snippet icon()}
|
||||||
<Card.Title>Ссылки</Card.Title>
|
<LinkIcon class="size-5" aria-hidden="true" />
|
||||||
</Card.Header>
|
{/snippet}
|
||||||
<Card.Content class="flex flex-wrap gap-2">
|
|
||||||
{#each row.links?.tls ?? [] as link (link)}
|
{#each row.links?.tls ?? [] as link (link)}
|
||||||
<Button variant="outline" size="sm" onclick={() => copy(link)}>
|
<Button variant="outline" size="sm" onclick={() => copy(link)}>
|
||||||
TLS
|
TLS
|
||||||
@@ -186,16 +185,17 @@
|
|||||||
<CopyIcon class="size-3" />
|
<CopyIcon class="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
{/each}
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
<KpiPanelCard
|
||||||
<Card.Root class="mt-4">
|
class="mt-4"
|
||||||
<Card.Header>
|
accent="violet"
|
||||||
<Card.Title>IP и подключения</Card.Title>
|
title="IP и подключения"
|
||||||
<Card.Description>
|
description="Снимок шлюза: активные и недавние списки по серверам; Geo/ASN при доступности данных."
|
||||||
Снимок шлюза: активные и недавние списки по серверам; Geo/ASN при доступности данных.
|
contentClass="p-0"
|
||||||
</Card.Description>
|
>
|
||||||
</Card.Header>
|
{#snippet icon()}
|
||||||
<Card.Content class="p-0">
|
<GlobeIcon class="size-5" aria-hidden="true" />
|
||||||
|
{/snippet}
|
||||||
{#if ipErr}
|
{#if ipErr}
|
||||||
<Alert variant="destructive" class="m-4">
|
<Alert variant="destructive" class="m-4">
|
||||||
<AlertTitle>Ошибка IP данных</AlertTitle>
|
<AlertTitle>Ошибка IP данных</AlertTitle>
|
||||||
@@ -280,6 +280,5 @@
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</KpiPanelCard>
|
||||||
</Card.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user