From 89b6cc5185b718fc67c32db5b72631700c0ea53e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 31 Mar 2026 18:37:03 +0700 Subject: [PATCH] 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. --- web/src/lib/breadcrumb-trail.ts | 40 + web/src/lib/chart-theme.ts | 29 + .../data-table/data-table-card.svelte | 34 + .../data-table/data-table-toolbar.svelte | 21 + .../components/data-table/sortable-th.svelte | 47 + .../components/data-table/table-empty.svelte | 26 + .../fleet/fleet-overview-charts.svelte | 163 ++++ web/src/lib/components/kpi-trend-card.svelte | 98 ++ .../mihomo/mihomo-overview-charts.svelte | 249 +++-- web/src/lib/components/site-header.svelte | 94 ++ web/src/lib/components/status-badge.svelte | 39 + .../ui/breadcrumb/breadcrumb-ellipsis.svelte | 23 + .../ui/breadcrumb/breadcrumb-item.svelte | 20 + .../ui/breadcrumb/breadcrumb-link.svelte | 31 + .../ui/breadcrumb/breadcrumb-list.svelte | 20 + .../ui/breadcrumb/breadcrumb-page.svelte | 23 + .../ui/breadcrumb/breadcrumb-separator.svelte | 27 + .../ui/breadcrumb/breadcrumb.svelte | 22 + web/src/lib/components/ui/breadcrumb/index.ts | 25 + .../components/ui/empty/empty-content.svelte | 23 + .../ui/empty/empty-description.svelte | 23 + .../components/ui/empty/empty-header.svelte | 20 + .../components/ui/empty/empty-media.svelte | 41 + .../components/ui/empty/empty-title.svelte | 15 + web/src/lib/components/ui/empty/empty.svelte | 23 + web/src/lib/components/ui/empty/index.ts | 22 + web/src/lib/kpi-trend-math.ts | 23 + web/src/lib/page-title.ts | 38 + web/src/lib/table-sort.ts | 20 + web/src/routes/+layout.svelte | 12 +- web/src/routes/+page.svelte | 851 +++++++++++++----- web/src/routes/incidents/+page.svelte | 159 +++- web/src/routes/ips/+page.svelte | 124 ++- web/src/routes/live/+page.svelte | 99 +- web/src/routes/servers/[alias]/+page.svelte | 171 ++-- web/src/routes/users/+page.svelte | 115 ++- 36 files changed, 2351 insertions(+), 459 deletions(-) create mode 100644 web/src/lib/breadcrumb-trail.ts create mode 100644 web/src/lib/chart-theme.ts create mode 100644 web/src/lib/components/data-table/data-table-card.svelte create mode 100644 web/src/lib/components/data-table/data-table-toolbar.svelte create mode 100644 web/src/lib/components/data-table/sortable-th.svelte create mode 100644 web/src/lib/components/data-table/table-empty.svelte create mode 100644 web/src/lib/components/fleet/fleet-overview-charts.svelte create mode 100644 web/src/lib/components/kpi-trend-card.svelte create mode 100644 web/src/lib/components/site-header.svelte create mode 100644 web/src/lib/components/status-badge.svelte create mode 100644 web/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte create mode 100644 web/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte create mode 100644 web/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte create mode 100644 web/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte create mode 100644 web/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte create mode 100644 web/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte create mode 100644 web/src/lib/components/ui/breadcrumb/breadcrumb.svelte create mode 100644 web/src/lib/components/ui/breadcrumb/index.ts create mode 100644 web/src/lib/components/ui/empty/empty-content.svelte create mode 100644 web/src/lib/components/ui/empty/empty-description.svelte create mode 100644 web/src/lib/components/ui/empty/empty-header.svelte create mode 100644 web/src/lib/components/ui/empty/empty-media.svelte create mode 100644 web/src/lib/components/ui/empty/empty-title.svelte create mode 100644 web/src/lib/components/ui/empty/empty.svelte create mode 100644 web/src/lib/components/ui/empty/index.ts create mode 100644 web/src/lib/kpi-trend-math.ts create mode 100644 web/src/lib/page-title.ts create mode 100644 web/src/lib/table-sort.ts diff --git a/web/src/lib/breadcrumb-trail.ts b/web/src/lib/breadcrumb-trail.ts new file mode 100644 index 0000000..68a724c --- /dev/null +++ b/web/src/lib/breadcrumb-trail.ts @@ -0,0 +1,40 @@ +export type BreadcrumbItem = { href: string; label: string; current?: boolean }; + +const SEG_LABELS: Record = { + 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; +} diff --git a/web/src/lib/chart-theme.ts b/web/src/lib/chart-theme.ts new file mode 100644 index 0000000..2b960c3 --- /dev/null +++ b/web/src/lib/chart-theme.ts @@ -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(); +} diff --git a/web/src/lib/components/data-table/data-table-card.svelte b/web/src/lib/components/data-table/data-table-card.svelte new file mode 100644 index 0000000..5808074 --- /dev/null +++ b/web/src/lib/components/data-table/data-table-card.svelte @@ -0,0 +1,34 @@ + + + +
+ {title} + {#if description} + {description} + {/if} +
+ {#if toolbar} + {@render toolbar()} + {/if} +
+ {@render children()} +
+
diff --git a/web/src/lib/components/data-table/data-table-toolbar.svelte b/web/src/lib/components/data-table/data-table-toolbar.svelte new file mode 100644 index 0000000..dd9bf36 --- /dev/null +++ b/web/src/lib/components/data-table/data-table-toolbar.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children()} +
diff --git a/web/src/lib/components/data-table/sortable-th.svelte b/web/src/lib/components/data-table/sortable-th.svelte new file mode 100644 index 0000000..621993b --- /dev/null +++ b/web/src/lib/components/data-table/sortable-th.svelte @@ -0,0 +1,47 @@ + + + + + diff --git a/web/src/lib/components/data-table/table-empty.svelte b/web/src/lib/components/data-table/table-empty.svelte new file mode 100644 index 0000000..706275e --- /dev/null +++ b/web/src/lib/components/data-table/table-empty.svelte @@ -0,0 +1,26 @@ + + +
+ + + + + + {title} + {#if description} + {description} + {/if} + + +
diff --git a/web/src/lib/components/fleet/fleet-overview-charts.svelte b/web/src/lib/components/fleet/fleet-overview-charts.svelte new file mode 100644 index 0000000..65c160a --- /dev/null +++ b/web/src/lib/components/fleet/fleet-overview-charts.svelte @@ -0,0 +1,163 @@ + + +
+ +
+
+
+ +
+
+ Статус нод + OK vs degraded по fleet-status +
+
+
+ + + +
+ +
+
+
+ +
+
+ Топ трафика + До 8 пользователей, MiB +
+
+
+ + + +
+
diff --git a/web/src/lib/components/kpi-trend-card.svelte b/web/src/lib/components/kpi-trend-card.svelte new file mode 100644 index 0000000..61c56a9 --- /dev/null +++ b/web/src/lib/components/kpi-trend-card.svelte @@ -0,0 +1,98 @@ + + + + +
+ {label} + {#if trend} + + {#if trend.raw === 'up'} + + {/if} +
+ {valueDisplay} +
+ + {insight} +
diff --git a/web/src/lib/components/mihomo/mihomo-overview-charts.svelte b/web/src/lib/components/mihomo/mihomo-overview-charts.svelte index 6a326fe..3fcd7f6 100644 --- a/web/src/lib/components/mihomo/mihomo-overview-charts.svelte +++ b/web/src/lib/components/mihomo/mihomo-overview-charts.svelte @@ -1,5 +1,5 @@ + +
+
+ + + + + {#each items as it, i (it.href + String(i))} + + {#if it.current} + {it.label} + {:else} + {it.label} + {/if} + + {#if i < items.length - 1} + + {/if} + {/each} + + +
+ + + {#snippet child({ props })} + + {/snippet} + + + Тема + { + setMode('light'); + }} + > + Светлая + {#if userPrefersMode.current === 'light'} + + {/if} + + { + setMode('dark'); + }} + > + Тёмная + {#if userPrefersMode.current === 'dark'} + + {/if} + + { + resetMode(); + }} + > + Системная + {#if userPrefersMode.current === 'system'} + + {/if} + + + +
diff --git a/web/src/lib/components/status-badge.svelte b/web/src/lib/components/status-badge.svelte new file mode 100644 index 0000000..8365a34 --- /dev/null +++ b/web/src/lib/components/status-badge.svelte @@ -0,0 +1,39 @@ + + +{#if variant === 'ok'} + + +{:else if variant === 'degraded'} + + +{:else if variant === 'pending'} + + +{:else} + + +{/if} diff --git a/web/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte b/web/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte new file mode 100644 index 0000000..9e1e22c --- /dev/null +++ b/web/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte @@ -0,0 +1,23 @@ + + + diff --git a/web/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte b/web/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte new file mode 100644 index 0000000..e9c77ea --- /dev/null +++ b/web/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte @@ -0,0 +1,20 @@ + + +
  • + {@render children?.()} +
  • diff --git a/web/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte b/web/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte new file mode 100644 index 0000000..e6bc17d --- /dev/null +++ b/web/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: attrs })} +{:else} + + {@render children?.()} + +{/if} diff --git a/web/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte b/web/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte new file mode 100644 index 0000000..4873cf0 --- /dev/null +++ b/web/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte @@ -0,0 +1,20 @@ + + +
      + {@render children?.()} +
    diff --git a/web/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte b/web/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte new file mode 100644 index 0000000..5fb6979 --- /dev/null +++ b/web/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/web/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte b/web/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte new file mode 100644 index 0000000..0f62fba --- /dev/null +++ b/web/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte @@ -0,0 +1,27 @@ + + + diff --git a/web/src/lib/components/ui/breadcrumb/breadcrumb.svelte b/web/src/lib/components/ui/breadcrumb/breadcrumb.svelte new file mode 100644 index 0000000..29ea3f5 --- /dev/null +++ b/web/src/lib/components/ui/breadcrumb/breadcrumb.svelte @@ -0,0 +1,22 @@ + + + diff --git a/web/src/lib/components/ui/breadcrumb/index.ts b/web/src/lib/components/ui/breadcrumb/index.ts new file mode 100644 index 0000000..dc914ec --- /dev/null +++ b/web/src/lib/components/ui/breadcrumb/index.ts @@ -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, +}; diff --git a/web/src/lib/components/ui/empty/empty-content.svelte b/web/src/lib/components/ui/empty/empty-content.svelte new file mode 100644 index 0000000..607b9f7 --- /dev/null +++ b/web/src/lib/components/ui/empty/empty-content.svelte @@ -0,0 +1,23 @@ + + +
    + {@render children?.()} +
    diff --git a/web/src/lib/components/ui/empty/empty-description.svelte b/web/src/lib/components/ui/empty/empty-description.svelte new file mode 100644 index 0000000..024228b --- /dev/null +++ b/web/src/lib/components/ui/empty/empty-description.svelte @@ -0,0 +1,23 @@ + + +
    a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...restProps} +> + {@render children?.()} +
    diff --git a/web/src/lib/components/ui/empty/empty-header.svelte b/web/src/lib/components/ui/empty/empty-header.svelte new file mode 100644 index 0000000..7112f66 --- /dev/null +++ b/web/src/lib/components/ui/empty/empty-header.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/web/src/lib/components/ui/empty/empty-media.svelte b/web/src/lib/components/ui/empty/empty-media.svelte new file mode 100644 index 0000000..95819a4 --- /dev/null +++ b/web/src/lib/components/ui/empty/empty-media.svelte @@ -0,0 +1,41 @@ + + + + +
    + {@render children?.()} +
    diff --git a/web/src/lib/components/ui/empty/empty-title.svelte b/web/src/lib/components/ui/empty/empty-title.svelte new file mode 100644 index 0000000..8d26fee --- /dev/null +++ b/web/src/lib/components/ui/empty/empty-title.svelte @@ -0,0 +1,15 @@ + + +
    + {@render children?.()} +
    diff --git a/web/src/lib/components/ui/empty/empty.svelte b/web/src/lib/components/ui/empty/empty.svelte new file mode 100644 index 0000000..3aef062 --- /dev/null +++ b/web/src/lib/components/ui/empty/empty.svelte @@ -0,0 +1,23 @@ + + +
    + {@render children?.()} +
    diff --git a/web/src/lib/components/ui/empty/index.ts b/web/src/lib/components/ui/empty/index.ts new file mode 100644 index 0000000..ae4c106 --- /dev/null +++ b/web/src/lib/components/ui/empty/index.ts @@ -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, +}; diff --git a/web/src/lib/kpi-trend-math.ts b/web/src/lib/kpi-trend-math.ts new file mode 100644 index 0000000..f68b63c --- /dev/null +++ b/web/src/lib/kpi-trend-math.ts @@ -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)}%`; +} diff --git a/web/src/lib/page-title.ts b/web/src/lib/page-title.ts new file mode 100644 index 0000000..8932b83 --- /dev/null +++ b/web/src/lib/page-title.ts @@ -0,0 +1,38 @@ +const SEG_LABELS: Record = { + 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(' · '); +} diff --git a/web/src/lib/table-sort.ts b/web/src/lib/table-sort.ts new file mode 100644 index 0000000..4c69af8 --- /dev/null +++ b/web/src/lib/table-sort.ts @@ -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; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index e532c58..6089429 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -9,9 +9,13 @@ import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import { Toaster } from '$lib/components/ui/sonner/index.js'; + import SiteHeader from '$lib/components/site-header.svelte'; + import { panelTitleFromPath } from '$lib/page-title.js'; let { children } = $props(); + const docTitle = $derived(`${panelTitleFromPath(page.url.pathname)} · Telemt Panel`); + let serverAliases = $state([]); const serverMatch = $derived.by(() => { @@ -42,7 +46,7 @@ - Telemt Panel + {docTitle} @@ -50,10 +54,8 @@ -
    - +
    +
    {@render children()} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 00e9ec9..8adcb96 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -9,35 +9,37 @@ fetchAggUniqueIps, fetchStatsSummary } from '$lib/api/client.js'; - import type { SummaryData } from '$lib/api/summary-types.js'; + import type { SummaryData, TopUser } from '$lib/api/summary-types.js'; import type { components } from '$lib/api/aggregate.gen.js'; import { formatDurationSeconds, formatMiB, flagEmoji } from '$lib/format.js'; - import { Badge } from '$lib/components/ui/badge/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import IpServerPresence from '$lib/components/ip-server-presence.svelte'; import * as Card from '$lib/components/ui/card/index.js'; import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js'; import * as Table from '$lib/components/ui/table/index.js'; import { Button } from '$lib/components/ui/button/index.js'; + import { Input } from '$lib/components/ui/input/index.js'; + import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import AliasFilterSelect from '$lib/components/alias-filter-select.svelte'; import DataQueryState from '$lib/components/fleet/data-query-state.svelte'; import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte'; import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte'; + import FleetOverviewCharts from '$lib/components/fleet/fleet-overview-charts.svelte'; import { Skeleton } from '$lib/components/ui/skeleton/index.js'; - import { cn } from '$lib/utils.js'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert'; - import ServerIcon from '@lucide/svelte/icons/server'; - import UsersIcon from '@lucide/svelte/icons/users'; - import UnplugIcon from '@lucide/svelte/icons/unplug'; - import GlobeIcon from '@lucide/svelte/icons/globe'; - import ActivityIcon from '@lucide/svelte/icons/activity'; - import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole'; import InfoIcon from '@lucide/svelte/icons/info'; - import type { KpiAccentKey } from '$lib/kpi-accents.js'; - import KpiStatCard from '$lib/components/kpi-stat-card.svelte'; - import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; - import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined'; + import KpiTrendCard from '$lib/components/kpi-trend-card.svelte'; + import DataTableCard from '$lib/components/data-table/data-table-card.svelte'; + import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte'; + import SortableTh from '$lib/components/data-table/sortable-th.svelte'; + import TableEmpty from '$lib/components/data-table/table-empty.svelte'; + import StatusBadge from '$lib/components/status-badge.svelte'; + import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; + import MoreVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; + import SearchIcon from '@lucide/svelte/icons/search'; + import { Badge } from '$lib/components/ui/badge/index.js'; + import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js'; let loading = $state(true); let err = $state(null); @@ -61,6 +63,58 @@ > >({}); + type FleetKpiPrev = { + serversOk: number; + serversTotal: number; + connections: number; + bad: number; + uniqueIp: number; + megabytes: number; + readOnly: number; + }; + + let kpiPrev = $state(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(); + 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(null); + + let nodeFilter = $state(''); + let nodeStatusFilter = $state<'all' | 'ok' | 'degraded'>('all'); + let nodeSortKey = $state<'alias' | 'latency' | 'bad' | null>(null); + let nodeSortDir = $state(null); + + let topFilter = $state(''); + let topSortKey = $state<'user' | 'traffic' | 'ips' | null>(null); + let topSortDir = $state(null); + function parseAliasFilter(raw: string | null): string { const value = raw?.trim() ?? ''; return !value || value.includes(',') ? 'all' : value; @@ -85,6 +139,17 @@ fetchAggFleetStatus({ aliases: aliasFilter === 'all' ? undefined : aliasFilter }), fetchAggUniqueIps({ aliases: aliasFilter === 'all' ? undefined : aliasFilter, geo: true }) ]); + if (summary !== null && fleet !== null) { + kpiPrev = { + serversOk: fleet.servers_all_ok ?? summary.servers_ok, + serversTotal: fleet.servers_total ?? summary.servers_total, + connections: summary.fleet_total_connections, + bad: sumBad(nodeStats), + uniqueIp: countUniqueIps(uniqueIps), + megabytes: summary.fleet_total_megabytes, + readOnly: countReadOnlyNodes(fleet) + }; + } generatedAt = s.generated_at; partial = !!(s.partial || f.partial || u.partial); summary = s.data; @@ -266,26 +331,15 @@ return n; }); - let nodesHealthAccent = $derived.by((): KpiAccentKey => { - const t = (fleet?.servers_total ?? summary?.servers_total) ?? 0; - const ok = (fleet?.servers_all_ok ?? summary?.servers_ok) ?? 0; - if (t === 0) return 'neutral'; - if (ok === t) return 'success'; - if (ok === 0) return 'danger'; - return 'warning'; + let nodesOkChart = $derived.by(() => { + const list = fleet?.servers ?? []; + return list.filter((s) => s.health_ok && s.system_info_ok).length; }); - - /** Пороги условные: 0 — норма; дальше — внимание и «сильное» внимание к метрике bad. */ - let badConnectionsAccent = $derived.by((): KpiAccentKey => { - if (totalBad === 0) return 'success'; - if (totalBad < 10_000) return 'warning'; - return 'danger'; + let nodesDegradedChart = $derived.by(() => { + const list = fleet?.servers ?? []; + return list.filter((s) => !s.health_ok || !s.system_info_ok).length; }); - let readOnlyAccent = $derived.by((): KpiAccentKey => - readOnlyNodes === 0 ? 'success' : 'warning' - ); - function chipLabel(ip: components['schemas']['IPAssignments']): string { const cc = ip.country_code?.trim(); const flag = flagEmoji(cc ?? undefined); @@ -373,6 +427,95 @@ out.sort((a, b) => a.ip.localeCompare(b.ip) || a.username.localeCompare(b.username)); return out; }); + + let filteredSortedIpRows = $derived.by(() => { + let rows = activeIpServerRows; + const q = ipFilter.trim().toLowerCase(); + if (q) { + rows = rows.filter( + (r) => + r.username.toLowerCase().includes(q) || + r.ip.toLowerCase().includes(q) || + r.activeLabel.toLowerCase().includes(q) + ); + } + const k = ipSortKey; + const d = ipSortDir; + if (!k || !d) return rows; + const copy = [...rows]; + if (k === 'ip') copy.sort((a, b) => compareStrings(a.ip, b.ip, d)); + else copy.sort((a, b) => compareStrings(a.username, b.username, d)); + return copy; + }); + + type NodeRow = components['schemas']['FleetServerStatus']; + + let filteredSortedNodeRows = $derived.by((): NodeRow[] => { + let rows = [...(fleet?.servers ?? [])]; + const q = nodeFilter.trim().toLowerCase(); + if (q) rows = rows.filter((s) => (s.alias ?? '').toLowerCase().includes(q)); + if (nodeStatusFilter === 'ok') rows = rows.filter((s) => s.health_ok && s.system_info_ok); + if (nodeStatusFilter === 'degraded') + rows = rows.filter((s) => !s.health_ok || !s.system_info_ok); + const k = nodeSortKey; + const d = nodeSortDir; + if (k && d) { + rows.sort((a, b) => { + if (k === 'alias') return compareStrings(a.alias ?? '', b.alias ?? '', d); + if (k === 'latency') { + const la = a.health_latency_ms ?? -1; + const lb = b.health_latency_ms ?? -1; + return compareNum(la, lb, d); + } + if (k === 'bad') { + const ba = + a.alias && nodeStats[a.alias] && 'bad' in nodeStats[a.alias]! + ? (nodeStats[a.alias] as { bad?: number }).bad ?? -1 + : -1; + const bb = + b.alias && nodeStats[b.alias] && 'bad' in nodeStats[b.alias]! + ? (nodeStats[b.alias] as { bad?: number }).bad ?? -1 + : -1; + return compareNum(ba, bb, d); + } + return 0; + }); + } + return rows; + }); + + let filteredSortedTopUsers = $derived.by((): TopUser[] => { + if (!summary) return []; + let rows = [...summary.top_users.slice(0, 10)]; + const q = topFilter.trim().toLowerCase(); + if (q) rows = rows.filter((t) => t.username.toLowerCase().includes(q)); + const k = topSortKey; + const d = topSortDir; + if (k && d) { + rows.sort((a, b) => { + if (k === 'user') return compareStrings(a.username, b.username, d); + if (k === 'traffic') return compareNum(a.total_megabytes, b.total_megabytes, d); + return compareNum(uniqueIpCountByUser[a.username] ?? 0, uniqueIpCountByUser[b.username] ?? 0, d); + }); + } + return rows; + }); + + function toggleIpSort(key: 'ip' | 'user') { + const next = nextSortDir(ipSortDir, key, ipSortKey); + ipSortKey = next === null ? null : key; + ipSortDir = next; + } + function toggleNodeSort(key: 'alias' | 'latency' | 'bad') { + const next = nextSortDir(nodeSortDir, key, nodeSortKey); + nodeSortKey = next === null ? null : key; + nodeSortDir = next; + } + function toggleTopSort(key: 'user' | 'traffic' | 'ips') { + const next = nextSortDir(topSortDir, key, topSortKey); + topSortKey = next === null ? null : key; + topSortDir = next; + }
    @@ -420,229 +563,465 @@ {#snippet skeleton()}
    {#each Array.from({ length: 6 }) as _, i (i)} - - -
    - -
    - - -
    -
    + + + + - - + + + {/each}
    - + + {/snippet} {#snippet children()} {#if summary} -
    - - {#snippet icon()} - - - {#snippet icon()} - - - {#snippet icon()} - - - {#snippet icon()} - - - {#snippet icon()} - - - {#snippet icon()} - -
    + {@const okN = fleet?.servers_all_ok ?? summary.servers_ok} + {@const totN = fleet?.servers_total ?? summary.servers_total} +
    + + + + + + +
    - - {#snippet icon()} - + {:else if filteredSortedIpRows.length === 0} + + + + + + {:else} + {#each filteredSortedIpRows as r (r.username + '|' + r.ip)} + + + {r.activeLabel} + + + + {r.username} + + + + + + + + + {#snippet child({ props })} + + {/snippet} + + + + window.open( + `/users/${encodeURIComponent(r.username)}`, + '_blank' + )} + >Открыть пользователя + + + + + {/each} + {/if} + + + + - - {#snippet icon()} - + + {#snippet toolbar()} + +
    + + +
    +
    + + + +
    + +
    + {/snippet} + + + + + toggleNodeSort('alias')} + >Сервер + Статус + toggleNodeSort('latency')} + class="text-right" + >Latency + Версия + Аптайм + toggleNodeSort('bad')} + class="text-right" + >Bad conn. + + + + + {#if filteredSortedNodeRows.length === 0} + + + + + + {:else} + {#each filteredSortedNodeRows as srv (srv.alias)} + + + + {srv.alias} + + + + {#if srv.health_ok && srv.system_info_ok} + + {:else} + + {/if} + + + {srv.health_latency_ms ?? '—'} ms + + + {srv.system_info?.version ?? '—'} + + + {srv.system_info && srv.system_info.uptime_seconds != null + ? formatDurationSeconds(srv.system_info.uptime_seconds) + : '—'} + + + {#if srv.alias} + {@const st = nodeStats[srv.alias]} + {#if !st} + + {:else if st && 'ok' in st && !st.ok && 'error' in st} + + {:else if st && 'bad' in st && st.ok} + {st.bad ?? 0} + {:else} + + {/if} + {:else} + — + {/if} + + + + + {#snippet child({ props })} + + {/snippet} + + + + window.open( + `/servers/${encodeURIComponent(srv.alias ?? '')}`, + '_blank' + )} + >Открыть ноду + + + + + {/each} + {/if} + + + +
    - - {#snippet icon()} - + + {#snippet toolbar()} + +
    + + +
    + +
    + {/snippet} + + + + + toggleTopSort('user')} + >Пользователь + toggleTopSort('traffic')} + class="text-right" + >Трафик + toggleTopSort('ips')} + class="text-right" + >IP + + + + + {#if filteredSortedTopUsers.length === 0} + + + + + + {:else} + {#each filteredSortedTopUsers as t (t.username)} + + + + {t.username} + + + + {formatMiB(t.total_megabytes)} + + + {uniqueIpCountByUser[t.username] ?? 0} + + + + + {#snippet child({ props })} + + {/snippet} + + + + window.open(`/users/${encodeURIComponent(t.username)}`, '_blank')} + >Открыть + + + + + {/each} + {/if} + + + +
    {/if} {/snippet} diff --git a/web/src/routes/incidents/+page.svelte b/web/src/routes/incidents/+page.svelte index f26dbb5..5e69564 100644 --- a/web/src/routes/incidents/+page.svelte +++ b/web/src/routes/incidents/+page.svelte @@ -10,12 +10,14 @@ type IncidentsData } from '$lib/api/client.js'; import * as Card from '$lib/components/ui/card/index.js'; - import KpiStatCard from '$lib/components/kpi-stat-card.svelte'; - import KpiPanelCard from '$lib/components/kpi-panel-card.svelte'; - import CircleAlertIcon from '@lucide/svelte/icons/circle-alert'; - import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'; - import InfoSmallIcon from '@lucide/svelte/icons/info'; - import ClipboardListIcon from '@lucide/svelte/icons/clipboard-list'; + import KpiTrendCard from '$lib/components/kpi-trend-card.svelte'; + import DataTableCard from '$lib/components/data-table/data-table-card.svelte'; + import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte'; + import SortableTh from '$lib/components/data-table/sortable-th.svelte'; + import TableEmpty from '$lib/components/data-table/table-empty.svelte'; + import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; + import SearchIcon from '@lucide/svelte/icons/search'; + import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js'; import * as Table from '$lib/components/ui/table/index.js'; import { Badge } from '$lib/components/ui/badge/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js'; @@ -54,6 +56,13 @@ let staleTimer: ReturnType | null = null; let triageById = $state>({}); + type IncKpiPrev = { critical: number; warning: number; info: number }; + let kpiPrev = $state(null); + + let incSearch = $state(''); + let incSortKey = $state<'sev' | 'title' | null>(null); + let incSortDir = $state(null); + function parseRefresh(raw: string | null): number { if (raw == null || raw.trim() === '') return 30; const n = Number(raw); @@ -127,14 +136,29 @@ } } + function sevRank(s: IncidentItem['severity']): number { + if (s === 'critical') return 3; + if (s === 'warning') return 2; + return 1; + } + async function load(aliasOverride?: string) { loading = true; err = null; try { const activeAlias = aliasOverride ?? aliasFilter; + const prev = + data != null + ? { + critical: data.critical_total ?? 0, + warning: data.warning_total ?? 0, + info: data.info_total ?? 0 + } + : null; const env = await fetchAggIncidents({ aliases: activeAlias === 'all' ? undefined : activeAlias }); + kpiPrev = prev; data = env.data ?? null; partial = !!env.partial; generatedAt = env.generated_at; @@ -225,6 +249,35 @@ let hasStaleData = $derived(err != null && data != null); let showSkeleton = $derived(loading && data === null && !err); + + let filteredIncidents = $derived.by((): IncidentItem[] => { + const items = data?.items ?? []; + const q = incSearch.trim().toLowerCase(); + let out = items; + if (q) { + out = items.filter( + (it) => + (it.title ?? '').toLowerCase().includes(q) || + (it.summary ?? '').toLowerCase().includes(q) || + (it.severity ?? '').toLowerCase().includes(q) + ); + } + const k = incSortKey; + const d = incSortDir; + if (k && d) { + out = [...out].sort((a, b) => { + if (k === 'sev') return compareNum(sevRank(a.severity), sevRank(b.severity), d); + return compareStrings(a.title ?? '', b.title ?? '', d); + }); + } + return out; + }); + + function toggleIncSort(key: 'sev' | 'title') { + const next = nextSortDir(incSortDir, key, incSortKey); + incSortKey = next === null ? null : key; + incSortDir = next; + }
    @@ -277,35 +330,66 @@ {/snippet} {#snippet children()}
    - - {#snippet icon()} - - - {#snippet icon()} - - - {#snippet icon()} - + + +
    - - {#snippet icon()} -