Enhance Mihomo overview charts component with improved theming and chart management
- 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:
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)}%`;
|
||||
}
|
||||
@@ -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(' · ');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user