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">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
import Chart from 'chart.js/auto';
|
import Chart from 'chart.js/auto';
|
||||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||||
import ActivityIcon from '@lucide/svelte/icons/activity';
|
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
import PlugIcon from '@lucide/svelte/icons/plug';
|
import PlugIcon from '@lucide/svelte/icons/plug';
|
||||||
import NetworkIcon from '@lucide/svelte/icons/network';
|
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||||
import GaugeIcon from '@lucide/svelte/icons/gauge';
|
import GaugeIcon from '@lucide/svelte/icons/gauge';
|
||||||
|
import {
|
||||||
|
chartGridColor,
|
||||||
|
chartLegendColor,
|
||||||
|
chartSeriesColors,
|
||||||
|
chartTickColor
|
||||||
|
} from '$lib/chart-theme.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
histUp,
|
histUp,
|
||||||
@@ -45,85 +51,184 @@
|
|||||||
let chartNet: Chart | null = null;
|
let chartNet: Chart | null = null;
|
||||||
let chartTop: Chart | null = null;
|
let chartTop: Chart | null = null;
|
||||||
|
|
||||||
const grid = '#64748b';
|
|
||||||
|
|
||||||
function lineOpts() {
|
function lineOpts() {
|
||||||
|
const tick = chartTickColor();
|
||||||
|
const grid = chartGridColor();
|
||||||
|
const legend = chartLegendColor();
|
||||||
return {
|
return {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
animation: false as const,
|
animation: false as const,
|
||||||
plugins: { legend: { labels: { color: grid } } },
|
plugins: { legend: { labels: { color: legend } } },
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { color: grid, maxTicksLimit: 8 }, grid: { color: '#33415555' } },
|
x: { ticks: { color: tick, maxTicksLimit: 8 }, grid: { color: grid } },
|
||||||
y: { ticks: { color: grid }, grid: { color: '#33415555' } }
|
y: { ticks: { color: tick }, grid: { color: grid } }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
function rebuildAll() {
|
||||||
if (!elTraffic || !elFlow || !elMem || !elConn || !elNet || !elTop) return;
|
const colors = chartSeriesColors();
|
||||||
|
const legend = chartLegendColor();
|
||||||
|
const grid = chartGridColor();
|
||||||
|
const tick = chartTickColor();
|
||||||
const lab = histDown.map((_, i) => String(i));
|
const lab = histDown.map((_, i) => String(i));
|
||||||
chartTraffic = new Chart(elTraffic, {
|
|
||||||
type: 'line',
|
if (elTraffic) {
|
||||||
data: {
|
chartTraffic?.destroy();
|
||||||
labels: lab,
|
chartTraffic = new Chart(elTraffic, {
|
||||||
datasets: [
|
type: 'line',
|
||||||
{ label: 'Скачивание', data: [...histDown], borderColor: '#38bdf8', tension: 0.2, fill: true },
|
data: {
|
||||||
{ label: 'Загрузка', data: [...histUp], borderColor: '#f472b6', tension: 0.2, fill: true }
|
labels: lab,
|
||||||
]
|
datasets: [
|
||||||
},
|
{
|
||||||
options: lineOpts()
|
label: 'Скачивание',
|
||||||
});
|
data: [...histDown],
|
||||||
chartFlow = new Chart(elFlow, {
|
borderColor: colors[0] ?? '#38bdf8',
|
||||||
type: 'doughnut',
|
backgroundColor: `${colors[0] ?? '#38bdf8'}33`,
|
||||||
data: {
|
tension: 0.2,
|
||||||
labels: ['Скачано', 'Загружено'],
|
fill: true
|
||||||
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 } } } }
|
label: 'Загрузка',
|
||||||
});
|
data: [...histUp],
|
||||||
chartMem = new Chart(elMem, {
|
borderColor: colors[4] ?? '#f472b6',
|
||||||
type: 'line',
|
backgroundColor: `${colors[4] ?? '#f472b6'}33`,
|
||||||
data: {
|
tension: 0.2,
|
||||||
labels: histMem.map((_, i) => String(i)),
|
fill: true
|
||||||
datasets: [{ label: 'KiB', data: [...histMem], borderColor: '#a78bfa', tension: 0.2, fill: true }]
|
}
|
||||||
},
|
]
|
||||||
options: lineOpts()
|
},
|
||||||
});
|
options: lineOpts()
|
||||||
chartConn = new Chart(elConn, {
|
});
|
||||||
type: 'line',
|
}
|
||||||
data: {
|
|
||||||
labels: histConn.map((_, i) => String(i)),
|
if (elFlow) {
|
||||||
datasets: [{ label: 'Соединения', data: [...histConn], borderColor: '#34d399', tension: 0.2, fill: true }]
|
chartFlow?.destroy();
|
||||||
},
|
chartFlow = new Chart(elFlow, {
|
||||||
options: lineOpts()
|
type: 'doughnut',
|
||||||
});
|
data: {
|
||||||
chartNet = new Chart(elNet, {
|
labels: ['Скачано', 'Загружено'],
|
||||||
type: 'doughnut',
|
datasets: [
|
||||||
data: {
|
{
|
||||||
labels: ['TCP', 'UDP'],
|
data: [Math.max(totalDown, 0), Math.max(totalUp, 0)],
|
||||||
datasets: [{ data: [tcpN, udpN], backgroundColor: ['#22c55e', '#eab308'] }]
|
backgroundColor: [colors[0] ?? '#38bdf8', colors[4] ?? '#f472b6']
|
||||||
},
|
}
|
||||||
options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: grid } } } }
|
]
|
||||||
});
|
},
|
||||||
chartTop = new Chart(elTop, {
|
options: {
|
||||||
type: 'bar',
|
responsive: true,
|
||||||
data: {
|
maintainAspectRatio: false,
|
||||||
labels: topList.map((x) => x.name),
|
animation: false,
|
||||||
datasets: [{ label: 'Сессии', data: topList.map((x) => x.n), backgroundColor: '#818cf8' }]
|
plugins: { legend: { labels: { color: legend } } }
|
||||||
},
|
|
||||||
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 (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(() => {
|
$effect(() => {
|
||||||
@@ -171,7 +276,7 @@
|
|||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<KpiPanelCard
|
<KpiPanelCard
|
||||||
accent="teal"
|
accent="neutral"
|
||||||
title="Трафик"
|
title="Трафик"
|
||||||
description="Скорость загрузки и скачивания (история из /traffic)"
|
description="Скорость загрузки и скачивания (история из /traffic)"
|
||||||
headerClass="pb-2"
|
headerClass="pb-2"
|
||||||
@@ -183,7 +288,7 @@
|
|||||||
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
|
<canvas bind:this={elTraffic} class="max-h-full w-full"></canvas>
|
||||||
</KpiPanelCard>
|
</KpiPanelCard>
|
||||||
<KpiPanelCard
|
<KpiPanelCard
|
||||||
accent="info"
|
accent="neutral"
|
||||||
title="Поток (накоплено)"
|
title="Поток (накоплено)"
|
||||||
description="Соотношение накопленного down / up"
|
description="Соотношение накопленного down / up"
|
||||||
headerClass="pb-2"
|
headerClass="pb-2"
|
||||||
@@ -195,7 +300,7 @@
|
|||||||
<canvas bind:this={elFlow} class="max-h-full w-full"></canvas>
|
<canvas bind:this={elFlow} class="max-h-full w-full"></canvas>
|
||||||
</KpiPanelCard>
|
</KpiPanelCard>
|
||||||
<KpiPanelCard
|
<KpiPanelCard
|
||||||
accent="violet"
|
accent="neutral"
|
||||||
title="Память (KiB)"
|
title="Память (KiB)"
|
||||||
description="Использование по снимкам (ось — KiB)"
|
description="Использование по снимкам (ось — KiB)"
|
||||||
headerClass="pb-2"
|
headerClass="pb-2"
|
||||||
@@ -207,7 +312,7 @@
|
|||||||
<canvas bind:this={elMem} class="max-h-full w-full"></canvas>
|
<canvas bind:this={elMem} class="max-h-full w-full"></canvas>
|
||||||
</KpiPanelCard>
|
</KpiPanelCard>
|
||||||
<KpiPanelCard
|
<KpiPanelCard
|
||||||
accent="success"
|
accent="neutral"
|
||||||
title="Подключения"
|
title="Подключения"
|
||||||
description="Число активных соединений во времени"
|
description="Число активных соединений во времени"
|
||||||
headerClass="pb-2"
|
headerClass="pb-2"
|
||||||
@@ -219,7 +324,7 @@
|
|||||||
<canvas bind:this={elConn} class="max-h-full w-full"></canvas>
|
<canvas bind:this={elConn} class="max-h-full w-full"></canvas>
|
||||||
</KpiPanelCard>
|
</KpiPanelCard>
|
||||||
<KpiPanelCard
|
<KpiPanelCard
|
||||||
accent="warning"
|
accent="neutral"
|
||||||
title="Типы сети"
|
title="Типы сети"
|
||||||
description="TCP и UDP по текущему снимку"
|
description="TCP и UDP по текущему снимку"
|
||||||
headerClass="pb-2"
|
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 Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import { Toaster } from '$lib/components/ui/sonner/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();
|
let { children } = $props();
|
||||||
|
|
||||||
|
const docTitle = $derived(`${panelTitleFromPath(page.url.pathname)} · Telemt Panel`);
|
||||||
|
|
||||||
let serverAliases = $state<string[]>([]);
|
let serverAliases = $state<string[]>([]);
|
||||||
|
|
||||||
const serverMatch = $derived.by(() => {
|
const serverMatch = $derived.by(() => {
|
||||||
@@ -42,7 +46,7 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<title>Telemt Panel</title>
|
<title>{docTitle}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<ModeWatcher />
|
<ModeWatcher />
|
||||||
@@ -50,10 +54,8 @@
|
|||||||
<Sidebar.Provider>
|
<Sidebar.Provider>
|
||||||
<AppSidebar {serverAliases} {serverPath} />
|
<AppSidebar {serverAliases} {serverPath} />
|
||||||
<Sidebar.Inset>
|
<Sidebar.Inset>
|
||||||
<header
|
<header class="flex h-14 w-full shrink-0 items-center gap-2 border-b border-border px-4">
|
||||||
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4"
|
<SiteHeader />
|
||||||
>
|
|
||||||
<Sidebar.Trigger class="-ml-1" />
|
|
||||||
</header>
|
</header>
|
||||||
<div class="flex flex-1 flex-col p-4">
|
<div class="flex flex-1 flex-col p-4">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
+615
-236
@@ -9,35 +9,37 @@
|
|||||||
fetchAggUniqueIps,
|
fetchAggUniqueIps,
|
||||||
fetchStatsSummary
|
fetchStatsSummary
|
||||||
} from '$lib/api/client.js';
|
} 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 type { components } from '$lib/api/aggregate.gen.js';
|
||||||
import { formatDurationSeconds, formatMiB, flagEmoji } from '$lib/format.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 * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import IpServerPresence from '$lib/components/ip-server-presence.svelte';
|
import IpServerPresence from '$lib/components/ip-server-presence.svelte';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/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 AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
|
||||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||||
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
|
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
|
||||||
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.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 { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||||
import { cn } from '$lib/utils.js';
|
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
|
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
|
||||||
import 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 InfoIcon from '@lucide/svelte/icons/info';
|
||||||
import type { KpiAccentKey } from '$lib/kpi-accents.js';
|
import KpiTrendCard from '$lib/components/kpi-trend-card.svelte';
|
||||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
|
||||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
|
||||||
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
|
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 loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
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 {
|
function parseAliasFilter(raw: string | null): string {
|
||||||
const value = raw?.trim() ?? '';
|
const value = raw?.trim() ?? '';
|
||||||
return !value || value.includes(',') ? 'all' : value;
|
return !value || value.includes(',') ? 'all' : value;
|
||||||
@@ -85,6 +139,17 @@
|
|||||||
fetchAggFleetStatus({ aliases: aliasFilter === 'all' ? undefined : aliasFilter }),
|
fetchAggFleetStatus({ aliases: aliasFilter === 'all' ? undefined : aliasFilter }),
|
||||||
fetchAggUniqueIps({ aliases: aliasFilter === 'all' ? undefined : aliasFilter, geo: true })
|
fetchAggUniqueIps({ aliases: aliasFilter === 'all' ? undefined : aliasFilter, geo: true })
|
||||||
]);
|
]);
|
||||||
|
if (summary !== null && fleet !== null) {
|
||||||
|
kpiPrev = {
|
||||||
|
serversOk: fleet.servers_all_ok ?? summary.servers_ok,
|
||||||
|
serversTotal: fleet.servers_total ?? summary.servers_total,
|
||||||
|
connections: summary.fleet_total_connections,
|
||||||
|
bad: sumBad(nodeStats),
|
||||||
|
uniqueIp: countUniqueIps(uniqueIps),
|
||||||
|
megabytes: summary.fleet_total_megabytes,
|
||||||
|
readOnly: countReadOnlyNodes(fleet)
|
||||||
|
};
|
||||||
|
}
|
||||||
generatedAt = s.generated_at;
|
generatedAt = s.generated_at;
|
||||||
partial = !!(s.partial || f.partial || u.partial);
|
partial = !!(s.partial || f.partial || u.partial);
|
||||||
summary = s.data;
|
summary = s.data;
|
||||||
@@ -266,26 +331,15 @@
|
|||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
|
|
||||||
let nodesHealthAccent = $derived.by((): KpiAccentKey => {
|
let nodesOkChart = $derived.by(() => {
|
||||||
const t = (fleet?.servers_total ?? summary?.servers_total) ?? 0;
|
const list = fleet?.servers ?? [];
|
||||||
const ok = (fleet?.servers_all_ok ?? summary?.servers_ok) ?? 0;
|
return list.filter((s) => s.health_ok && s.system_info_ok).length;
|
||||||
if (t === 0) return 'neutral';
|
|
||||||
if (ok === t) return 'success';
|
|
||||||
if (ok === 0) return 'danger';
|
|
||||||
return 'warning';
|
|
||||||
});
|
});
|
||||||
|
let nodesDegradedChart = $derived.by(() => {
|
||||||
/** Пороги условные: 0 — норма; дальше — внимание и «сильное» внимание к метрике bad. */
|
const list = fleet?.servers ?? [];
|
||||||
let badConnectionsAccent = $derived.by((): KpiAccentKey => {
|
return list.filter((s) => !s.health_ok || !s.system_info_ok).length;
|
||||||
if (totalBad === 0) return 'success';
|
|
||||||
if (totalBad < 10_000) return 'warning';
|
|
||||||
return 'danger';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let readOnlyAccent = $derived.by((): KpiAccentKey =>
|
|
||||||
readOnlyNodes === 0 ? 'success' : 'warning'
|
|
||||||
);
|
|
||||||
|
|
||||||
function chipLabel(ip: components['schemas']['IPAssignments']): string {
|
function chipLabel(ip: components['schemas']['IPAssignments']): string {
|
||||||
const cc = ip.country_code?.trim();
|
const cc = ip.country_code?.trim();
|
||||||
const flag = flagEmoji(cc ?? undefined);
|
const flag = flagEmoji(cc ?? undefined);
|
||||||
@@ -373,6 +427,95 @@
|
|||||||
out.sort((a, b) => a.ip.localeCompare(b.ip) || a.username.localeCompare(b.username));
|
out.sort((a, b) => a.ip.localeCompare(b.ip) || a.username.localeCompare(b.username));
|
||||||
return out;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
@@ -420,229 +563,465 @@
|
|||||||
{#snippet skeleton()}
|
{#snippet skeleton()}
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
{#each Array.from({ length: 6 }) as _, i (i)}
|
{#each Array.from({ length: 6 }) as _, i (i)}
|
||||||
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
|
<Card.Root class="border border-border shadow-sm">
|
||||||
<Card.Header class="pb-2">
|
<Card.Header class="space-y-2 pb-2">
|
||||||
<div class="flex gap-3">
|
<Skeleton class="h-3 w-24" />
|
||||||
<Skeleton class="size-10 shrink-0 rounded-lg" />
|
<Skeleton class="h-8 w-20" />
|
||||||
<div class="min-w-0 flex-1 space-y-2">
|
|
||||||
<Skeleton class="h-3 w-20" />
|
|
||||||
<Skeleton class="h-8 w-14" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Skeleton class="mx-4 h-px" />
|
||||||
<Skeleton class="h-3 w-28" />
|
<Card.Content class="pt-3">
|
||||||
|
<Skeleton class="h-3 w-full" />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
{#if summary}
|
{#if summary}
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
{@const okN = fleet?.servers_all_ok ?? summary.servers_ok}
|
||||||
<KpiStatCard accent={nodesHealthAccent} label="Ноды" footer="успешный опрос">
|
{@const totN = fleet?.servers_total ?? summary.servers_total}
|
||||||
{#snippet icon()}
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
<ServerIcon class="size-5" aria-hidden="true" />
|
<KpiTrendCard
|
||||||
{/snippet}
|
label="Ноды OK / всего"
|
||||||
{fleet?.servers_all_ok ?? summary.servers_ok}/{fleet?.servers_total ?? summary.servers_total}
|
valueDisplay="{okN}/{totN}"
|
||||||
</KpiStatCard>
|
prevValue={kpiPrev?.serversOk ?? null}
|
||||||
<KpiStatCard accent="info" label="Соединения (флот)" footer="текущие по stats/users">
|
currentValue={okN}
|
||||||
{#snippet icon()}
|
insight="Успешный health + system/info. Динамика — по числу OK-нод к прошлому опросу."
|
||||||
<UsersIcon class="size-5" aria-hidden="true" />
|
/>
|
||||||
{/snippet}
|
<KpiTrendCard
|
||||||
{summary.fleet_total_connections}
|
label="Соединения (флот)"
|
||||||
</KpiStatCard>
|
valueDisplay={String(summary.fleet_total_connections)}
|
||||||
<KpiStatCard accent={badConnectionsAccent} label="Bad connections" footer="сумма по нодам (stats/summary)">
|
prevValue={kpiPrev?.connections ?? null}
|
||||||
{#snippet icon()}
|
currentValue={summary.fleet_total_connections}
|
||||||
<UnplugIcon class="size-5" aria-hidden="true" />
|
insight="Текущие соединения по агрегату stats/users."
|
||||||
{/snippet}
|
/>
|
||||||
{totalBad}
|
<KpiTrendCard
|
||||||
</KpiStatCard>
|
label="Bad connections"
|
||||||
<KpiStatCard accent="violet" label="Уникальные IP" footer="по снимку unique-ips">
|
valueDisplay={String(totalBad)}
|
||||||
{#snippet icon()}
|
prevValue={kpiPrev?.bad ?? null}
|
||||||
<GlobeIcon class="size-5" aria-hidden="true" />
|
currentValue={totalBad}
|
||||||
{/snippet}
|
invertTrend={true}
|
||||||
{uniqueIpCount}
|
insight="Сумма connections_bad по нодам (stats/summary). Меньше — лучше."
|
||||||
</KpiStatCard>
|
/>
|
||||||
<KpiStatCard accent="teal" label="Трафик флота" footer="сумма MiB">
|
<KpiTrendCard
|
||||||
{#snippet icon()}
|
label="Уникальные IP"
|
||||||
<ActivityIcon class="size-5" aria-hidden="true" />
|
valueDisplay={String(uniqueIpCount)}
|
||||||
{/snippet}
|
prevValue={kpiPrev?.uniqueIp ?? null}
|
||||||
{formatMiB(summary.fleet_total_megabytes)}
|
currentValue={uniqueIpCount}
|
||||||
</KpiStatCard>
|
insight="По снимку unique-ips на шлюзе."
|
||||||
<KpiStatCard accent={readOnlyAccent} label="Read-only API" footer="нод с read_only">
|
/>
|
||||||
{#snippet icon()}
|
<KpiTrendCard
|
||||||
<LockKeyholeIcon class="size-5" aria-hidden="true" />
|
label="Трафик флота (MiB)"
|
||||||
{/snippet}
|
valueDisplay={formatMiB(summary.fleet_total_megabytes)}
|
||||||
{readOnlyNodes}
|
prevValue={kpiPrev?.megabytes ?? null}
|
||||||
</KpiStatCard>
|
currentValue={summary.fleet_total_megabytes}
|
||||||
</div>
|
insight="Суммарный трафик по снимку summary."
|
||||||
|
/>
|
||||||
|
<KpiTrendCard
|
||||||
|
label="Read-only API (нод)"
|
||||||
|
valueDisplay={String(readOnlyNodes)}
|
||||||
|
prevValue={kpiPrev?.readOnly ?? null}
|
||||||
|
currentValue={readOnlyNodes}
|
||||||
|
invertTrend={true}
|
||||||
|
insight="Ноды с read_only в health. Меньше — лучше."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<KpiPanelCard
|
<FleetOverviewCharts
|
||||||
class="mb-6"
|
nodesOk={nodesOkChart}
|
||||||
accent="violet"
|
nodesDegraded={nodesDegradedChart}
|
||||||
title="Активные IP (агрегат)"
|
topUsers={summary.top_users.slice(0, 8)}
|
||||||
description="С GeoIP при включённой базе на шлюзе"
|
/>
|
||||||
>
|
|
||||||
{#snippet icon()}
|
<DataTableCard
|
||||||
<GlobeIcon class="size-5" aria-hidden="true" />
|
class="mb-6"
|
||||||
{/snippet}
|
title="Активные IP (агрегат)"
|
||||||
<div class="overflow-x-auto">
|
description="С GeoIP при включённой базе на шлюзе"
|
||||||
<Table.Root>
|
>
|
||||||
<Table.Header>
|
{#snippet toolbar()}
|
||||||
<Table.Row>
|
<DataTableToolbar>
|
||||||
<Table.Head class="text-xs sm:text-sm">IP со страной</Table.Head>
|
<div class="relative max-w-xs flex-1">
|
||||||
<Table.Head class="text-xs sm:text-sm">Клиент</Table.Head>
|
<SearchIcon
|
||||||
<Table.Head class="text-xs sm:text-sm">
|
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
<div class="flex items-center gap-1">
|
/>
|
||||||
Серверы
|
<Input
|
||||||
<Tooltip.Root>
|
class="h-8 ps-8"
|
||||||
<Tooltip.Trigger
|
placeholder="Поиск IP, пользователя…"
|
||||||
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
|
bind:value={ipFilter}
|
||||||
aria-label="Пояснение колонки"
|
/>
|
||||||
>
|
</div>
|
||||||
<InfoIcon class="size-3.5" />
|
<Button
|
||||||
</Tooltip.Trigger>
|
variant="outline"
|
||||||
<Tooltip.Content side="bottom" class="max-w-xs">
|
size="sm"
|
||||||
<p class="text-xs">
|
class="h-8"
|
||||||
Активные и недавние — списки на момент снимка шлюза; в ячейке пояснены
|
onclick={() => {
|
||||||
группы.
|
ipFilter = '';
|
||||||
</p>
|
ipSortKey = null;
|
||||||
</Tooltip.Content>
|
ipSortDir = null;
|
||||||
</Tooltip.Root>
|
}}
|
||||||
</div>
|
>Сброс</Button>
|
||||||
</Table.Head>
|
</DataTableToolbar>
|
||||||
</Table.Row>
|
{/snippet}
|
||||||
</Table.Header>
|
<ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
|
||||||
<Table.Body>
|
<Table.Root>
|
||||||
{#if (activeIpServerRows?.length ?? 0) === 0}
|
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell colspan={3} class="p-4 text-center text-muted-foreground">
|
<SortableTh
|
||||||
Нет активных подключений
|
active={ipSortKey === 'ip'}
|
||||||
</Table.Cell>
|
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>
|
</Table.Row>
|
||||||
{:else}
|
</Table.Header>
|
||||||
{#each activeIpServerRows as r (r.username + '|' + r.ip)}
|
<Table.Body>
|
||||||
|
{#if activeIpServerRows.length === 0}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="font-mono text-xs sm:text-sm whitespace-normal break-all">
|
<Table.Cell colspan={4} class="p-0">
|
||||||
{r.activeLabel}
|
<TableEmpty
|
||||||
</Table.Cell>
|
title="Нет активных подключений"
|
||||||
<Table.Cell class="text-xs sm:text-sm">
|
description="В снимке unique-ips нет IP с активными серверами."
|
||||||
<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>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/each}
|
{:else if filteredSortedIpRows.length === 0}
|
||||||
{/if}
|
<Table.Row>
|
||||||
</Table.Body>
|
<Table.Cell colspan={4} class="p-0">
|
||||||
</Table.Root>
|
<TableEmpty
|
||||||
</div>
|
title="Нет строк по фильтру"
|
||||||
</KpiPanelCard>
|
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">
|
<DataTableCard
|
||||||
{#snippet icon()}
|
class="mb-6"
|
||||||
<ServerIcon class="size-5" aria-hidden="true" />
|
title="По нодам"
|
||||||
{/snippet}
|
description="Health, версия, аптайм, bad connections"
|
||||||
<Table.Root>
|
>
|
||||||
<Table.Header>
|
{#snippet toolbar()}
|
||||||
<Table.Row>
|
<DataTableToolbar>
|
||||||
<Table.Head>Сервер</Table.Head>
|
<div class="relative max-w-xs flex-1">
|
||||||
<Table.Head>Статус</Table.Head>
|
<SearchIcon
|
||||||
<Table.Head class="text-right">Latency</Table.Head>
|
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
<Table.Head class="text-right">Версия</Table.Head>
|
/>
|
||||||
<Table.Head class="text-right">Аптайм</Table.Head>
|
<Input
|
||||||
<Table.Head class="text-right">Bad conn.</Table.Head>
|
class="h-8 ps-8"
|
||||||
</Table.Row>
|
placeholder="Поиск по alias…"
|
||||||
</Table.Header>
|
bind:value={nodeFilter}
|
||||||
<Table.Body>
|
/>
|
||||||
{#each fleet?.servers ?? [] as srv (srv.alias)}
|
</div>
|
||||||
<Table.Row>
|
<div class="flex flex-wrap gap-1">
|
||||||
<Table.Cell class="font-medium">
|
<Button
|
||||||
<a class="text-primary hover:underline" href="/servers/{encodeURIComponent(srv.alias ?? '')}">
|
size="sm"
|
||||||
{srv.alias}
|
variant={nodeStatusFilter === 'all' ? 'default' : 'outline'}
|
||||||
</a>
|
class="h-8"
|
||||||
</Table.Cell>
|
onclick={() => (nodeStatusFilter = 'all')}
|
||||||
<Table.Cell>
|
>Все</Button>
|
||||||
{#if srv.health_ok && srv.system_info_ok}
|
<Button
|
||||||
<Badge class="bg-emerald-600/20 text-emerald-400">OK</Badge>
|
size="sm"
|
||||||
{:else}
|
variant={nodeStatusFilter === 'ok' ? 'default' : 'outline'}
|
||||||
<Badge variant="destructive">degraded</Badge>
|
class="h-8"
|
||||||
{/if}
|
onclick={() => (nodeStatusFilter = 'ok')}
|
||||||
</Table.Cell>
|
>OK</Button>
|
||||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
<Button
|
||||||
{srv.health_latency_ms ?? '—'} ms
|
size="sm"
|
||||||
</Table.Cell>
|
variant={nodeStatusFilter === 'degraded' ? 'default' : 'outline'}
|
||||||
<Table.Cell class="text-right text-muted-foreground">
|
class="h-8"
|
||||||
{srv.system_info?.version ?? '—'}
|
onclick={() => (nodeStatusFilter = 'degraded')}
|
||||||
</Table.Cell>
|
>Degraded</Button>
|
||||||
<Table.Cell class="text-right text-muted-foreground">
|
</div>
|
||||||
{srv.system_info && srv.system_info.uptime_seconds != null
|
<Button
|
||||||
? formatDurationSeconds(srv.system_info.uptime_seconds)
|
variant="outline"
|
||||||
: '—'}
|
size="sm"
|
||||||
</Table.Cell>
|
class="h-8"
|
||||||
<Table.Cell class="text-right tabular-nums">
|
onclick={() => {
|
||||||
{#if srv.alias}
|
nodeFilter = '';
|
||||||
{@const st = nodeStats[srv.alias]}
|
nodeStatusFilter = 'all';
|
||||||
{#if !st}
|
nodeSortKey = null;
|
||||||
<span title="Нет ответа stats/summary для этой ноды">—</span>
|
nodeSortDir = null;
|
||||||
{:else if st && 'ok' in st && !st.ok && 'error' in st}
|
}}
|
||||||
<span class="text-xs text-muted-foreground" title={st.error}>—</span>
|
>Сброс</Button>
|
||||||
{:else if st && 'bad' in st && st.ok}
|
</DataTableToolbar>
|
||||||
{st.bad ?? 0}
|
{/snippet}
|
||||||
{:else}
|
<ScrollArea class="max-h-[min(70vh,480px)] w-full" orientation="both">
|
||||||
<span title="Неожиданное состояние stats">—</span>
|
<Table.Root>
|
||||||
{/if}
|
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
|
||||||
{:else}
|
<Table.Row>
|
||||||
—
|
<SortableTh
|
||||||
{/if}
|
active={nodeSortKey === 'alias'}
|
||||||
</Table.Cell>
|
dir={nodeSortDir}
|
||||||
</Table.Row>
|
onToggle={() => toggleNodeSort('alias')}
|
||||||
{/each}
|
>Сервер</SortableTh>
|
||||||
</Table.Body>
|
<Table.Head>Статус</Table.Head>
|
||||||
</Table.Root>
|
<SortableTh
|
||||||
</KpiPanelCard>
|
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="">
|
<DataTableCard
|
||||||
{#snippet icon()}
|
class="mt-6"
|
||||||
<ChartNoAxesCombinedIcon class="size-5" aria-hidden="true" />
|
title="Топ пользователей по трафику"
|
||||||
{/snippet}
|
description="До 10 записей из summary"
|
||||||
<Table.Root>
|
>
|
||||||
<Table.Header>
|
{#snippet toolbar()}
|
||||||
<Table.Row>
|
<DataTableToolbar>
|
||||||
<Table.Head>Пользователь</Table.Head>
|
<div class="relative max-w-xs flex-1">
|
||||||
<Table.Head class="text-right">Трафик</Table.Head>
|
<SearchIcon
|
||||||
<Table.Head class="text-right">Замеченные IP</Table.Head>
|
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
</Table.Row>
|
/>
|
||||||
</Table.Header>
|
<Input class="h-8 ps-8" placeholder="Поиск пользователя…" bind:value={topFilter} />
|
||||||
<Table.Body>
|
</div>
|
||||||
{#each summary.top_users.slice(0, 10) as t (t.username)}
|
<Button
|
||||||
<Table.Row>
|
variant="outline"
|
||||||
<Table.Cell class="font-medium">
|
size="sm"
|
||||||
<a href="/users/{encodeURIComponent(t.username)}" class="text-primary hover:underline">
|
class="h-8"
|
||||||
{t.username}
|
onclick={() => {
|
||||||
</a>
|
topFilter = '';
|
||||||
</Table.Cell>
|
topSortKey = null;
|
||||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">{formatMiB(t.total_megabytes)}</Table.Cell>
|
topSortDir = null;
|
||||||
<Table.Cell class="text-right text-muted-foreground tabular-nums">
|
}}
|
||||||
{uniqueIpCountByUser[t.username] ?? 0}
|
>Сброс</Button>
|
||||||
</Table.Cell>
|
</DataTableToolbar>
|
||||||
</Table.Row>
|
{/snippet}
|
||||||
{/each}
|
<ScrollArea class="max-h-[min(60vh,400px)] w-full" orientation="vertical">
|
||||||
</Table.Body>
|
<Table.Root>
|
||||||
</Table.Root>
|
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
|
||||||
</KpiPanelCard>
|
<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}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|||||||
@@ -10,12 +10,14 @@
|
|||||||
type IncidentsData
|
type IncidentsData
|
||||||
} from '$lib/api/client.js';
|
} from '$lib/api/client.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
import KpiTrendCard from '$lib/components/kpi-trend-card.svelte';
|
||||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
|
||||||
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
|
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
|
||||||
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
|
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
|
||||||
import InfoSmallIcon from '@lucide/svelte/icons/info';
|
import TableEmpty from '$lib/components/data-table/table-empty.svelte';
|
||||||
import ClipboardListIcon from '@lucide/svelte/icons/clipboard-list';
|
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 * as Table from '$lib/components/ui/table/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
@@ -54,6 +56,13 @@
|
|||||||
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let triageById = $state<Record<string, TriageState>>({});
|
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 {
|
function parseRefresh(raw: string | null): number {
|
||||||
if (raw == null || raw.trim() === '') return 30;
|
if (raw == null || raw.trim() === '') return 30;
|
||||||
const n = Number(raw);
|
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) {
|
async function load(aliasOverride?: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
err = null;
|
err = null;
|
||||||
try {
|
try {
|
||||||
const activeAlias = aliasOverride ?? aliasFilter;
|
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({
|
const env = await fetchAggIncidents({
|
||||||
aliases: activeAlias === 'all' ? undefined : activeAlias
|
aliases: activeAlias === 'all' ? undefined : activeAlias
|
||||||
});
|
});
|
||||||
|
kpiPrev = prev;
|
||||||
data = env.data ?? null;
|
data = env.data ?? null;
|
||||||
partial = !!env.partial;
|
partial = !!env.partial;
|
||||||
generatedAt = env.generated_at;
|
generatedAt = env.generated_at;
|
||||||
@@ -225,6 +249,35 @@
|
|||||||
|
|
||||||
let hasStaleData = $derived(err != null && data != null);
|
let hasStaleData = $derived(err != null && data != null);
|
||||||
let showSkeleton = $derived(loading && data === null && !err);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
@@ -277,35 +330,66 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
||||||
<KpiStatCard accent="danger" label="Critical" footer="открытые critical">
|
<KpiTrendCard
|
||||||
{#snippet icon()}
|
label="Critical"
|
||||||
<CircleAlertIcon class="size-5" aria-hidden="true" />
|
valueDisplay={String(data?.critical_total ?? 0)}
|
||||||
{/snippet}
|
prevValue={kpiPrev?.critical ?? null}
|
||||||
{data?.critical_total ?? 0}
|
currentValue={data != null ? (data.critical_total ?? 0) : null}
|
||||||
</KpiStatCard>
|
invertTrend={true}
|
||||||
<KpiStatCard accent="warning" label="Warning" footer="открытые warning">
|
insight="Открытые critical; меньше — лучше. К прошлому опросу."
|
||||||
{#snippet icon()}
|
/>
|
||||||
<TriangleAlertIcon class="size-5" aria-hidden="true" />
|
<KpiTrendCard
|
||||||
{/snippet}
|
label="Warning"
|
||||||
{data?.warning_total ?? 0}
|
valueDisplay={String(data?.warning_total ?? 0)}
|
||||||
</KpiStatCard>
|
prevValue={kpiPrev?.warning ?? null}
|
||||||
<KpiStatCard accent="info" label="Info" footer="открытые info">
|
currentValue={data != null ? (data.warning_total ?? 0) : null}
|
||||||
{#snippet icon()}
|
invertTrend={true}
|
||||||
<InfoSmallIcon class="size-5" aria-hidden="true" />
|
insight="Открытые warning; меньше — лучше."
|
||||||
{/snippet}
|
/>
|
||||||
{data?.info_total ?? 0}
|
<KpiTrendCard
|
||||||
</KpiStatCard>
|
label="Info"
|
||||||
|
valueDisplay={String(data?.info_total ?? 0)}
|
||||||
|
prevValue={kpiPrev?.info ?? null}
|
||||||
|
currentValue={data != null ? (data.info_total ?? 0) : null}
|
||||||
|
insight="Открытые info. К прошлому опросу."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KpiPanelCard accent="neutral" title="Инциденты" description="Список и triage" contentClass="p-0">
|
<DataTableCard title="Инциденты" description="Список и triage">
|
||||||
{#snippet icon()}
|
{#snippet toolbar()}
|
||||||
<ClipboardListIcon class="size-5" aria-hidden="true" />
|
<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}
|
{/snippet}
|
||||||
|
<ScrollArea class="max-h-[min(75vh,640px)] w-full" orientation="both">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Severity</Table.Head>
|
<SortableTh
|
||||||
<Table.Head>Инцидент</Table.Head>
|
active={incSortKey === 'sev'}
|
||||||
|
dir={incSortDir}
|
||||||
|
onToggle={() => toggleIncSort('sev')}
|
||||||
|
>Severity</SortableTh>
|
||||||
|
<SortableTh
|
||||||
|
active={incSortKey === 'title'}
|
||||||
|
dir={incSortDir}
|
||||||
|
onToggle={() => toggleIncSort('title')}
|
||||||
|
>Инцидент</SortableTh>
|
||||||
<Table.Head>
|
<Table.Head>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
Серверы
|
Серверы
|
||||||
@@ -329,12 +413,18 @@
|
|||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#if (data?.items.length ?? 0) === 0}
|
{#if (data?.items.length ?? 0) === 0}
|
||||||
<Table.Row>
|
<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.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{:else}
|
{:else}
|
||||||
{#each data?.items ?? [] as item (item.id)}
|
{#each filteredIncidents as item (item.id)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
|
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
|
||||||
@@ -408,6 +498,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</KpiPanelCard>
|
</ScrollArea>
|
||||||
|
</DataTableCard>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|||||||
+110
-14
@@ -9,8 +9,14 @@
|
|||||||
import { flagEmoji } from '$lib/format.js';
|
import { flagEmoji } from '$lib/format.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
import 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 MapIcon from '@lucide/svelte/icons/map';
|
||||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
@@ -49,6 +55,59 @@
|
|||||||
let mapStatus = $state<string | null>(null);
|
let mapStatus = $state<string | null>(null);
|
||||||
let renderAttempts = $state(0);
|
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 {
|
function parseAliasFilter(raw: string | null): string {
|
||||||
const value = raw?.trim() ?? '';
|
const value = raw?.trim() ?? '';
|
||||||
return !value || value.includes(',') ? 'all' : value;
|
return !value || value.includes(',') ? 'all' : value;
|
||||||
@@ -528,16 +587,46 @@
|
|||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<KpiPanelCard accent="violet" title="Уникальные IP" description="Снимок шлюза: активные и недавние списки" contentClass="p-0">
|
<DataTableCard title="Уникальные IP" description="Снимок шлюза: активные и недавние списки">
|
||||||
{#snippet icon()}
|
{#snippet toolbar()}
|
||||||
<GlobeIcon class="size-5" aria-hidden="true" />
|
<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}
|
{/snippet}
|
||||||
|
<ScrollArea class="max-h-[min(75vh,560px)] w-full" orientation="both">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Пользователь</Table.Head>
|
<SortableTh
|
||||||
<Table.Head>IP</Table.Head>
|
active={ipSortKey === 'user'}
|
||||||
<Table.Head>Geo</Table.Head>
|
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>
|
<Table.Head>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
Серверы
|
Серверы
|
||||||
@@ -559,12 +648,18 @@
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each rows as ur (ur.username)}
|
{#if displayedIpRows.length === 0}
|
||||||
{#each ur.ips ?? [] as ip (ur.username + (ip.ip ?? ''))}
|
<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.Row>
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
<a href="/users/{encodeURIComponent(ur.username ?? '')}" class="text-primary hover:underline">
|
<a href="/users/{encodeURIComponent(username)}" class="text-primary hover:underline">
|
||||||
{ur.username}
|
{username}
|
||||||
</a>
|
</a>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="font-mono text-sm">{ip.ip}</Table.Cell>
|
<Table.Cell class="font-mono text-sm">{ip.ip}</Table.Cell>
|
||||||
@@ -587,10 +682,11 @@
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</KpiPanelCard>
|
</ScrollArea>
|
||||||
|
</DataTableCard>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,16 @@
|
|||||||
type IncidentStatus
|
type IncidentStatus
|
||||||
} from '$lib/api/client.js';
|
} from '$lib/api/client.js';
|
||||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
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 ActivityIcon from '@lucide/svelte/icons/activity';
|
||||||
import LayersIcon from '@lucide/svelte/icons/layers';
|
import LayersIcon from '@lucide/svelte/icons/layers';
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||||
import BellIcon from '@lucide/svelte/icons/bell';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
@@ -56,6 +61,45 @@
|
|||||||
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let es: EventSource | 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' {
|
function severityVariant(sev: IncidentSeverity): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
if (sev === 'critical') return 'destructive';
|
if (sev === 'critical') return 'destructive';
|
||||||
if (sev === 'warning') return 'secondary';
|
if (sev === 'warning') return 'secondary';
|
||||||
@@ -334,20 +378,44 @@
|
|||||||
</KpiStatCard>
|
</KpiStatCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KpiPanelCard
|
<DataTableCard
|
||||||
accent="danger"
|
|
||||||
title="Последние incidents из snapshot"
|
title="Последние incidents из snapshot"
|
||||||
description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}"
|
description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}"
|
||||||
contentClass="p-0"
|
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet toolbar()}
|
||||||
<BellIcon class="size-5" aria-hidden="true" />
|
<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}
|
{/snippet}
|
||||||
|
<ScrollArea class="max-h-[min(70vh,520px)] w-full" orientation="both">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Severity</Table.Head>
|
<SortableTh
|
||||||
<Table.Head>Title</Table.Head>
|
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>Summary</Table.Head>
|
||||||
<Table.Head>
|
<Table.Head>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@@ -374,8 +442,14 @@
|
|||||||
Инцидентов нет
|
Инцидентов нет
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</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}
|
{:else}
|
||||||
{#each snapshot?.incidents ?? [] as item (item.id)}
|
{#each filteredLiveIncidents as item (item.id)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
|
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
|
||||||
@@ -390,4 +464,5 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</KpiPanelCard>
|
</ScrollArea>
|
||||||
|
</DataTableCard>
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import {
|
import { ApiError, fetchStatsSummary, fetchTelemt } from '$lib/api/client.js';
|
||||||
ApiError,
|
|
||||||
fetchStatsSummary,
|
|
||||||
fetchTelemt
|
|
||||||
} from '$lib/api/client.js';
|
|
||||||
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
|
import type { HealthData, StatsSummaryData, SystemInfoData } from '$lib/api/telemt-v1.js';
|
||||||
import { formatDurationSeconds, formatMiB } from '$lib/format.js';
|
import { formatDurationSeconds } from '$lib/format.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import KpiStatCard from '$lib/components/kpi-stat-card.svelte';
|
import KpiTrendCard from '$lib/components/kpi-trend-card.svelte';
|
||||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
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 CpuIcon from '@lucide/svelte/icons/cpu';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||||
@@ -30,15 +22,45 @@
|
|||||||
let summary = $state<StatsSummaryData | null>(null);
|
let summary = $state<StatsSummaryData | null>(null);
|
||||||
let sys = $state<SystemInfoData | 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) {
|
async function loadFor(a: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
err = null;
|
err = null;
|
||||||
try {
|
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([
|
const [h, s, i] = await Promise.all([
|
||||||
fetchTelemt<HealthData>(a, 'health'),
|
fetchTelemt<HealthData>(a, 'health'),
|
||||||
fetchStatsSummary(a),
|
fetchStatsSummary(a),
|
||||||
fetchTelemt<SystemInfoData>(a, 'system/info')
|
fetchTelemt<SystemInfoData>(a, 'system/info')
|
||||||
]);
|
]);
|
||||||
|
kpiPrev = prev;
|
||||||
health = h.data;
|
health = h.data;
|
||||||
summary = s.data;
|
summary = s.data;
|
||||||
sys = i.data;
|
sys = i.data;
|
||||||
@@ -64,11 +86,11 @@
|
|||||||
let hasStaleData = $derived(err != null && (health != null || summary != null));
|
let hasStaleData = $derived(err != null && (health != null || summary != null));
|
||||||
let showSkeleton = $derived(loading && !err && health == null && summary == null);
|
let showSkeleton = $derived(loading && !err && health == null && summary == null);
|
||||||
|
|
||||||
let badConnAccent = $derived.by((): 'danger' | 'success' => {
|
let uptimeSec = $derived(num(sys?.uptime_seconds));
|
||||||
const n = summary?.connections_bad_total;
|
let connTotal = $derived(num(summary?.connections_total));
|
||||||
if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) return 'success';
|
let badTotal = $derived(num(summary?.connections_bad_total));
|
||||||
return 'danger';
|
let cfgUsers = $derived(num(summary?.configured_users));
|
||||||
});
|
let handshakeT = $derived(num(summary?.handshake_timeouts_total));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between gap-4">
|
<div class="mb-6 flex items-center justify-between gap-4">
|
||||||
@@ -92,18 +114,14 @@
|
|||||||
{#snippet skeleton()}
|
{#snippet skeleton()}
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{#each Array.from({ length: 4 }) as _, i (i)}
|
{#each Array.from({ length: 4 }) as _, i (i)}
|
||||||
<Card.Root class="border-l-4 border-l-muted-foreground/30 shadow-sm">
|
<Card.Root class="border border-border shadow-sm">
|
||||||
<Card.Header class="pb-2">
|
<Card.Header class="space-y-2 pb-2">
|
||||||
<div class="flex gap-3">
|
<Skeleton class="h-3 w-24" />
|
||||||
<Skeleton class="size-10 shrink-0 rounded-lg" />
|
<Skeleton class="h-8 w-20" />
|
||||||
<div class="min-w-0 flex-1 space-y-2">
|
|
||||||
<Skeleton class="h-3 w-24" />
|
|
||||||
<Skeleton class="h-8 w-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Skeleton class="mx-4 h-px" />
|
||||||
<Skeleton class="h-3 w-32" />
|
<Card.Content class="pt-3">
|
||||||
|
<Skeleton class="h-3 w-full" />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -111,47 +129,64 @@
|
|||||||
<Skeleton class="h-40 w-full rounded-lg" />
|
<Skeleton class="h-40 w-full rounded-lg" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
{#if health?.status === 'ok'}
|
{#if health?.status === 'ok'}
|
||||||
<Alert class="mb-4 border-emerald-500/30 bg-emerald-500/10">
|
<Alert class="mb-4 border-emerald-500/30 bg-emerald-500/10">
|
||||||
<AlertTitle>Telemt отвечает</AlertTitle>
|
<AlertTitle>Telemt отвечает</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
read_only: {health.read_only ? 'да' : 'нет'}
|
read_only: {health.read_only ? 'да' : 'нет'}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||||
<KpiStatCard accent="teal" label="Аптайм" footer="uptime с ноды" valueClass="text-xl">
|
<KpiTrendCard
|
||||||
{#snippet icon()}
|
label="Аптайм"
|
||||||
<ClockIcon class="size-5" aria-hidden="true" />
|
valueDisplay={sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
|
||||||
{/snippet}
|
prevValue={kpiPrev?.uptime ?? null}
|
||||||
{sys ? formatDurationSeconds(sys.uptime_seconds) : '—'}
|
currentValue={sys ? uptimeSec : null}
|
||||||
</KpiStatCard>
|
insight="Секунды uptime с ноды; к прошлому обновлению страницы."
|
||||||
<KpiStatCard accent="info" label="Соединения (всего)" footer="stats/summary" valueClass="text-xl">
|
/>
|
||||||
{#snippet icon()}
|
<KpiTrendCard
|
||||||
<PlugIcon class="size-5" aria-hidden="true" />
|
label="Соединения (всего)"
|
||||||
{/snippet}
|
valueDisplay={summary ? String(connTotal) : '—'}
|
||||||
{summary?.connections_total ?? '—'}
|
prevValue={kpiPrev?.conn ?? null}
|
||||||
</KpiStatCard>
|
currentValue={summary ? connTotal : null}
|
||||||
<KpiStatCard accent={badConnAccent} label="Bad connections" footer="счётчик на ноде" valueClass="text-xl">
|
insight="stats/summary с ноды."
|
||||||
{#snippet icon()}
|
/>
|
||||||
<UnplugIcon class="size-5" aria-hidden="true" />
|
<KpiTrendCard
|
||||||
{/snippet}
|
label="Bad connections"
|
||||||
{summary?.connections_bad_total ?? '—'}
|
valueDisplay={summary ? String(badTotal) : '—'}
|
||||||
</KpiStatCard>
|
prevValue={kpiPrev?.bad ?? null}
|
||||||
<KpiStatCard accent="success" label="Пользователей в конфиге" footer="configured_users" valueClass="text-xl">
|
currentValue={summary ? badTotal : null}
|
||||||
{#snippet icon()}
|
invertTrend={true}
|
||||||
<UsersIcon class="size-5" aria-hidden="true" />
|
insight="Счётчик на ноде; меньше — лучше."
|
||||||
{/snippet}
|
/>
|
||||||
{summary?.configured_users ?? '—'}
|
<KpiTrendCard
|
||||||
</KpiStatCard>
|
label="Handshake timeouts"
|
||||||
</div>
|
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}
|
{#if sys}
|
||||||
<KpiPanelCard accent="neutral" title="Система" contentClass="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
<KpiPanelCard
|
||||||
{#snippet icon()}
|
accent="neutral"
|
||||||
<CpuIcon class="size-5" aria-hidden="true" />
|
title="Система"
|
||||||
{/snippet}
|
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">version</span> {sys.version}</div>
|
||||||
<div><span class="text-muted-foreground">OS / arch</span> {sys.target_os} / {sys.target_arch}</div>
|
<div><span class="text-muted-foreground">OS / arch</span> {sys.target_os} / {sys.target_arch}</div>
|
||||||
<div><span class="text-muted-foreground">build</span> {sys.build_profile}</div>
|
<div><span class="text-muted-foreground">build</span> {sys.build_profile}</div>
|
||||||
@@ -162,7 +197,7 @@
|
|||||||
<span class="text-muted-foreground">hash</span> {sys.config_hash}
|
<span class="text-muted-foreground">hash</span> {sys.config_hash}
|
||||||
</div>
|
</div>
|
||||||
<div><span class="text-muted-foreground">reload count</span> {sys.config_reload_count}</div>
|
<div><span class="text-muted-foreground">reload count</span> {sys.config_reload_count}</div>
|
||||||
</KpiPanelCard>
|
</KpiPanelCard>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|||||||
@@ -6,10 +6,15 @@
|
|||||||
import type { components } from '$lib/api/aggregate.gen.js';
|
import type { components } from '$lib/api/aggregate.gen.js';
|
||||||
import { formatMiB } from '$lib/format.js';
|
import { formatMiB } from '$lib/format.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.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 Table from '$lib/components/ui/table/index.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import KpiPanelCard from '$lib/components/kpi-panel-card.svelte';
|
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
|
||||||
import UsersIcon from '@lucide/svelte/icons/users';
|
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 { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
@@ -162,23 +167,59 @@
|
|||||||
|
|
||||||
const usersPerPage = 25;
|
const usersPerPage = 25;
|
||||||
let usersPage = $state(1);
|
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 hasStaleData = $derived(err != null && rows.length > 0);
|
||||||
let showSkeleton = $derived(loading && rows.length === 0 && !err);
|
let showSkeleton = $derived(loading && rows.length === 0 && !err);
|
||||||
let isDataEmpty = $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(() => {
|
let pagedRows = $derived.by(() => {
|
||||||
const start = (usersPage - 1) * usersPerPage;
|
const start = (usersPage - 1) * usersPerPage;
|
||||||
return rows.slice(start, start + usersPerPage);
|
return processedRows.slice(start, start + usersPerPage);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
aliasFilter;
|
aliasFilter;
|
||||||
|
userSearch;
|
||||||
usersPage = 1;
|
usersPage = 1;
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (usersPage > pageCount) usersPage = pageCount;
|
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) {
|
function copy(text: string) {
|
||||||
const value = String(text ?? '');
|
const value = String(text ?? '');
|
||||||
const done = () => toast.success('Скопировано в буфер');
|
const done = () => toast.success('Скопировано в буфер');
|
||||||
@@ -281,23 +322,60 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
<KpiPanelCard
|
<DataTableCard
|
||||||
accent="info"
|
|
||||||
title="Список пользователей"
|
title="Список пользователей"
|
||||||
description="Слияние по имени между серверами; клик по строке — карточка пользователя"
|
description="Слияние по имени между серверами; клик по строке — карточка пользователя"
|
||||||
contentClass="p-0"
|
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet toolbar()}
|
||||||
<UsersIcon class="size-5" aria-hidden="true" />
|
<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}
|
{/snippet}
|
||||||
|
<ScrollArea class="max-h-[min(70vh,560px)] w-full" orientation="both">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
|
||||||
<Table.Row>
|
<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="hidden sm:table-cell">Ссылки</Table.Head>
|
||||||
<Table.Head class="text-right">Трафик</Table.Head>
|
<SortableTh
|
||||||
<Table.Head class="text-right">Активных IP</Table.Head>
|
numeric
|
||||||
<Table.Head class="hidden sm:table-cell">Истекает</Table.Head>
|
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.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
@@ -362,14 +440,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
{#if rows.length > usersPerPage}
|
</ScrollArea>
|
||||||
|
{#if processedRows.length > usersPerPage}
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3 text-sm text-muted-foreground"
|
class="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3 text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{rows.length === 0
|
{processedRows.length === 0
|
||||||
? '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>
|
</span>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -387,6 +466,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</KpiPanelCard>
|
</DataTableCard>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataQueryState>
|
</DataQueryState>
|
||||||
|
|||||||
Reference in New Issue
Block a user