Enhance Mihomo overview charts component with improved theming and chart management
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m25s

- Integrated dynamic theming for chart colors using a centralized theme file, enhancing visual consistency across charts.
- Refactored chart initialization logic to ensure proper destruction and recreation of charts on data updates, improving performance and responsiveness.
- Updated chart options to utilize theme colors for grid, ticks, and legends, providing a more cohesive user experience.
This commit is contained in:
Denozordec
2026-03-31 18:37:03 +07:00
parent 054f58b071
commit 89b6cc5185
36 changed files with 2351 additions and 459 deletions
+40
View File
@@ -0,0 +1,40 @@
export type BreadcrumbItem = { href: string; label: string; current?: boolean };
const SEG_LABELS: Record<string, string> = {
users: 'Пользователи',
ips: 'Уникальные IP',
incidents: 'Инциденты',
live: 'Live',
mihomo: 'Mihomo',
servers: 'Серверы',
runtime: 'Runtime',
security: 'Security',
upstreams: 'Upstreams',
config: 'Конфигурация',
update: 'Обновление'
};
function segLabel(seg: string): string {
return SEG_LABELS[seg] ?? decodeURIComponent(seg);
}
export function breadcrumbsFromPath(pathname: string): BreadcrumbItem[] {
const p = pathname || '/';
if (p === '/') {
return [{ href: '/', label: 'Обзор', current: true }];
}
const parts = p.split('/').filter(Boolean);
const items: BreadcrumbItem[] = [{ href: '/', label: 'Обзор' }];
let acc = '';
for (let i = 0; i < parts.length; i++) {
const seg = parts[i];
acc += `/${seg}`;
items.push({
href: acc,
label: segLabel(seg),
current: i === parts.length - 1
});
}
return items;
}
+29
View File
@@ -0,0 +1,29 @@
/** Цвета для Chart.js из CSS-переменных темы (светлая/тёмная). */
export function readCssVar(name: string): string {
if (typeof document === 'undefined') return '';
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
export function chartTickColor(): string {
return readCssVar('--muted-foreground') || 'oklch(0.552 0.016 285.938)';
}
export function chartGridColor(): string {
const b = readCssVar('--border');
return b ? `color-mix(in oklch, ${b} 45%, transparent)` : 'oklch(0.5 0.02 285 / 0.12)';
}
export function chartSeriesColors(): string[] {
return [
readCssVar('--chart-1') || '#6366f1',
readCssVar('--chart-2') || '#22c55e',
readCssVar('--chart-3') || '#eab308',
readCssVar('--chart-4') || '#f97316',
readCssVar('--chart-5') || '#ec4899'
];
}
export function chartLegendColor(): string {
return chartTickColor();
}
@@ -0,0 +1,34 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { cn } from '$lib/utils.js';
import type { Snippet } from 'svelte';
let {
title,
description,
class: className,
toolbar,
children
}: {
title: string;
description?: string;
toolbar?: Snippet;
children: Snippet;
class?: string;
} = $props();
</script>
<Card.Root class={cn('overflow-hidden rounded-lg border border-border shadow-sm', className)}>
<div class="border-b border-border bg-muted/40 px-4 py-3">
<Card.Title class="text-base font-semibold">{title}</Card.Title>
{#if description}
<Card.Description class="text-xs sm:text-sm">{description}</Card.Description>
{/if}
</div>
{#if toolbar}
{@render toolbar()}
{/if}
<div class="p-0">
{@render children()}
</div>
</Card.Root>
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { Snippet } from 'svelte';
let {
class: className,
children
}: {
class?: string;
children: Snippet;
} = $props();
</script>
<div
class={cn(
'flex flex-wrap items-center justify-between gap-2 border-b border-border bg-muted/30 px-3 py-2',
className
)}
>
{@render children()}
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import type { Snippet } from 'svelte';
import type { SortDir } from '$lib/table-sort.js';
let {
active,
dir,
onToggle,
class: className,
children,
numeric
}: {
active: boolean;
dir: SortDir | null;
onToggle: () => void;
class?: string;
children: Snippet;
numeric?: boolean;
} = $props();
</script>
<Table.Head class={cn(numeric && 'text-right', className)}>
<Button
variant="ghost"
size="sm"
class={cn(
'-ml-2 h-8 gap-1 px-2 font-medium text-muted-foreground hover:text-foreground',
numeric && 'ms-auto'
)}
onclick={onToggle}
>
{@render children()}
{#if active && dir === 'asc'}
<ArrowUpIcon class="size-3.5 opacity-70" aria-hidden="true" />
{:else if active && dir === 'desc'}
<ArrowDownIcon class="size-3.5 opacity-70" aria-hidden="true" />
{:else}
<ArrowUpDownIcon class="size-3.5 opacity-50" aria-hidden="true" />
{/if}
</Button>
</Table.Head>
@@ -0,0 +1,26 @@
<script lang="ts">
import * as Empty from '$lib/components/ui/empty/index.js';
import InboxIcon from '@lucide/svelte/icons/inbox';
let {
title,
description
}: {
title: string;
description?: string;
} = $props();
</script>
<div class="flex justify-center py-10">
<Empty.Root class="max-w-sm border-0">
<Empty.Header>
<Empty.Media variant="icon">
<InboxIcon />
</Empty.Media>
<Empty.Title>{title}</Empty.Title>
{#if description}
<Empty.Description>{description}</Empty.Description>
{/if}
</Empty.Header>
</Empty.Root>
</div>
@@ -0,0 +1,163 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import Chart from 'chart.js/auto';
import * as Card from '$lib/components/ui/card/index.js';
import {
chartGridColor,
chartLegendColor,
chartSeriesColors,
chartTickColor
} from '$lib/chart-theme.js';
import ServerIcon from '@lucide/svelte/icons/server';
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
let {
nodesOk,
nodesDegraded,
topUsers
}: {
nodesOk: number;
nodesDegraded: number;
topUsers: { username: string; total_megabytes: number }[];
} = $props();
let elDonut = $state<HTMLCanvasElement | null>(null);
let elBar = $state<HTMLCanvasElement | null>(null);
let chartDonut: Chart | null = null;
let chartBar: Chart | null = null;
function buildDonut() {
if (!elDonut) return;
const colors = chartSeriesColors();
const legend = chartLegendColor();
chartDonut?.destroy();
chartDonut = new Chart(elDonut, {
type: 'doughnut',
data: {
labels: ['OK', 'Degraded'],
datasets: [
{
data: [Math.max(nodesOk, 0), Math.max(nodesDegraded, 0)],
backgroundColor: [colors[1] ?? '#22c55e', colors[4] ?? '#ef4444'],
borderWidth: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { labels: { color: legend } } }
}
});
}
function buildBar() {
if (!elBar) return;
const colors = chartSeriesColors();
const tickC = chartTickColor();
const grid = chartGridColor();
const slice = topUsers.slice(0, 8);
chartBar?.destroy();
chartBar = new Chart(elBar, {
type: 'bar',
data: {
labels: slice.map((x) => x.username),
datasets: [
{
label: 'MiB',
data: slice.map((x) => x.total_megabytes),
backgroundColor: colors[0] ?? '#6366f1'
}
]
},
options: {
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tickC }, grid: { color: grid } },
y: { ticks: { color: tickC }, grid: { display: false } }
}
}
});
}
function rebuildAll() {
buildDonut();
buildBar();
}
onMount(() => {
let cancelled = false;
void tick().then(() => {
if (!cancelled) rebuildAll();
});
const obs = new MutationObserver(() => {
rebuildAll();
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => {
cancelled = true;
obs.disconnect();
};
});
$effect(() => {
nodesOk;
nodesDegraded;
topUsers;
if (chartDonut) {
chartDonut.data.datasets[0].data = [Math.max(nodesOk, 0), Math.max(nodesDegraded, 0)];
chartDonut.update('none');
}
if (chartBar) {
const slice = topUsers.slice(0, 8);
chartBar.data.labels = slice.map((x) => x.username);
chartBar.data.datasets[0].data = slice.map((x) => x.total_megabytes);
chartBar.update('none');
}
});
onDestroy(() => {
chartDonut?.destroy();
chartBar?.destroy();
});
</script>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<Card.Root class="overflow-hidden rounded-lg border border-border shadow-sm">
<div class="border-b border-border bg-muted/40 px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<ServerIcon class="size-5" />
</div>
<div>
<Card.Title class="text-base">Статус нод</Card.Title>
<Card.Description class="text-xs">OK vs degraded по fleet-status</Card.Description>
</div>
</div>
</div>
<Card.Content class="h-56 p-4">
<canvas bind:this={elDonut} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
<Card.Root class="overflow-hidden rounded-lg border border-border shadow-sm">
<div class="border-b border-border bg-muted/40 px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<ChartNoAxesCombinedIcon class="size-5" />
</div>
<div>
<Card.Title class="text-base">Топ трафика</Card.Title>
<Card.Description class="text-xs">До 8 пользователей, MiB</Card.Description>
</div>
</div>
</div>
<Card.Content class="h-56 p-4">
<canvas bind:this={elBar} class="max-h-full w-full"></canvas>
</Card.Content>
</Card.Root>
</div>
@@ -0,0 +1,98 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from '$lib/utils.js';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import TrendingDownIcon from '@lucide/svelte/icons/trending-down';
import MinusIcon from '@lucide/svelte/icons/minus';
import { formatSignedPercent, percentChange, trendFromZero } from '$lib/kpi-trend-math.js';
let {
label,
insight,
valueDisplay,
prevValue,
currentValue,
invertTrend = false,
class: className
}: {
label: string;
/** Короткий текст под разделителем */
insight: string;
/** Отображаемое основное значение */
valueDisplay: string;
prevValue: number | null;
currentValue: number | null;
/** true: рост значения = хуже (красный «вверх») */
invertTrend?: boolean;
class?: string;
} = $props();
type TrendOut =
| null
| { raw: 'flat'; label: string; good: boolean }
| { raw: 'up' | 'down'; label: string; good: boolean };
const trend = $derived.by((): TrendOut => {
if (prevValue == null || currentValue == null) return null;
if (!Number.isFinite(prevValue) || !Number.isFinite(currentValue)) return null;
const dir = trendFromZero(prevValue, currentValue);
if (dir === null || dir === 'flat') {
if (dir === 'flat' && prevValue === currentValue)
return {
raw: 'flat' as const,
label: '0%',
good: true
};
return null;
}
const raw: 'up' | 'down' = dir === 'up' ? 'up' : 'down';
const good = invertTrend ? raw === 'down' : raw === 'up';
const pct = percentChange(prevValue, currentValue);
if (pct != null) {
return { raw, label: formatSignedPercent(pct), good };
}
if (prevValue === 0 && currentValue !== 0) {
return { raw, label: 'от нуля', good };
}
return null;
});
</script>
<Card.Root
class={cn(
'border-border bg-card shadow-sm transition-colors dark:bg-card/80',
className
)}
>
<Card.Header class="space-y-0 pb-2">
<div class="flex items-start justify-between gap-2">
<Card.Description class="text-xs font-medium uppercase tracking-wide">{label}</Card.Description>
{#if trend}
<Badge
variant="secondary"
class={cn(
'gap-0.5 border-0 font-medium tabular-nums',
trend.good &&
'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
!trend.good && trend.raw !== 'flat' && 'bg-red-500/15 text-red-700 dark:text-red-400',
trend.raw === 'flat' && 'text-muted-foreground'
)}
>
{#if trend.raw === 'up'}
<TrendingUpIcon class="size-3.5" aria-hidden="true" />
{:else if trend.raw === 'down'}
<TrendingDownIcon class="size-3.5" aria-hidden="true" />
{:else}
<MinusIcon class="size-3.5" aria-hidden="true" />
{/if}
{trend.label}
</Badge>
{/if}
</div>
<Card.Title class="pt-1 text-2xl font-semibold tabular-nums tracking-tight">{valueDisplay}</Card.Title>
</Card.Header>
<Separator />
<Card.Content class="pt-3 text-sm text-muted-foreground">{insight}</Card.Content>
</Card.Root>
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import Chart from 'chart.js/auto';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import ActivityIcon from '@lucide/svelte/icons/activity';
@@ -8,6 +8,12 @@
import PlugIcon from '@lucide/svelte/icons/plug';
import NetworkIcon from '@lucide/svelte/icons/network';
import GaugeIcon from '@lucide/svelte/icons/gauge';
import {
chartGridColor,
chartLegendColor,
chartSeriesColors,
chartTickColor
} from '$lib/chart-theme.js';
let {
histUp,
@@ -45,85 +51,184 @@
let chartNet: Chart | null = null;
let chartTop: Chart | null = null;
const grid = '#64748b';
function lineOpts() {
const tick = chartTickColor();
const grid = chartGridColor();
const legend = chartLegendColor();
return {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
plugins: { legend: { labels: { color: grid } } },
plugins: { legend: { labels: { color: legend } } },
scales: {
x: { ticks: { color: grid, maxTicksLimit: 8 }, grid: { color: '#33415555' } },
y: { ticks: { color: grid }, grid: { color: '#33415555' } }
x: { ticks: { color: tick, maxTicksLimit: 8 }, grid: { color: grid } },
y: { ticks: { color: tick }, grid: { color: grid } }
}
};
}
onMount(() => {
if (!elTraffic || !elFlow || !elMem || !elConn || !elNet || !elTop) return;
function rebuildAll() {
const colors = chartSeriesColors();
const legend = chartLegendColor();
const grid = chartGridColor();
const tick = chartTickColor();
const lab = histDown.map((_, i) => String(i));
chartTraffic = new Chart(elTraffic, {
type: 'line',
data: {
labels: lab,
datasets: [
{ label: 'Скачивание', data: [...histDown], borderColor: '#38bdf8', tension: 0.2, fill: true },
{ label: 'Загрузка', data: [...histUp], borderColor: '#f472b6', tension: 0.2, fill: true }
]
},
options: lineOpts()
});
chartFlow = new Chart(elFlow, {
type: 'doughnut',
data: {
labels: ['Скачано', 'Загружено'],
datasets: [{ data: [Math.max(totalDown, 0), Math.max(totalUp, 0)], backgroundColor: ['#38bdf8', '#f472b6'] }]
},
options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: grid } } } }
});
chartMem = new Chart(elMem, {
type: 'line',
data: {
labels: histMem.map((_, i) => String(i)),
datasets: [{ label: 'KiB', data: [...histMem], borderColor: '#a78bfa', tension: 0.2, fill: true }]
},
options: lineOpts()
});
chartConn = new Chart(elConn, {
type: 'line',
data: {
labels: histConn.map((_, i) => String(i)),
datasets: [{ label: 'Соединения', data: [...histConn], borderColor: '#34d399', tension: 0.2, fill: true }]
},
options: lineOpts()
});
chartNet = new Chart(elNet, {
type: 'doughnut',
data: {
labels: ['TCP', 'UDP'],
datasets: [{ data: [tcpN, udpN], backgroundColor: ['#22c55e', '#eab308'] }]
},
options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: grid } } } }
});
chartTop = new Chart(elTop, {
type: 'bar',
data: {
labels: topList.map((x) => x.name),
datasets: [{ label: 'Сессии', data: topList.map((x) => x.n), backgroundColor: '#818cf8' }]
},
options: {
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: grid }, grid: { color: '#33415555' } },
y: { ticks: { color: grid }, grid: { display: false } }
if (elTraffic) {
chartTraffic?.destroy();
chartTraffic = new Chart(elTraffic, {
type: 'line',
data: {
labels: lab,
datasets: [
{
label: 'Скачивание',
data: [...histDown],
borderColor: colors[0] ?? '#38bdf8',
backgroundColor: `${colors[0] ?? '#38bdf8'}33`,
tension: 0.2,
fill: true
},
{
label: 'Загрузка',
data: [...histUp],
borderColor: colors[4] ?? '#f472b6',
backgroundColor: `${colors[4] ?? '#f472b6'}33`,
tension: 0.2,
fill: true
}
]
},
options: lineOpts()
});
}
if (elFlow) {
chartFlow?.destroy();
chartFlow = new Chart(elFlow, {
type: 'doughnut',
data: {
labels: ['Скачано', 'Загружено'],
datasets: [
{
data: [Math.max(totalDown, 0), Math.max(totalUp, 0)],
backgroundColor: [colors[0] ?? '#38bdf8', colors[4] ?? '#f472b6']
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { labels: { color: legend } } }
}
}
});
}
if (elMem) {
chartMem?.destroy();
chartMem = new Chart(elMem, {
type: 'line',
data: {
labels: histMem.map((_, i) => String(i)),
datasets: [
{
label: 'KiB',
data: [...histMem],
borderColor: colors[2] ?? '#a78bfa',
backgroundColor: `${colors[2] ?? '#a78bfa'}33`,
tension: 0.2,
fill: true
}
]
},
options: lineOpts()
});
}
if (elConn) {
chartConn?.destroy();
chartConn = new Chart(elConn, {
type: 'line',
data: {
labels: histConn.map((_, i) => String(i)),
datasets: [
{
label: 'Соединения',
data: [...histConn],
borderColor: colors[1] ?? '#34d399',
backgroundColor: `${colors[1] ?? '#34d399'}33`,
tension: 0.2,
fill: true
}
]
},
options: lineOpts()
});
}
if (elNet) {
chartNet?.destroy();
chartNet = new Chart(elNet, {
type: 'doughnut',
data: {
labels: ['TCP', 'UDP'],
datasets: [
{
data: [tcpN, udpN],
backgroundColor: [colors[1] ?? '#22c55e', colors[3] ?? '#eab308']
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { labels: { color: legend } } }
}
});
}
if (elTop) {
chartTop?.destroy();
chartTop = new Chart(elTop, {
type: 'bar',
data: {
labels: topList.map((x) => x.name),
datasets: [
{
label: 'Сессии',
data: topList.map((x) => x.n),
backgroundColor: colors[0] ?? '#818cf8'
}
]
},
options: {
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tick }, grid: { color: grid } },
y: { ticks: { color: tick }, grid: { display: false } }
}
}
});
}
}
onMount(() => {
let cancelled = false;
void tick().then(() => {
if (!cancelled) rebuildAll();
});
const obs = new MutationObserver(() => rebuildAll());
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => {
cancelled = true;
obs.disconnect();
};
});
$effect(() => {
@@ -171,7 +276,7 @@
<div class="grid gap-4 lg:grid-cols-2">
<KpiPanelCard
accent="teal"
accent="neutral"
title="Трафик"
description="Скорость загрузки и скачивания (история из /traffic)"
headerClass="pb-2"
@@ -183,7 +288,7 @@
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
</KpiPanelCard>
<KpiPanelCard
accent="info"
accent="neutral"
title="Поток (накоплено)"
description="Соотношение накопленного down / up"
headerClass="pb-2"
@@ -195,7 +300,7 @@
<canvas bind:this={elFlow} class="max-h-full w-full"></canvas>
</KpiPanelCard>
<KpiPanelCard
accent="violet"
accent="neutral"
title="Память (KiB)"
description="Использование по снимкам (ось — KiB)"
headerClass="pb-2"
@@ -207,7 +312,7 @@
<canvas bind:this={elMem} class="max-h-full w-full"></canvas>
</KpiPanelCard>
<KpiPanelCard
accent="success"
accent="neutral"
title="Подключения"
description="Число активных соединений во времени"
headerClass="pb-2"
@@ -219,7 +324,7 @@
<canvas bind:this={elConn} class="max-h-full w-full"></canvas>
</KpiPanelCard>
<KpiPanelCard
accent="warning"
accent="neutral"
title="Типы сети"
description="TCP и UDP по текущему снимку"
headerClass="pb-2"
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts">
import { page } from '$app/state';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { breadcrumbsFromPath } from '$lib/breadcrumb-trail.js';
import { setMode, resetMode, userPrefersMode } from 'mode-watcher';
import MonitorIcon from '@lucide/svelte/icons/monitor';
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
const items = $derived(breadcrumbsFromPath(page.url.pathname));
</script>
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
<div class="flex min-w-0 flex-1 items-center gap-2">
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="me-1 h-4" />
<Breadcrumb.Root class="min-w-0">
<Breadcrumb.List class="flex-wrap">
{#each items as it, i (it.href + String(i))}
<Breadcrumb.Item class="min-w-0 max-w-[140px] sm:max-w-[220px]">
{#if it.current}
<Breadcrumb.Page class="truncate">{it.label}</Breadcrumb.Page>
{:else}
<Breadcrumb.Link href={it.href} class="truncate">{it.label}</Breadcrumb.Link>
{/if}
</Breadcrumb.Item>
{#if i < items.length - 1}
<Breadcrumb.Separator />
{/if}
{/each}
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="icon-sm"
class="relative shrink-0"
aria-label="Тема оформления"
>
<SunIcon
class="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90"
/>
<MoonIcon
class="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0"
/>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-44">
<DropdownMenu.Label class="text-xs text-muted-foreground">Тема</DropdownMenu.Label>
<DropdownMenu.Item
class="gap-2"
onclick={() => {
setMode('light');
}}
>
<SunIcon class="size-4" /> Светлая
{#if userPrefersMode.current === 'light'}
<span class="ms-auto text-xs text-muted-foreground"></span>
{/if}
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2"
onclick={() => {
setMode('dark');
}}
>
<MoonIcon class="size-4" /> Тёмная
{#if userPrefersMode.current === 'dark'}
<span class="ms-auto text-xs text-muted-foreground"></span>
{/if}
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2"
onclick={() => {
resetMode();
}}
>
<MonitorIcon class="size-4" /> Системная
{#if userPrefersMode.current === 'system'}
<span class="ms-auto text-xs text-muted-foreground"></span>
{/if}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge/index.js';
import { cn } from '$lib/utils.js';
import CheckIcon from '@lucide/svelte/icons/check';
import Loader2Icon from '@lucide/svelte/icons/loader-circle';
import AlertCircleIcon from '@lucide/svelte/icons/circle-alert';
let {
variant
}: {
variant: 'ok' | 'degraded' | 'pending' | 'error';
} = $props();
</script>
{#if variant === 'ok'}
<Badge
class={cn(
'gap-1 border-0 bg-emerald-500/15 font-medium text-emerald-700 dark:text-emerald-400'
)}
>
<CheckIcon class="size-3.5 shrink-0" aria-hidden="true" />
OK
</Badge>
{:else if variant === 'degraded'}
<Badge variant="destructive" class="gap-1 font-medium">
<AlertCircleIcon class="size-3.5 shrink-0" aria-hidden="true" />
degraded
</Badge>
{:else if variant === 'pending'}
<Badge variant="secondary" class="gap-1 font-medium text-muted-foreground">
<Loader2Icon class="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
ожидание
</Badge>
{:else}
<Badge variant="destructive" class="gap-1 font-medium">
<AlertCircleIcon class="size-3.5 shrink-0" aria-hidden="true" />
ошибка
</Badge>
{/if}
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
class={cn("size-5 [&>svg]:size-4 flex items-center justify-center", className)}
{...restProps}
>
<MoreHorizontalIcon />
<span class="sr-only">More</span>
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLLiAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-item"
class={cn("gap-1 inline-flex items-center", className)}
{...restProps}
>
{@render children?.()}
</li>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
href = undefined,
child,
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
} = $props();
const attrs = $derived({
"data-slot": "breadcrumb-link",
class: cn("hover:text-foreground transition-colors", className),
href,
...restProps,
});
</script>
{#if child}
{@render child({ props: attrs })}
{:else}
<a bind:this={ref} {...attrs}>
{@render children?.()}
</a>
{/if}
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLOlAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLOlAttributes> = $props();
</script>
<ol
bind:this={ref}
data-slot="breadcrumb-list"
class={cn("text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word", className)}
{...restProps}
>
{@render children?.()}
</ol>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
class={cn("text-foreground font-normal", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLLiAttributes } from "svelte/elements";
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
class={cn("[&>svg]:size-3.5", className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
<ChevronRightIcon />
{/if}
</li>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
bind:this={ref}
data-slot="breadcrumb"
aria-label="breadcrumb"
class={cn("cn-breadcrumb", className)}
{...restProps}
>
{@render children?.()}
</nav>
@@ -0,0 +1,25 @@
import Root from "./breadcrumb.svelte";
import Ellipsis from "./breadcrumb-ellipsis.svelte";
import Item from "./breadcrumb-item.svelte";
import Separator from "./breadcrumb-separator.svelte";
import Link from "./breadcrumb-link.svelte";
import List from "./breadcrumb-list.svelte";
import Page from "./breadcrumb-page.svelte";
export {
Root,
Ellipsis,
Item,
Separator,
Link,
List,
Page,
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Separator as BreadcrumbSeparator,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-content"
class={cn(
"gap-2.5 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-description"
class={cn(
"text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-header"
class={cn("gap-2 flex max-w-sm flex-col items-center", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,41 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const emptyMediaVariants = tv({
base: "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
});
export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>["variant"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
variant = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
</script>
<div
bind:this={ref}
data-slot="empty-icon"
data-variant={variant}
class={cn(emptyMediaVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="empty-title" class={cn("text-sm font-medium tracking-tight", className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty"
class={cn(
"gap-4 rounded-xl border-dashed p-6 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance",
className
)}
{...restProps}
>
{@render children?.()}
</div>
+22
View File
@@ -0,0 +1,22 @@
import Root from "./empty.svelte";
import Header from "./empty-header.svelte";
import Media from "./empty-media.svelte";
import Title from "./empty-title.svelte";
import Description from "./empty-description.svelte";
import Content from "./empty-content.svelte";
export {
Root,
Header,
Media,
Title,
Description,
Content,
//
Root as Empty,
Header as EmptyHeader,
Media as EmptyMedia,
Title as EmptyTitle,
Description as EmptyDescription,
Content as EmptyContent,
};
+23
View File
@@ -0,0 +1,23 @@
/** Доля изменения для тренд-бейджа; null если сравнивать не с чем. */
export function percentChange(prev: number, cur: number): number | null {
if (!Number.isFinite(prev) || !Number.isFinite(cur)) return null;
if (prev === 0 && cur === 0) return null;
if (prev === 0) return cur === 0 ? null : null; // избегаем бесконечного %; покажем только направление отдельно
const p = ((cur - prev) / Math.abs(prev)) * 100;
return Number.isFinite(p) ? p : null;
}
/** Для prev===0 и cur>0 возвращает «рост от нуля». */
export function trendFromZero(prev: number, cur: number): 'up' | 'down' | 'flat' | null {
if (!Number.isFinite(prev) || !Number.isFinite(cur)) return null;
if (prev === 0 && cur === 0) return null;
if (prev === 0) return cur > 0 ? 'up' : cur < 0 ? 'down' : 'flat';
if (cur > prev) return 'up';
if (cur < prev) return 'down';
return 'flat';
}
export function formatSignedPercent(p: number): string {
const sign = p > 0 ? '+' : '';
return `${sign}${p.toFixed(1)}%`;
}
+38
View File
@@ -0,0 +1,38 @@
const SEG_LABELS: Record<string, string> = {
users: 'Пользователи',
ips: 'Уникальные IP',
incidents: 'Инциденты',
live: 'Live',
mihomo: 'Mihomo',
servers: 'Серверы',
runtime: 'Runtime',
security: 'Security',
upstreams: 'Upstreams и DC',
config: 'Конфигурация',
update: 'Обновление'
};
export function panelTitleFromPath(pathname: string): string {
const p = pathname === '' ? '/' : pathname;
if (p === '/') return 'Обзор флота';
const parts = p.split('/').filter(Boolean);
if (parts.length === 0) return 'Telemt Panel';
if (parts[0] === 'servers' && parts.length >= 2) {
const alias = decodeURIComponent(parts[1]);
if (parts.length === 2) return `Нода ${alias}`;
const sub = parts.slice(2).map((s) => SEG_LABELS[s] ?? s).join(' · ');
return `${alias} · ${sub}`;
}
if (parts.length === 1) {
return SEG_LABELS[parts[0]] ?? parts[0];
}
if (parts[0] === 'users' && parts[1]) {
return `Пользователь ${decodeURIComponent(parts[1])}`;
}
return parts.map((s) => SEG_LABELS[s] ?? decodeURIComponent(s)).join(' · ');
}
+20
View File
@@ -0,0 +1,20 @@
export type SortDir = 'asc' | 'desc';
export function nextSortDir(
current: SortDir | null,
key: string,
activeKey: string | null
): SortDir | null {
if (activeKey !== key) return 'asc';
if (current === 'asc') return 'desc';
return null;
}
export function compareStrings(a: string, b: string, dir: SortDir): number {
const c = a.localeCompare(b, undefined, { sensitivity: 'base' });
return dir === 'asc' ? c : -c;
}
export function compareNum(a: number, b: number, dir: SortDir): number {
return dir === 'asc' ? a - b : b - a;
}
+7 -5
View File
@@ -9,9 +9,13 @@
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { Toaster } from '$lib/components/ui/sonner/index.js';
import SiteHeader from '$lib/components/site-header.svelte';
import { panelTitleFromPath } from '$lib/page-title.js';
let { children } = $props();
const docTitle = $derived(`${panelTitleFromPath(page.url.pathname)} · Telemt Panel`);
let serverAliases = $state<string[]>([]);
const serverMatch = $derived.by(() => {
@@ -42,7 +46,7 @@
<svelte:head>
<link rel="icon" href={favicon} />
<title>Telemt Panel</title>
<title>{docTitle}</title>
</svelte:head>
<ModeWatcher />
@@ -50,10 +54,8 @@
<Sidebar.Provider>
<AppSidebar {serverAliases} {serverPath} />
<Sidebar.Inset>
<header
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4"
>
<Sidebar.Trigger class="-ml-1" />
<header class="flex h-14 w-full shrink-0 items-center gap-2 border-b border-border px-4">
<SiteHeader />
</header>
<div class="flex flex-1 flex-col p-4">
{@render children()}
+615 -236
View File
@@ -9,35 +9,37 @@
fetchAggUniqueIps,
fetchStatsSummary
} from '$lib/api/client.js';
import type { SummaryData } from '$lib/api/summary-types.js';
import type { SummaryData, TopUser } from '$lib/api/summary-types.js';
import type { components } from '$lib/api/aggregate.gen.js';
import { formatDurationSeconds, formatMiB, flagEmoji } from '$lib/format.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import IpServerPresence from '$lib/components/ip-server-presence.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import FleetOverviewCharts from '$lib/components/fleet/fleet-overview-charts.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { cn } from '$lib/utils.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
import ServerIcon from '@lucide/svelte/icons/server';
import UsersIcon from '@lucide/svelte/icons/users';
import UnplugIcon from '@lucide/svelte/icons/unplug';
import GlobeIcon from '@lucide/svelte/icons/globe';
import ActivityIcon from '@lucide/svelte/icons/activity';
import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole';
import InfoIcon from '@lucide/svelte/icons/info';
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';
import KpiTrendCard from '$lib/components/kpi-trend-card.svelte';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import TableEmpty from '$lib/components/data-table/table-empty.svelte';
import StatusBadge from '$lib/components/status-badge.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import MoreVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import SearchIcon from '@lucide/svelte/icons/search';
import { Badge } from '$lib/components/ui/badge/index.js';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
let loading = $state(true);
let err = $state<string | null>(null);
@@ -61,6 +63,58 @@
>
>({});
type FleetKpiPrev = {
serversOk: number;
serversTotal: number;
connections: number;
bad: number;
uniqueIp: number;
megabytes: number;
readOnly: number;
};
let kpiPrev = $state<FleetKpiPrev | null>(null);
function sumBad(stats: typeof nodeStats): number {
let n = 0;
for (const v of Object.values(stats)) {
if (v && 'ok' in v && v.ok && typeof v.bad === 'number') n += v.bad;
}
return n;
}
function countUniqueIps(rows: components['schemas']['UniqueIPsRow'][] | null): number {
const set = new Set<string>();
for (const row of rows ?? []) {
for (const ip of row.ips ?? []) {
if (ip.ip) set.add(ip.ip);
}
}
return set.size;
}
function countReadOnlyNodes(f: components['schemas']['FleetStatusData'] | null): number {
let n = 0;
for (const s of f?.servers ?? []) {
if (s.health?.read_only) n++;
}
return n;
}
/** Таблицы: сортировка и поиск */
let ipFilter = $state('');
let ipSortKey = $state<'ip' | 'user' | null>(null);
let ipSortDir = $state<SortDir | null>(null);
let nodeFilter = $state('');
let nodeStatusFilter = $state<'all' | 'ok' | 'degraded'>('all');
let nodeSortKey = $state<'alias' | 'latency' | 'bad' | null>(null);
let nodeSortDir = $state<SortDir | null>(null);
let topFilter = $state('');
let topSortKey = $state<'user' | 'traffic' | 'ips' | null>(null);
let topSortDir = $state<SortDir | null>(null);
function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value;
@@ -85,6 +139,17 @@
fetchAggFleetStatus({ aliases: aliasFilter === 'all' ? undefined : aliasFilter }),
fetchAggUniqueIps({ aliases: aliasFilter === 'all' ? undefined : aliasFilter, geo: true })
]);
if (summary !== null && fleet !== null) {
kpiPrev = {
serversOk: fleet.servers_all_ok ?? summary.servers_ok,
serversTotal: fleet.servers_total ?? summary.servers_total,
connections: summary.fleet_total_connections,
bad: sumBad(nodeStats),
uniqueIp: countUniqueIps(uniqueIps),
megabytes: summary.fleet_total_megabytes,
readOnly: countReadOnlyNodes(fleet)
};
}
generatedAt = s.generated_at;
partial = !!(s.partial || f.partial || u.partial);
summary = s.data;
@@ -266,26 +331,15 @@
return n;
});
let nodesHealthAccent = $derived.by((): KpiAccentKey => {
const t = (fleet?.servers_total ?? summary?.servers_total) ?? 0;
const ok = (fleet?.servers_all_ok ?? summary?.servers_ok) ?? 0;
if (t === 0) return 'neutral';
if (ok === t) return 'success';
if (ok === 0) return 'danger';
return 'warning';
let nodesOkChart = $derived.by(() => {
const list = fleet?.servers ?? [];
return list.filter((s) => s.health_ok && s.system_info_ok).length;
});
/** Пороги условные: 0 — норма; дальше — внимание и «сильное» внимание к метрике bad. */
let badConnectionsAccent = $derived.by((): KpiAccentKey => {
if (totalBad === 0) return 'success';
if (totalBad < 10_000) return 'warning';
return 'danger';
let nodesDegradedChart = $derived.by(() => {
const list = fleet?.servers ?? [];
return list.filter((s) => !s.health_ok || !s.system_info_ok).length;
});
let readOnlyAccent = $derived.by((): KpiAccentKey =>
readOnlyNodes === 0 ? 'success' : 'warning'
);
function chipLabel(ip: components['schemas']['IPAssignments']): string {
const cc = ip.country_code?.trim();
const flag = flagEmoji(cc ?? undefined);
@@ -373,6 +427,95 @@
out.sort((a, b) => a.ip.localeCompare(b.ip) || a.username.localeCompare(b.username));
return out;
});
let filteredSortedIpRows = $derived.by(() => {
let rows = activeIpServerRows;
const q = ipFilter.trim().toLowerCase();
if (q) {
rows = rows.filter(
(r) =>
r.username.toLowerCase().includes(q) ||
r.ip.toLowerCase().includes(q) ||
r.activeLabel.toLowerCase().includes(q)
);
}
const k = ipSortKey;
const d = ipSortDir;
if (!k || !d) return rows;
const copy = [...rows];
if (k === 'ip') copy.sort((a, b) => compareStrings(a.ip, b.ip, d));
else copy.sort((a, b) => compareStrings(a.username, b.username, d));
return copy;
});
type NodeRow = components['schemas']['FleetServerStatus'];
let filteredSortedNodeRows = $derived.by((): NodeRow[] => {
let rows = [...(fleet?.servers ?? [])];
const q = nodeFilter.trim().toLowerCase();
if (q) rows = rows.filter((s) => (s.alias ?? '').toLowerCase().includes(q));
if (nodeStatusFilter === 'ok') rows = rows.filter((s) => s.health_ok && s.system_info_ok);
if (nodeStatusFilter === 'degraded')
rows = rows.filter((s) => !s.health_ok || !s.system_info_ok);
const k = nodeSortKey;
const d = nodeSortDir;
if (k && d) {
rows.sort((a, b) => {
if (k === 'alias') return compareStrings(a.alias ?? '', b.alias ?? '', d);
if (k === 'latency') {
const la = a.health_latency_ms ?? -1;
const lb = b.health_latency_ms ?? -1;
return compareNum(la, lb, d);
}
if (k === 'bad') {
const ba =
a.alias && nodeStats[a.alias] && 'bad' in nodeStats[a.alias]!
? (nodeStats[a.alias] as { bad?: number }).bad ?? -1
: -1;
const bb =
b.alias && nodeStats[b.alias] && 'bad' in nodeStats[b.alias]!
? (nodeStats[b.alias] as { bad?: number }).bad ?? -1
: -1;
return compareNum(ba, bb, d);
}
return 0;
});
}
return rows;
});
let filteredSortedTopUsers = $derived.by((): TopUser[] => {
if (!summary) return [];
let rows = [...summary.top_users.slice(0, 10)];
const q = topFilter.trim().toLowerCase();
if (q) rows = rows.filter((t) => t.username.toLowerCase().includes(q));
const k = topSortKey;
const d = topSortDir;
if (k && d) {
rows.sort((a, b) => {
if (k === 'user') return compareStrings(a.username, b.username, d);
if (k === 'traffic') return compareNum(a.total_megabytes, b.total_megabytes, d);
return compareNum(uniqueIpCountByUser[a.username] ?? 0, uniqueIpCountByUser[b.username] ?? 0, d);
});
}
return rows;
});
function toggleIpSort(key: 'ip' | 'user') {
const next = nextSortDir(ipSortDir, key, ipSortKey);
ipSortKey = next === null ? null : key;
ipSortDir = next;
}
function toggleNodeSort(key: 'alias' | 'latency' | 'bad') {
const next = nextSortDir(nodeSortDir, key, nodeSortKey);
nodeSortKey = next === null ? null : key;
nodeSortDir = next;
}
function toggleTopSort(key: 'user' | 'traffic' | 'ips') {
const next = nextSortDir(topSortDir, key, topSortKey);
topSortKey = next === null ? null : key;
topSortDir = next;
}
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
@@ -420,229 +563,465 @@
{#snippet skeleton()}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{#each Array.from({ length: 6 }) as _, i (i)}
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<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.Root class="border border-border shadow-sm">
<Card.Header class="space-y-2 pb-2">
<Skeleton class="h-3 w-24" />
<Skeleton class="h-8 w-20" />
</Card.Header>
<Card.Content class="pt-0">
<Skeleton class="h-3 w-28" />
<Skeleton class="mx-4 h-px" />
<Card.Content class="pt-3">
<Skeleton class="h-3 w-full" />
</Card.Content>
</Card.Root>
{/each}
</div>
<Skeleton class="mb-4 h-32 w-full rounded-lg" />
<Skeleton class="mb-4 h-56 w-full rounded-lg" />
<Skeleton class="mb-4 h-56 w-full rounded-lg" />
{/snippet}
{#snippet children()}
{#if summary}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<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>
{@const okN = fleet?.servers_all_ok ?? summary.servers_ok}
{@const totN = fleet?.servers_total ?? summary.servers_total}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<KpiTrendCard
label="Ноды OK / всего"
valueDisplay="{okN}/{totN}"
prevValue={kpiPrev?.serversOk ?? null}
currentValue={okN}
insight="Успешный health + system/info. Динамика — по числу OK-нод к прошлому опросу."
/>
<KpiTrendCard
label="Соединения (флот)"
valueDisplay={String(summary.fleet_total_connections)}
prevValue={kpiPrev?.connections ?? null}
currentValue={summary.fleet_total_connections}
insight="Текущие соединения по агрегату stats/users."
/>
<KpiTrendCard
label="Bad connections"
valueDisplay={String(totalBad)}
prevValue={kpiPrev?.bad ?? null}
currentValue={totalBad}
invertTrend={true}
insight="Сумма connections_bad по нодам (stats/summary). Меньше — лучше."
/>
<KpiTrendCard
label="Уникальные IP"
valueDisplay={String(uniqueIpCount)}
prevValue={kpiPrev?.uniqueIp ?? null}
currentValue={uniqueIpCount}
insight="По снимку unique-ips на шлюзе."
/>
<KpiTrendCard
label="Трафик флота (MiB)"
valueDisplay={formatMiB(summary.fleet_total_megabytes)}
prevValue={kpiPrev?.megabytes ?? null}
currentValue={summary.fleet_total_megabytes}
insight="Суммарный трафик по снимку summary."
/>
<KpiTrendCard
label="Read-only API (нод)"
valueDisplay={String(readOnlyNodes)}
prevValue={kpiPrev?.readOnly ?? null}
currentValue={readOnlyNodes}
invertTrend={true}
insight="Ноды с read_only в health. Меньше — лучше."
/>
</div>
<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>
<Table.Head class="text-xs sm:text-sm">IP со страной</Table.Head>
<Table.Head class="text-xs sm:text-sm">Клиент</Table.Head>
<Table.Head class="text-xs sm:text-sm">
<div class="flex items-center gap-1">
Серверы
<Tooltip.Root>
<Tooltip.Trigger
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
aria-label="Пояснение колонки"
>
<InfoIcon class="size-3.5" />
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="max-w-xs">
<p class="text-xs">
Активные и недавние — списки на момент снимка шлюза; в ячейке пояснены
группы.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if (activeIpServerRows?.length ?? 0) === 0}
<FleetOverviewCharts
nodesOk={nodesOkChart}
nodesDegraded={nodesDegradedChart}
topUsers={summary.top_users.slice(0, 8)}
/>
<DataTableCard
class="mb-6"
title="Активные IP (агрегат)"
description="С GeoIP при включённой базе на шлюзе"
>
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-xs flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-8 ps-8"
placeholder="Поиск IP, пользователя…"
bind:value={ipFilter}
/>
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
ipFilter = '';
ipSortKey = null;
ipSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
<Table.Row>
<Table.Cell colspan={3} class="p-4 text-center text-muted-foreground">
Нет активных подключений
</Table.Cell>
<SortableTh
active={ipSortKey === 'ip'}
dir={ipSortDir}
onToggle={() => toggleIpSort('ip')}
>IP / страна</SortableTh>
<SortableTh
active={ipSortKey === 'user'}
dir={ipSortDir}
onToggle={() => toggleIpSort('user')}
>Пользователь</SortableTh>
<Table.Head class="text-xs sm:text-sm">
<div class="flex items-center gap-1">
Серверы
<Tooltip.Root>
<Tooltip.Trigger
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
aria-label="Пояснение колонки"
>
<InfoIcon class="size-3.5" />
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="max-w-xs">
<p class="text-xs">
Активные и недавние — на момент снимка шлюза.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</Table.Head>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
{:else}
{#each activeIpServerRows as r (r.username + '|' + r.ip)}
</Table.Header>
<Table.Body>
{#if activeIpServerRows.length === 0}
<Table.Row>
<Table.Cell class="font-mono text-xs sm:text-sm whitespace-normal break-all">
{r.activeLabel}
</Table.Cell>
<Table.Cell class="text-xs sm:text-sm">
<a
href="/users/{encodeURIComponent(r.username)}"
class="text-primary hover:underline"
>
{r.username}
</a>
</Table.Cell>
<Table.Cell class="max-w-[280px] align-top text-xs sm:text-sm whitespace-normal">
<IpServerPresence
active_on_servers={r.active_on_servers}
recent_on_servers={r.recent_on_servers}
primary_server={r.primary_server}
<Table.Cell colspan={4} class="p-0">
<TableEmpty
title="Нет активных подключений"
description="В снимке unique-ips нет IP с активными серверами."
/>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</KpiPanelCard>
{:else if filteredSortedIpRows.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-0">
<TableEmpty
title="Нет строк по фильтру"
description="Измените поиск или сбросьте фильтры."
/>
</Table.Cell>
</Table.Row>
{:else}
{#each filteredSortedIpRows as r (r.username + '|' + r.ip)}
<Table.Row>
<Table.Cell class="font-mono text-xs sm:text-sm whitespace-normal break-all">
<Badge variant="secondary" class="font-mono font-normal">{r.activeLabel}</Badge>
</Table.Cell>
<Table.Cell class="text-xs sm:text-sm">
<a
href="/users/{encodeURIComponent(r.username)}"
class="text-primary hover:underline"
>
{r.username}
</a>
</Table.Cell>
<Table.Cell class="max-w-[280px] align-top text-xs sm:text-sm whitespace-normal">
<IpServerPresence
active_on_servers={r.active_on_servers}
recent_on_servers={r.recent_on_servers}
primary_server={r.primary_server}
/>
</Table.Cell>
<Table.Cell class="w-10 text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
window.open(
`/users/${encodeURIComponent(r.username)}`,
'_blank'
)}
>Открыть пользователя</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</ScrollArea>
</DataTableCard>
<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>
<Table.Head>Сервер</Table.Head>
<Table.Head>Статус</Table.Head>
<Table.Head class="text-right">Latency</Table.Head>
<Table.Head class="text-right">Версия</Table.Head>
<Table.Head class="text-right">Аптайм</Table.Head>
<Table.Head class="text-right">Bad conn.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each fleet?.servers ?? [] as srv (srv.alias)}
<Table.Row>
<Table.Cell class="font-medium">
<a class="text-primary hover:underline" href="/servers/{encodeURIComponent(srv.alias ?? '')}">
{srv.alias}
</a>
</Table.Cell>
<Table.Cell>
{#if srv.health_ok && srv.system_info_ok}
<Badge class="bg-emerald-600/20 text-emerald-400">OK</Badge>
{:else}
<Badge variant="destructive">degraded</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{srv.health_latency_ms ?? '—'} ms
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground">
{srv.system_info?.version ?? '—'}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground">
{srv.system_info && srv.system_info.uptime_seconds != null
? formatDurationSeconds(srv.system_info.uptime_seconds)
: '—'}
</Table.Cell>
<Table.Cell class="text-right tabular-nums">
{#if srv.alias}
{@const st = nodeStats[srv.alias]}
{#if !st}
<span title="Нет ответа stats/summary для этой ноды"></span>
{:else if st && 'ok' in st && !st.ok && 'error' in st}
<span class="text-xs text-muted-foreground" title={st.error}>—</span>
{:else if st && 'bad' in st && st.ok}
{st.bad ?? 0}
{:else}
<span title="Неожиданное состояние stats"></span>
{/if}
{:else}
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</KpiPanelCard>
<DataTableCard
class="mb-6"
title="По нодам"
description="Health, версия, аптайм, bad connections"
>
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-xs flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-8 ps-8"
placeholder="Поиск по alias…"
bind:value={nodeFilter}
/>
</div>
<div class="flex flex-wrap gap-1">
<Button
size="sm"
variant={nodeStatusFilter === 'all' ? 'default' : 'outline'}
class="h-8"
onclick={() => (nodeStatusFilter = 'all')}
>Все</Button>
<Button
size="sm"
variant={nodeStatusFilter === 'ok' ? 'default' : 'outline'}
class="h-8"
onclick={() => (nodeStatusFilter = 'ok')}
>OK</Button>
<Button
size="sm"
variant={nodeStatusFilter === 'degraded' ? 'default' : 'outline'}
class="h-8"
onclick={() => (nodeStatusFilter = 'degraded')}
>Degraded</Button>
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
nodeFilter = '';
nodeStatusFilter = 'all';
nodeSortKey = null;
nodeSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(70vh,480px)] w-full" orientation="both">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
<Table.Row>
<SortableTh
active={nodeSortKey === 'alias'}
dir={nodeSortDir}
onToggle={() => toggleNodeSort('alias')}
>Сервер</SortableTh>
<Table.Head>Статус</Table.Head>
<SortableTh
numeric
active={nodeSortKey === 'latency'}
dir={nodeSortDir}
onToggle={() => toggleNodeSort('latency')}
class="text-right"
>Latency</SortableTh>
<Table.Head class="text-right">Версия</Table.Head>
<Table.Head class="text-right">Аптайм</Table.Head>
<SortableTh
numeric
active={nodeSortKey === 'bad'}
dir={nodeSortDir}
onToggle={() => toggleNodeSort('bad')}
class="text-right"
>Bad conn.</SortableTh>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if filteredSortedNodeRows.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="p-0">
<TableEmpty title="Нет нод по фильтру" />
</Table.Cell>
</Table.Row>
{:else}
{#each filteredSortedNodeRows as srv (srv.alias)}
<Table.Row>
<Table.Cell class="font-medium">
<a
class="text-primary hover:underline"
href="/servers/{encodeURIComponent(srv.alias ?? '')}"
>
{srv.alias}
</a>
</Table.Cell>
<Table.Cell>
{#if srv.health_ok && srv.system_info_ok}
<StatusBadge variant="ok" />
{:else}
<StatusBadge variant="degraded" />
{/if}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{srv.health_latency_ms ?? '—'} ms
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground">
<Badge variant="secondary">{srv.system_info?.version ?? '—'}</Badge>
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground">
{srv.system_info && srv.system_info.uptime_seconds != null
? formatDurationSeconds(srv.system_info.uptime_seconds)
: '—'}
</Table.Cell>
<Table.Cell class="text-right tabular-nums">
{#if srv.alias}
{@const st = nodeStats[srv.alias]}
{#if !st}
<StatusBadge variant="pending" />
{:else if st && 'ok' in st && !st.ok && 'error' in st}
<span class="text-xs text-muted-foreground" title={st.error}>—</span>
{:else if st && 'bad' in st && st.ok}
{st.bad ?? 0}
{:else}
<span title="Неожиданное состояние stats">—</span>
{/if}
{:else}
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
window.open(
`/servers/${encodeURIComponent(srv.alias ?? '')}`,
'_blank'
)}
>Открыть ноду</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</ScrollArea>
</DataTableCard>
<KpiPanelCard class="mt-6" accent="teal" title="Топ пользователей по трафику" contentClass="">
{#snippet icon()}
<ChartNoAxesCombinedIcon class="size-5" aria-hidden="true" />
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Пользователь</Table.Head>
<Table.Head class="text-right">Трафик</Table.Head>
<Table.Head class="text-right">Замеченные IP</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each summary.top_users.slice(0, 10) as t (t.username)}
<Table.Row>
<Table.Cell class="font-medium">
<a href="/users/{encodeURIComponent(t.username)}" class="text-primary hover:underline">
{t.username}
</a>
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">{formatMiB(t.total_megabytes)}</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{uniqueIpCountByUser[t.username] ?? 0}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</KpiPanelCard>
<DataTableCard
class="mt-6"
title="Топ пользователей по трафику"
description="До 10 записей из summary"
>
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-xs flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск пользователя…" bind:value={topFilter} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
topFilter = '';
topSortKey = null;
topSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(60vh,400px)] w-full" orientation="vertical">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
<Table.Row>
<SortableTh
active={topSortKey === 'user'}
dir={topSortDir}
onToggle={() => toggleTopSort('user')}
>Пользователь</SortableTh>
<SortableTh
numeric
active={topSortKey === 'traffic'}
dir={topSortDir}
onToggle={() => toggleTopSort('traffic')}
class="text-right"
>Трафик</SortableTh>
<SortableTh
numeric
active={topSortKey === 'ips'}
dir={topSortDir}
onToggle={() => toggleTopSort('ips')}
class="text-right"
>IP</SortableTh>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if filteredSortedTopUsers.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-0">
<TableEmpty title="Нет данных" />
</Table.Cell>
</Table.Row>
{:else}
{#each filteredSortedTopUsers as t (t.username)}
<Table.Row>
<Table.Cell class="font-medium">
<a href="/users/{encodeURIComponent(t.username)}" class="text-primary hover:underline">
{t.username}
</a>
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{formatMiB(t.total_megabytes)}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{uniqueIpCountByUser[t.username] ?? 0}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-xs" aria-label="Действия">
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
window.open(`/users/${encodeURIComponent(t.username)}`, '_blank')}
>Открыть</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</ScrollArea>
</DataTableCard>
{/if}
{/snippet}
</DataQueryState>
+125 -34
View File
@@ -10,12 +10,14 @@
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 KpiTrendCard from '$lib/components/kpi-trend-card.svelte';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import TableEmpty from '$lib/components/data-table/table-empty.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
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';
@@ -54,6 +56,13 @@
let staleTimer: ReturnType<typeof setInterval> | null = null;
let triageById = $state<Record<string, TriageState>>({});
type IncKpiPrev = { critical: number; warning: number; info: number };
let kpiPrev = $state<IncKpiPrev | null>(null);
let incSearch = $state('');
let incSortKey = $state<'sev' | 'title' | null>(null);
let incSortDir = $state<SortDir | null>(null);
function parseRefresh(raw: string | null): number {
if (raw == null || raw.trim() === '') return 30;
const n = Number(raw);
@@ -127,14 +136,29 @@
}
}
function sevRank(s: IncidentItem['severity']): number {
if (s === 'critical') return 3;
if (s === 'warning') return 2;
return 1;
}
async function load(aliasOverride?: string) {
loading = true;
err = null;
try {
const activeAlias = aliasOverride ?? aliasFilter;
const prev =
data != null
? {
critical: data.critical_total ?? 0,
warning: data.warning_total ?? 0,
info: data.info_total ?? 0
}
: null;
const env = await fetchAggIncidents({
aliases: activeAlias === 'all' ? undefined : activeAlias
});
kpiPrev = prev;
data = env.data ?? null;
partial = !!env.partial;
generatedAt = env.generated_at;
@@ -225,6 +249,35 @@
let hasStaleData = $derived(err != null && data != null);
let showSkeleton = $derived(loading && data === null && !err);
let filteredIncidents = $derived.by((): IncidentItem[] => {
const items = data?.items ?? [];
const q = incSearch.trim().toLowerCase();
let out = items;
if (q) {
out = items.filter(
(it) =>
(it.title ?? '').toLowerCase().includes(q) ||
(it.summary ?? '').toLowerCase().includes(q) ||
(it.severity ?? '').toLowerCase().includes(q)
);
}
const k = incSortKey;
const d = incSortDir;
if (k && d) {
out = [...out].sort((a, b) => {
if (k === 'sev') return compareNum(sevRank(a.severity), sevRank(b.severity), d);
return compareStrings(a.title ?? '', b.title ?? '', d);
});
}
return out;
});
function toggleIncSort(key: 'sev' | 'title') {
const next = nextSortDir(incSortDir, key, incSortKey);
incSortKey = next === null ? null : key;
incSortDir = next;
}
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
@@ -277,35 +330,66 @@
{/snippet}
{#snippet children()}
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<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>
<KpiTrendCard
label="Critical"
valueDisplay={String(data?.critical_total ?? 0)}
prevValue={kpiPrev?.critical ?? null}
currentValue={data != null ? (data.critical_total ?? 0) : null}
invertTrend={true}
insight="Открытые critical; меньше — лучше. К прошлому опросу."
/>
<KpiTrendCard
label="Warning"
valueDisplay={String(data?.warning_total ?? 0)}
prevValue={kpiPrev?.warning ?? null}
currentValue={data != null ? (data.warning_total ?? 0) : null}
invertTrend={true}
insight="Открытые warning; меньше — лучше."
/>
<KpiTrendCard
label="Info"
valueDisplay={String(data?.info_total ?? 0)}
prevValue={kpiPrev?.info ?? null}
currentValue={data != null ? (data.info_total ?? 0) : null}
insight="Открытые info. К прошлому опросу."
/>
</div>
<KpiPanelCard accent="neutral" title="Инциденты" description="Список и triage" contentClass="p-0">
{#snippet icon()}
<ClipboardListIcon class="size-5" aria-hidden="true" />
<DataTableCard title="Инциденты" description="Список и triage">
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-md flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск по заголовку, summary, severity…" bind:value={incSearch} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
incSearch = '';
incSortKey = null;
incSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(75vh,640px)] w-full" orientation="both">
<Table.Root>
<Table.Header>
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
<Table.Row>
<Table.Head>Severity</Table.Head>
<Table.Head>Инцидент</Table.Head>
<SortableTh
active={incSortKey === 'sev'}
dir={incSortDir}
onToggle={() => toggleIncSort('sev')}
>Severity</SortableTh>
<SortableTh
active={incSortKey === 'title'}
dir={incSortDir}
onToggle={() => toggleIncSort('title')}
>Инцидент</SortableTh>
<Table.Head>
<div class="flex items-center gap-1">
Серверы
@@ -329,12 +413,18 @@
<Table.Body>
{#if (data?.items.length ?? 0) === 0}
<Table.Row>
<Table.Cell colspan={5} class="p-4 text-center text-muted-foreground">
Инцидентов нет
<Table.Cell colspan={5} class="p-0">
<TableEmpty title="Инцидентов нет" description="На снимке пусто." />
</Table.Cell>
</Table.Row>
{:else if filteredIncidents.length === 0}
<Table.Row>
<Table.Cell colspan={5} class="p-0">
<TableEmpty title="Нет по фильтру" description="Измените поиск." />
</Table.Cell>
</Table.Row>
{:else}
{#each data?.items ?? [] as item (item.id)}
{#each filteredIncidents as item (item.id)}
<Table.Row>
<Table.Cell>
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
@@ -408,6 +498,7 @@
{/if}
</Table.Body>
</Table.Root>
</KpiPanelCard>
</ScrollArea>
</DataTableCard>
{/snippet}
</DataQueryState>
+110 -14
View File
@@ -9,8 +9,14 @@
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 DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, type SortDir } from '$lib/table-sort.js';
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';
@@ -49,6 +55,59 @@
let mapStatus = $state<string | null>(null);
let renderAttempts = $state(0);
type IpFlatRow = {
username: string;
ip: components['schemas']['IPAssignments'];
};
let ipSearch = $state('');
let ipSortKey = $state<'user' | 'ip' | 'geo' | null>(null);
let ipSortDir = $state<SortDir | null>(null);
let flatIpRows = $derived.by((): IpFlatRow[] => {
const out: IpFlatRow[] = [];
for (const ur of rows) {
const u = ur.username ?? '';
for (const ipa of ur.ips ?? []) {
if (ipa?.ip) out.push({ username: u, ip: ipa });
}
}
return out;
});
let displayedIpRows = $derived.by(() => {
let r = flatIpRows;
const q = ipSearch.trim().toLowerCase();
if (q) {
r = r.filter(
({ username, ip }) =>
username.toLowerCase().includes(q) ||
(ip.ip ?? '').toLowerCase().includes(q) ||
(ip.country_code ?? '').toLowerCase().includes(q) ||
(ip.city_name ?? '').toLowerCase().includes(q) ||
String(ip.asn ?? '').includes(q)
);
}
const k = ipSortKey;
const d = ipSortDir;
if (k && d) {
r = [...r].sort((a, b) => {
if (k === 'user') return compareStrings(a.username, b.username, d);
if (k === 'ip') return compareStrings(a.ip.ip ?? '', b.ip.ip ?? '', d);
const ga = `${a.ip.country_code ?? ''} ${a.ip.city_name ?? ''}`;
const gb = `${b.ip.country_code ?? ''} ${b.ip.city_name ?? ''}`;
return compareStrings(ga, gb, d);
});
}
return r;
});
function toggleIpSort(key: 'user' | 'ip' | 'geo') {
const next = nextSortDir(ipSortDir, key, ipSortKey);
ipSortKey = next === null ? null : key;
ipSortDir = next;
}
function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value;
@@ -528,16 +587,46 @@
</Alert>
{/if}
<KpiPanelCard accent="violet" title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" contentClass="p-0">
{#snippet icon()}
<GlobeIcon class="size-5" aria-hidden="true" />
<DataTableCard title="Уникальные IP" description="Снимок шлюза: активные и недавние списки">
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-md flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск: пользователь, IP, страна, город…" bind:value={ipSearch} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
ipSearch = '';
ipSortKey = null;
ipSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(75vh,560px)] w-full" orientation="both">
<Table.Root>
<Table.Header>
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
<Table.Row>
<Table.Head>Пользователь</Table.Head>
<Table.Head>IP</Table.Head>
<Table.Head>Geo</Table.Head>
<SortableTh
active={ipSortKey === 'user'}
dir={ipSortDir}
onToggle={() => toggleIpSort('user')}
>Пользователь</SortableTh>
<SortableTh
active={ipSortKey === 'ip'}
dir={ipSortDir}
onToggle={() => toggleIpSort('ip')}
>IP</SortableTh>
<SortableTh
active={ipSortKey === 'geo'}
dir={ipSortDir}
onToggle={() => toggleIpSort('geo')}
>Geo</SortableTh>
<Table.Head>
<div class="flex items-center gap-1">
Серверы
@@ -559,12 +648,18 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as ur (ur.username)}
{#each ur.ips ?? [] as ip (ur.username + (ip.ip ?? ''))}
{#if displayedIpRows.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-4 text-center text-muted-foreground">
Нет строк по фильтру или снимок пуст.
</Table.Cell>
</Table.Row>
{:else}
{#each displayedIpRows as { username, ip } (username + (ip.ip ?? ''))}
<Table.Row>
<Table.Cell class="font-medium">
<a href="/users/{encodeURIComponent(ur.username ?? '')}" class="text-primary hover:underline">
{ur.username}
<a href="/users/{encodeURIComponent(username)}" class="text-primary hover:underline">
{username}
</a>
</Table.Cell>
<Table.Cell class="font-mono text-sm">{ip.ip}</Table.Cell>
@@ -587,10 +682,11 @@
</Table.Cell>
</Table.Row>
{/each}
{/each}
{/if}
</Table.Body>
</Table.Root>
</KpiPanelCard>
</ScrollArea>
</DataTableCard>
{/snippet}
</DataQueryState>
+87 -12
View File
@@ -10,11 +10,16 @@
type IncidentStatus
} from '$lib/api/client.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
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';
@@ -56,6 +61,45 @@
let staleTimer: ReturnType<typeof setInterval> | null = null;
let es: EventSource | null = null;
let liveIncSearch = $state('');
let liveSortKey = $state<'sev' | 'title' | null>(null);
let liveSortDir = $state<SortDir | null>(null);
function liveSevRank(s: IncidentSeverity): number {
if (s === 'critical') return 3;
if (s === 'warning') return 2;
return 1;
}
let filteredLiveIncidents = $derived.by((): IncidentItem[] => {
const items = snapshot?.incidents ?? [];
const q = liveIncSearch.trim().toLowerCase();
let out = items;
if (q) {
out = items.filter(
(it) =>
(it.title ?? '').toLowerCase().includes(q) ||
(it.summary ?? '').toLowerCase().includes(q) ||
(it.severity ?? '').toLowerCase().includes(q)
);
}
const k = liveSortKey;
const d = liveSortDir;
if (k && d) {
out = [...out].sort((a, b) => {
if (k === 'sev') return compareNum(liveSevRank(a.severity), liveSevRank(b.severity), d);
return compareStrings(a.title, b.title, d);
});
}
return out;
});
function toggleLiveSort(key: 'sev' | 'title') {
const next = nextSortDir(liveSortDir, key, liveSortKey);
liveSortKey = next === null ? null : key;
liveSortDir = next;
}
function severityVariant(sev: IncidentSeverity): 'default' | 'secondary' | 'destructive' | 'outline' {
if (sev === 'critical') return 'destructive';
if (sev === 'warning') return 'secondary';
@@ -334,20 +378,44 @@
</KpiStatCard>
</div>
<KpiPanelCard
accent="danger"
<DataTableCard
title="Последние incidents из snapshot"
description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}"
contentClass="p-0"
>
{#snippet icon()}
<BellIcon class="size-5" aria-hidden="true" />
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-md flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск по severity, title, summary…" bind:value={liveIncSearch} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
liveIncSearch = '';
liveSortKey = null;
liveSortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
<Table.Root>
<Table.Header>
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
<Table.Row>
<Table.Head>Severity</Table.Head>
<Table.Head>Title</Table.Head>
<SortableTh
active={liveSortKey === 'sev'}
dir={liveSortDir}
onToggle={() => toggleLiveSort('sev')}
>Severity</SortableTh>
<SortableTh
active={liveSortKey === 'title'}
dir={liveSortDir}
onToggle={() => toggleLiveSort('title')}
>Title</SortableTh>
<Table.Head>Summary</Table.Head>
<Table.Head>
<div class="flex items-center gap-1">
@@ -374,8 +442,14 @@
Инцидентов нет
</Table.Cell>
</Table.Row>
{:else if filteredLiveIncidents.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-4 text-center text-muted-foreground">
Нет строк по фильтру
</Table.Cell>
</Table.Row>
{:else}
{#each snapshot?.incidents ?? [] as item (item.id)}
{#each filteredLiveIncidents as item (item.id)}
<Table.Row>
<Table.Cell>
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
@@ -390,4 +464,5 @@
{/if}
</Table.Body>
</Table.Root>
</KpiPanelCard>
</ScrollArea>
</DataTableCard>
+103 -68
View File
@@ -1,22 +1,14 @@
<script lang="ts">
import { page } from '$app/state';
import {
ApiError,
fetchStatsSummary,
fetchTelemt
} from '$lib/api/client.js';
import { ApiError, fetchStatsSummary, fetchTelemt } from '$lib/api/client.js';
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
import { formatDurationSeconds, formatMiB } from '$lib/format.js';
import { formatDurationSeconds } from '$lib/format.js';
import * as Card from '$lib/components/ui/card/index.js';
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
import KpiTrendCard from '$lib/components/kpi-trend-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';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
@@ -30,15 +22,45 @@
let summary = $state<StatsSummaryData | null>(null);
let sys = $state<SystemInfoData | null>(null);
type NodeKpiPrev = {
uptime: number;
conn: number;
bad: number;
users: number;
handshake: number;
};
let kpiPrev = $state<NodeKpiPrev | null>(null);
function num(v: unknown): number {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '') {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return 0;
}
async function loadFor(a: string) {
loading = true;
err = null;
try {
const prev =
summary && sys
? {
uptime: num(sys.uptime_seconds),
conn: num(summary.connections_total),
bad: num(summary.connections_bad_total),
users: num(summary.configured_users),
handshake: num(summary.handshake_timeouts_total)
}
: null;
const [h, s, i] = await Promise.all([
fetchTelemt<HealthData>(a, 'health'),
fetchStatsSummary(a),
fetchTelemt<SystemInfoData>(a, 'system/info')
]);
kpiPrev = prev;
health = h.data;
summary = s.data;
sys = i.data;
@@ -64,11 +86,11 @@
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';
});
let uptimeSec = $derived(num(sys?.uptime_seconds));
let connTotal = $derived(num(summary?.connections_total));
let badTotal = $derived(num(summary?.connections_bad_total));
let cfgUsers = $derived(num(summary?.configured_users));
let handshakeT = $derived(num(summary?.handshake_timeouts_total));
</script>
<div class="mb-6 flex items-center justify-between gap-4">
@@ -92,18 +114,14 @@
{#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 class="border-l-4 border-l-muted-foreground/30 shadow-sm">
<Card.Header class="pb-2">
<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.Root class="border border-border shadow-sm">
<Card.Header class="space-y-2 pb-2">
<Skeleton class="h-3 w-24" />
<Skeleton class="h-8 w-20" />
</Card.Header>
<Card.Content class="pt-0">
<Skeleton class="h-3 w-32" />
<Skeleton class="mx-4 h-px" />
<Card.Content class="pt-3">
<Skeleton class="h-3 w-full" />
</Card.Content>
</Card.Root>
{/each}
@@ -111,47 +129,64 @@
<Skeleton class="h-40 w-full rounded-lg" />
{/snippet}
{#snippet children()}
{#if health?.status === 'ok'}
<Alert class="mb-4 border-emerald-500/30 bg-emerald-500/10">
<AlertTitle>Telemt отвечает</AlertTitle>
<AlertDescription>
read_only: {health.read_only ? 'да' : 'нет'}
</AlertDescription>
</Alert>
{/if}
{#if health?.status === 'ok'}
<Alert class="mb-4 border-emerald-500/30 bg-emerald-500/10">
<AlertTitle>Telemt отвечает</AlertTitle>
<AlertDescription>
read_only: {health.read_only ? 'да' : 'нет'}
</AlertDescription>
</Alert>
{/if}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<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>
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<KpiTrendCard
label="Аптайм"
valueDisplay={sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
prevValue={kpiPrev?.uptime ?? null}
currentValue={sys ? uptimeSec : null}
insight="Секунды uptime с ноды; к прошлому обновлению страницы."
/>
<KpiTrendCard
label="Соединения (всего)"
valueDisplay={summary ? String(connTotal) : '—'}
prevValue={kpiPrev?.conn ?? null}
currentValue={summary ? connTotal : null}
insight="stats/summary с ноды."
/>
<KpiTrendCard
label="Bad connections"
valueDisplay={summary ? String(badTotal) : '—'}
prevValue={kpiPrev?.bad ?? null}
currentValue={summary ? badTotal : null}
invertTrend={true}
insight="Счётчик на ноде; меньше — лучше."
/>
<KpiTrendCard
label="Handshake timeouts"
valueDisplay={summary ? String(handshakeT) : '—'}
prevValue={kpiPrev?.handshake ?? null}
currentValue={summary ? handshakeT : null}
invertTrend={true}
insight="handshake_timeouts_total; меньше — лучше."
/>
<KpiTrendCard
label="Пользователей в конфиге"
valueDisplay={summary ? String(cfgUsers) : '—'}
prevValue={kpiPrev?.users ?? null}
currentValue={summary ? cfgUsers : null}
insight="configured_users."
/>
</div>
{#if sys}
<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}
{#if sys}
<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>
@@ -162,7 +197,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>
</KpiPanelCard>
{/if}
</KpiPanelCard>
{/if}
{/snippet}
</DataQueryState>
+97 -18
View File
@@ -6,10 +6,15 @@
import type { components } from '$lib/api/aggregate.gen.js';
import { formatMiB } from '$lib/format.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/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 DataTableCard from '$lib/components/data-table/data-table-card.svelte';
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
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';
@@ -162,23 +167,59 @@
const usersPerPage = 25;
let usersPage = $state(1);
let userSearch = $state('');
let sortKey = $state<'name' | 'traffic' | 'ips' | 'exp' | null>(null);
let sortDir = $state<SortDir | null>(null);
let hasStaleData = $derived(err != null && rows.length > 0);
let showSkeleton = $derived(loading && rows.length === 0 && !err);
let isDataEmpty = $derived(!loading && rows.length === 0 && !err);
let pageCount = $derived(Math.max(1, Math.ceil(rows.length / usersPerPage)));
let processedRows = $derived.by(() => {
let r = [...rows];
const q = userSearch.trim().toLowerCase();
if (q) r = r.filter((row) => (row.username ?? '').toLowerCase().includes(q));
const k = sortKey;
const d = sortDir;
if (k && d) {
r.sort((a, b) => {
if (k === 'name') return compareStrings(a.username ?? '', b.username ?? '', d);
if (k === 'traffic')
return compareNum(a.total_megabytes ?? 0, b.total_megabytes ?? 0, d);
if (k === 'ips')
return compareNum(a.active_unique_ips ?? 0, b.active_unique_ips ?? 0, d);
if (k === 'exp') {
const ta = a.expiration_rfc3339 ? new Date(a.expiration_rfc3339).getTime() : 0;
const tb = b.expiration_rfc3339 ? new Date(b.expiration_rfc3339).getTime() : 0;
return compareNum(ta, tb, d);
}
return 0;
});
}
return r;
});
let pageCount = $derived(Math.max(1, Math.ceil(processedRows.length / usersPerPage)));
let pagedRows = $derived.by(() => {
const start = (usersPage - 1) * usersPerPage;
return rows.slice(start, start + usersPerPage);
return processedRows.slice(start, start + usersPerPage);
});
$effect(() => {
aliasFilter;
userSearch;
usersPage = 1;
});
$effect(() => {
if (usersPage > pageCount) usersPage = pageCount;
});
function toggleSort(key: 'name' | 'traffic' | 'ips' | 'exp') {
const next = nextSortDir(sortDir, key, sortKey);
sortKey = next === null ? null : key;
sortDir = next;
}
function copy(text: string) {
const value = String(text ?? '');
const done = () => toast.success('Скопировано в буфер');
@@ -281,23 +322,60 @@
</Card.Root>
{/snippet}
{#snippet children()}
<KpiPanelCard
accent="info"
<DataTableCard
title="Список пользователей"
description="Слияние по имени между серверами; клик по строке — карточка пользователя"
contentClass="p-0"
>
{#snippet icon()}
<UsersIcon class="size-5" aria-hidden="true" />
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-sm flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск по имени…" bind:value={userSearch} />
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
userSearch = '';
sortKey = null;
sortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
{/snippet}
<ScrollArea class="max-h-[min(70vh,560px)] w-full" orientation="both">
<Table.Root>
<Table.Header>
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
<Table.Row>
<Table.Head>Имя</Table.Head>
<SortableTh
active={sortKey === 'name'}
dir={sortDir}
onToggle={() => toggleSort('name')}
>Имя</SortableTh>
<Table.Head class="hidden sm:table-cell">Ссылки</Table.Head>
<Table.Head class="text-right">Трафик</Table.Head>
<Table.Head class="text-right">Активных IP</Table.Head>
<Table.Head class="hidden sm:table-cell">Истекает</Table.Head>
<SortableTh
numeric
active={sortKey === 'traffic'}
dir={sortDir}
onToggle={() => toggleSort('traffic')}
class="text-right"
>Трафик</SortableTh>
<SortableTh
numeric
active={sortKey === 'ips'}
dir={sortDir}
onToggle={() => toggleSort('ips')}
class="text-right"
>Активных IP</SortableTh>
<SortableTh
active={sortKey === 'exp'}
dir={sortDir}
onToggle={() => toggleSort('exp')}
class="hidden sm:table-cell"
>Истекает</SortableTh>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -362,14 +440,15 @@
{/each}
</Table.Body>
</Table.Root>
{#if rows.length > usersPerPage}
</ScrollArea>
{#if processedRows.length > usersPerPage}
<div
class="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3 text-sm text-muted-foreground"
>
<span>
{rows.length === 0
{processedRows.length === 0
? '0'
: `${(usersPage - 1) * usersPerPage + 1}${Math.min(usersPage * usersPerPage, rows.length)}`} из {rows.length}
: `${(usersPage - 1) * usersPerPage + 1}${Math.min(usersPage * usersPerPage, processedRows.length)}`} из {processedRows.length}
</span>
<div class="flex gap-2">
<Button
@@ -387,6 +466,6 @@
</div>
</div>
{/if}
</KpiPanelCard>
</DataTableCard>
{/snippet}
</DataQueryState>