From d17a403f12d54b007e62974429be577ad9554441 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 4 Aug 2026 21:39:45 +0700 Subject: [PATCH] Add Errors route and integrate into navigation and dashboard --- .../components/errors/error-detail-sheet.tsx | 171 +++++++ .../src/components/errors/errors-columns.tsx | 315 ++++++++++++ .../components/errors/errors-grid-view.tsx | 452 ++++++++++++++++++ apps/web/src/components/layout/app-shell.tsx | 2 + apps/web/src/lib/telemt-errors.ts | 267 +++++++++++ apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/dashboard.tsx | 41 +- apps/web/src/routes/errors.tsx | 18 + 8 files changed, 1263 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/errors/error-detail-sheet.tsx create mode 100644 apps/web/src/components/errors/errors-columns.tsx create mode 100644 apps/web/src/components/errors/errors-grid-view.tsx create mode 100644 apps/web/src/lib/telemt-errors.ts create mode 100644 apps/web/src/routes/errors.tsx diff --git a/apps/web/src/components/errors/error-detail-sheet.tsx b/apps/web/src/components/errors/error-detail-sheet.tsx new file mode 100644 index 0000000..357fd40 --- /dev/null +++ b/apps/web/src/components/errors/error-detail-sheet.tsx @@ -0,0 +1,171 @@ +'use no memo' + +import { Badge } from '@/components/reui/badge' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@telemt/ui/components/sheet' +import { ScrollArea } from '@telemt/ui/components/scroll-area' +import { Separator } from '@telemt/ui/components/separator' +import { formatEpoch, formatNumber } from '@/lib/telemt' +import type { TelemtErrorRow } from '@/lib/telemt-errors' + +interface ErrorDetailSheetProps { + row: TelemtErrorRow | null + open: boolean + onOpenChange: (open: boolean) => void +} + +/** Error detail — IPs, API counters, runtime event logs. */ +export function ErrorDetailSheet({ row, open, onOpenChange }: ErrorDetailSheetProps) { + return ( + + + + {row?.labelRu ?? 'Ошибка'} + + {row ? ( + + {row.labelEn} · {row.code} + + ) : ( + 'Детали класса отказа' + )} + + + + + {!row ? null : ( +
+
+

Сводка API

+
+
+
Тип
+
+ + {row.kind === 'connection' ? 'Соединение' : 'Handshake'} + +
+
+
+
Счётчик
+
+ {formatNumber(row.total)} ({row.sharePct}%) +
+
+
+
Последний раз
+
+ {row.lastSeenEpoch != null + ? formatEpoch(row.lastSeenEpoch) + : '—'} +
+
+
+
Код
+
{row.code}
+
+
+ {row.stageHint ? ( +

+ stages: {row.stageHint} +

+ ) : null} +
+ + + +
+

+ IP ({formatNumber(row.ipDetails.length)}) +

+ {row.ipDetails.length === 0 ? ( +

+ В events/TLS fingerprint пока нет IP, связанных с этим классом. + Счётчик приходит из stats/summary без per-IP разбивки. +

+ ) : ( +
    + {row.ipDetails.map((d) => ( +
  • +
    + {d.ip} + + {d.source === 'tls' ? 'TLS probe' : 'event'} + + {d.badOrProbe != null ? ( + + bad {formatNumber(d.badOrProbe)} + + ) : null} +
    +
    + {d.lastSeenEpoch != null ? ( + last {formatEpoch(d.lastSeenEpoch)} + ) : null} + {d.ja4 ? ( + JA4 {d.ja4} + ) : null} + {d.ja3 ? ( + JA3 {d.ja3} + ) : null} +
    +
  • + ))} +
+ )} +
+ + + +
+

+ Логи / events ({formatNumber(row.logs.length)}) +

+ {row.logs.length === 0 ? ( +

+ В `/v1/runtime/events/recent` нет записей с этим классом в + event_type/context. +

+ ) : ( +
    + {row.logs.map((log, i) => ( +
  • +
    + {log.eventType} + + {log.tsEpoch != null ? formatEpoch(log.tsEpoch) : '—'} + +
    +

    + {log.context || '—'} +

    +
  • + ))} +
+ )} +
+
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/components/errors/errors-columns.tsx b/apps/web/src/components/errors/errors-columns.tsx new file mode 100644 index 0000000..d2ca76b --- /dev/null +++ b/apps/web/src/components/errors/errors-columns.tsx @@ -0,0 +1,315 @@ +'use no memo' + +import { useMemo } from 'react' +import { type ColumnDef, type Row } from '@tanstack/react-table' +import { EyeIcon, MoreHorizontalIcon } from 'lucide-react' + +import { Badge } from '@/components/reui/badge' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { Button } from '@telemt/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@telemt/ui/components/dropdown-menu' +import { Skeleton } from '@telemt/ui/components/skeleton' +import { formatEpoch, formatNumber } from '@/lib/telemt' +import type { TelemtErrorRow } from '@/lib/telemt-errors' + +function KindBadge({ kind }: { kind: TelemtErrorRow['kind'] }) { + return ( + + {kind === 'connection' ? 'Соединение' : 'Handshake'} + + ) +} + +function IpBadges({ ips }: { ips: string[] }) { + if (ips.length === 0) { + return нет IP в логах + } + const shown = ips.slice(0, 3) + const rest = ips.length - shown.length + return ( +
+ {shown.map((ip) => ( + + {ip} + + ))} + {rest > 0 ? ( + + +{rest} + + ) : null} +
+ ) +} + +export function createErrorsColumns(opts: { + onOpen: (row: TelemtErrorRow) => void +}): ColumnDef[] { + return [ + { + id: 'labelRu', + accessorKey: 'labelRu', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + minSize: 260, + enableSorting: true, + enableHiding: false, + enableResizing: true, + meta: { + autoSize: true, + skeleton: ( +
+ + +
+ ), + }, + }, + { + id: 'kind', + accessorKey: 'kind', + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 120, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { skeleton: }, + }, + { + id: 'total', + accessorKey: 'total', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + {formatNumber(row.original.total)} + + + {row.original.sharePct}% в группе + +
+ ), + size: 110, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { skeleton: }, + }, + { + id: 'ips', + accessorFn: (row) => row.ips.join(' '), + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 240, + enableSorting: false, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: ( +
+ + +
+ ), + }, + }, + { + id: 'ipCount', + accessorFn: (row) => row.ips.length, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {formatNumber(row.original.ips.length)} + ), + size: 80, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { skeleton: }, + }, + { + id: 'lastSeenEpoch', + accessorKey: 'lastSeenEpoch', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.lastSeenEpoch != null + ? formatEpoch(row.original.lastSeenEpoch) + : '—'} + + ), + size: 150, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { skeleton: }, + }, + { + id: 'logs', + accessorFn: (row) => row.logs.length, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const first = row.original.logs[0] + return ( +
+ + {formatNumber(row.original.logs.length)} событий + + {first ? ( + + {first.eventType}: {first.context || '—'} + + ) : ( + нет в ring buffer + )} +
+ ) + }, + size: 220, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: ( +
+ + +
+ ), + }, + }, + { + id: 'stageHint', + accessorKey: 'stageHint', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.stageHint ?? '—'} + + ), + size: 160, + enableSorting: false, + enableHiding: true, + enableResizing: true, + meta: { skeleton: }, + }, + { + id: 'actions', + header: '', + cell: ({ row }) => , + size: 52, + enableSorting: false, + enableHiding: false, + enableResizing: false, + meta: { skeleton: }, + }, + ] +} + +function RowActions({ + row, + onOpen, +}: { + row: Row + onOpen: (row: TelemtErrorRow) => void +}) { + return ( + + + } + > + + + + onOpen(row.original)}> + + Открыть + + + + ) +} + +export function useErrorsFilterFields() { + return useMemo( + () => [ + { + key: 'labelRu', + label: 'Ошибка', + type: 'text' as const, + className: 'w-48', + placeholder: 'Поиск…', + }, + { + key: 'code', + label: 'Код', + type: 'text' as const, + className: 'w-44', + placeholder: 'tls_…', + }, + { + key: 'ips', + label: 'IP', + type: 'text' as const, + className: 'w-40', + placeholder: '1.2.3.4…', + }, + { + key: 'kind', + label: 'Тип', + type: 'select' as const, + searchable: false, + className: 'w-[150px]', + options: [ + { value: 'connection', label: 'Соединение' }, + { value: 'handshake', label: 'Handshake' }, + ], + }, + ], + [], + ) +} diff --git a/apps/web/src/components/errors/errors-grid-view.tsx b/apps/web/src/components/errors/errors-grid-view.tsx new file mode 100644 index 0000000..824057b --- /dev/null +++ b/apps/web/src/components/errors/errors-grid-view.tsx @@ -0,0 +1,452 @@ +'use no memo' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type PaginationState, + type SortingState, +} from '@tanstack/react-table' +import { + FilterIcon, + FilterXIcon, + ShieldAlertIcon, +} from 'lucide-react' + +import { DataGrid } from '@/components/reui/data-grid/data-grid' +import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' +import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' +import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { + createFilter, + Filters, + type Filter, +} from '@/components/reui/filters' +import { + Frame, + FrameDescription, + FrameFooter, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { + applyFiltersToData, + getActiveFilters, +} from '@/components/reui-kit/filter-utils' +import { ErrorDetailSheet } from '@/components/errors/error-detail-sheet' +import { + createErrorsColumns, + useErrorsFilterFields, +} from '@/components/errors/errors-columns' +import { Badge } from '@/components/reui/badge' +import { Button } from '@telemt/ui/components/button' +import { Separator } from '@telemt/ui/components/separator' +import { TooltipProvider } from '@telemt/ui/components/tooltip' +import { api } from '@/lib/api-client' +import { + formatEpoch, + unwrapData, + type SummaryData, +} from '@/lib/telemt' +import { + buildErrorLogRows, + buildTelemtErrorRows, + extractIpsFromText, + parseEventsPayload, + parseTlsByIp, + type TelemtErrorLogEvent, + type TelemtErrorRow, +} from '@/lib/telemt-errors' + +function createDefaultFilters(): Filter[] { + return [createFilter('labelRu', 'contains', [''])] +} + +function createLogColumns(): ColumnDef[] { + return [ + { + id: 'tsEpoch', + accessorKey: 'tsEpoch', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.tsEpoch != null ? formatEpoch(row.original.tsEpoch) : '—'} + + ), + size: 150, + }, + { + id: 'eventType', + accessorKey: 'eventType', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.eventType} + + ), + size: 180, + }, + { + id: 'ips', + accessorFn: (row) => extractIpsFromText(row.context).join(' '), + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const ips = extractIpsFromText(row.original.context) + if (ips.length === 0) { + return + } + return ( +
+ {ips.slice(0, 3).map((ip) => ( + + {ip} + + ))} +
+ ) + }, + size: 180, + }, + { + id: 'context', + accessorKey: 'context', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.context || '—'} + + ), + minSize: 280, + }, + ] +} + +/** + * Errors section — data-grid-base-2 DNA. + * Preview: https://reui.io/preview/base/data-grid-base-2 + * Docs: https://reui.io/blocks + */ +export function ErrorsGridView() { + const [selected, setSelected] = useState(null) + const [sheetOpen, setSheetOpen] = useState(false) + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + const [logPagination, setLogPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + const [sorting, setSorting] = useState([ + { id: 'total', desc: true }, + ]) + const [logSorting, setLogSorting] = useState([ + { id: 'tsEpoch', desc: true }, + ]) + const [filters, setFilters] = useState(createDefaultFilters) + const [logFilters, setLogFilters] = useState(() => [ + createFilter('eventType', 'contains', ['']), + ]) + + const summary = useQuery({ + queryKey: ['telemt', 'summary'], + queryFn: () => api('/api/telemt/stats/summary'), + refetchInterval: 10_000, + }) + const events = useQuery({ + queryKey: ['telemt', 'events', 'errors'], + queryFn: () => + api('/api/telemt/runtime/events/recent?limit=500').catch(() => null), + refetchInterval: 10_000, + }) + const tls = useQuery({ + queryKey: ['telemt', 'tls-fingerprints', 'errors'], + queryFn: () => + api('/api/telemt/runtime/tls-fingerprints?limit=500').catch(() => null), + refetchInterval: 15_000, + }) + + const data = unwrapData(summary.data) ?? {} + const eventList = useMemo(() => parseEventsPayload(events.data), [events.data]) + const tlsByIp = useMemo(() => parseTlsByIp(tls.data), [tls.data]) + + const rows = useMemo( + () => + buildTelemtErrorRows({ + connectionClasses: data.connections_bad_by_class, + handshakeClasses: data.handshake_failures_by_class, + handshakeStages: data.handshake_failures_by_stage, + events: eventList, + tlsByIp, + }), + [ + data.connections_bad_by_class, + data.handshake_failures_by_class, + data.handshake_failures_by_stage, + eventList, + tlsByIp, + ], + ) + + const logRows = useMemo(() => buildErrorLogRows(eventList), [eventList]) + + const filteredData = useMemo( + () => + applyFiltersToData(rows, filters, (item, field) => { + if (field === 'ips') return item.ips.join(' ') + return (item as unknown as Record)[field] + }), + [rows, filters], + ) + + const filteredLogs = useMemo( + () => + applyFiltersToData(logRows, logFilters, (item, field) => { + if (field === 'ips') return extractIpsFromText(item.context).join(' ') + return (item as unknown as Record)[field] + }), + [logRows, logFilters], + ) + + useEffect(() => { + setPagination((p) => ({ ...p, pageIndex: 0 })) + }, [filters]) + + useEffect(() => { + setLogPagination((p) => ({ ...p, pageIndex: 0 })) + }, [logFilters]) + + const handleOpen = useCallback((row: TelemtErrorRow) => { + setSelected(row) + setSheetOpen(true) + }, []) + + const columns = useMemo( + () => createErrorsColumns({ onOpen: handleOpen }), + [handleOpen], + ) + const logColumns = useMemo(() => createLogColumns(), []) + const filterFields = useErrorsFilterFields() + const logFilterFields = useMemo( + () => [ + { + key: 'eventType', + label: 'event_type', + type: 'text' as const, + className: 'w-44', + placeholder: 'тип…', + }, + { + key: 'context', + label: 'context', + type: 'text' as const, + className: 'w-48', + placeholder: 'текст лога…', + }, + { + key: 'ips', + label: 'IP', + type: 'text' as const, + className: 'w-40', + placeholder: '1.2.3.4…', + }, + ], + [], + ) + + const table = useReactTable({ + columns, + data: filteredData, + getRowId: (row) => row.id, + state: { pagination, sorting }, + onPaginationChange: setPagination, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + const logTable = useReactTable({ + columns: logColumns, + data: filteredLogs, + getRowId: (row, i) => String(row.seq ?? `${row.tsEpoch}-${i}`), + state: { pagination: logPagination, sorting: logSorting }, + onPaginationChange: setLogPagination, + onSortingChange: setLogSorting, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + const activeFilters = getActiveFilters(filters) + const isLoading = summary.isLoading && !summary.data + + return ( + <> + +
+ handleOpen(row)} + emptyMessage={ + !isLoading && filteredData.length === 0 + ? 'Нет классов ошибок. Сбросьте фильтры или дождитесь статистики Telemt.' + : undefined + } + tableLayout={{ + columnsResizable: true, + columnsMovable: true, + columnsVisibility: true, + dense: true, + }} + > + + +
+ Ошибки соединений + + Классы отказов из stats/summary + IP из events и TLS fingerprints ·{' '} + {formatNumberSafe(data.connections_bad_total)} bad total + +
+ +
+ +
+ + + Filters + + } + /> + {activeFilters.length > 0 ? ( + + ) : null} +
+ + + + + + + + +
+ +
+ + + + +
+ Журнал events + + Сырые runtime-логи API · IP извлекаются из context + +
+
+ +
+ + + Filters + + } + /> + {getActiveFilters(logFilters).length > 0 ? ( + + ) : null} +
+ + + + + + + + +
+ +
+
+
+ + + + ) +} + +function formatNumberSafe(value: unknown): string { + const n = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(n)) return '—' + return new Intl.NumberFormat('ru-RU').format(n) +} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 212f0c9..a63cfbc 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -5,6 +5,7 @@ import { Settings, Shield, Activity, + ShieldAlert, } from 'lucide-react' import type { CSSProperties, ReactNode } from 'react' import { Link, useRouterState } from '@tanstack/react-router' @@ -60,6 +61,7 @@ const NAV_GROUPS: NavGroup[] = [ label: 'Telemt', items: [ { to: '/users', label: 'Пользователи', icon: Users }, + { to: '/errors', label: 'Ошибки', icon: ShieldAlert }, { to: '/runtime', label: 'Runtime', icon: Activity }, { to: '/security', label: 'Безопасность', icon: Shield }, { to: '/servers', label: 'Серверы', icon: Server, fleetOnly: true }, diff --git a/apps/web/src/lib/telemt-errors.ts b/apps/web/src/lib/telemt-errors.ts new file mode 100644 index 0000000..4f902c3 --- /dev/null +++ b/apps/web/src/lib/telemt-errors.ts @@ -0,0 +1,267 @@ +import type { ApiEventRecord, TlsFingerprintRow } from '@/lib/telemt' +import { resolveErrorClassLabel } from '@/lib/telemt-error-classes' + +export type TelemtErrorKind = 'connection' | 'handshake' + +export interface TelemtErrorIpDetail { + ip: string + source: 'event' | 'tls' + badOrProbe?: number + total?: number + lastSeenEpoch?: number | null + ja4?: string + ja3?: string +} + +export interface TelemtErrorLogEvent { + seq?: number + tsEpoch?: number | null + eventType: string + context: string +} + +export interface TelemtErrorRow { + id: string + kind: TelemtErrorKind + code: string + labelRu: string + labelEn: string + total: number + ips: string[] + ipDetails: TelemtErrorIpDetail[] + logs: TelemtErrorLogEvent[] + lastSeenEpoch: number | null + stageHint: string | null + sharePct: number +} + +const IPV4_RE = + /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)(?::\d{1,5})?\b/g + +function normalizeIp(raw: string): string { + const trimmed = raw.trim() + // strip :port for IPv4 + const m = trimmed.match( + /^((?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d))(?::\d{1,5})?$/, + ) + return m ? m[1] : trimmed +} + +export function extractIpsFromText(text: string): string[] { + if (!text) return [] + const found = text.match(IPV4_RE) ?? [] + return [...new Set(found.map(normalizeIp))] +} + +function isTlsRelated(code: string): boolean { + const c = code.toLowerCase() + return ( + c.includes('tls') || + c.includes('sni') || + c.includes('clienthello') || + c.includes('handshake') || + c.includes('probe') + ) +} + +function eventMatchesClass(ev: ApiEventRecord, code: string): boolean { + const type = String(ev.event_type ?? '').toLowerCase() + const ctx = String(ev.context ?? '').toLowerCase() + const needle = code.toLowerCase() + if (type === needle || type.includes(needle) || ctx.includes(needle)) return true + // soft match: class tokens in event_type (underscores → parts) + const parts = needle.split('_').filter((p) => p.length > 3) + if (parts.length >= 2 && parts.every((p) => type.includes(p) || ctx.includes(p))) { + return true + } + return false +} + +function stageHintForClass( + code: string, + stages: Array<{ stage: string; total: number }>, +): string | null { + if (stages.length === 0) return null + const c = code.toLowerCase() + if (c.includes('tls')) { + const tlsStages = stages.filter((s) => s.stage.toLowerCase().includes('tls')) + if (tlsStages.length) { + return tlsStages + .slice(0, 3) + .map((s) => `${s.stage}=${s.total}`) + .join(', ') + } + } + if (c.includes('direct')) { + const direct = stages.filter((s) => s.stage.toLowerCase().includes('direct')) + if (direct.length) { + return direct + .slice(0, 3) + .map((s) => `${s.stage}=${s.total}`) + .join(', ') + } + } + return stages + .slice(0, 2) + .map((s) => `${s.stage}=${s.total}`) + .join(', ') +} + +export function buildTelemtErrorRows(opts: { + connectionClasses?: Array<{ class: string; total: number }> + handshakeClasses?: Array<{ class: string; total: number }> + handshakeStages?: Array<{ stage: string; total: number }> + events?: ApiEventRecord[] + tlsByIp?: TlsFingerprintRow[] +}): TelemtErrorRow[] { + const events = opts.events ?? [] + const tlsByIp = (opts.tlsByIp ?? []).filter((r) => (r.bad_or_probe ?? 0) > 0) + const stages = opts.handshakeStages ?? [] + + const buckets: Array<{ kind: TelemtErrorKind; code: string; total: number }> = [] + for (const c of opts.connectionClasses ?? []) { + if (!c.class) continue + buckets.push({ kind: 'connection', code: c.class, total: Number(c.total) || 0 }) + } + for (const c of opts.handshakeClasses ?? []) { + if (!c.class) continue + buckets.push({ kind: 'handshake', code: c.class, total: Number(c.total) || 0 }) + } + + const connTotal = buckets + .filter((b) => b.kind === 'connection') + .reduce((s, b) => s + b.total, 0) + const hsTotal = buckets + .filter((b) => b.kind === 'handshake') + .reduce((s, b) => s + b.total, 0) + + const rows: TelemtErrorRow[] = buckets.map((b) => { + const labels = resolveErrorClassLabel(b.code) + const matchedEvents = events.filter((ev) => eventMatchesClass(ev, b.code)) + const ipMap = new Map() + + for (const ev of matchedEvents) { + for (const ip of extractIpsFromText(String(ev.context ?? ''))) { + const prev = ipMap.get(ip) + if (!prev) { + ipMap.set(ip, { + ip, + source: 'event', + lastSeenEpoch: ev.ts_epoch_secs ?? null, + }) + } else if ( + (ev.ts_epoch_secs ?? 0) > (prev.lastSeenEpoch ?? 0) + ) { + prev.lastSeenEpoch = ev.ts_epoch_secs ?? null + } + } + } + + if (isTlsRelated(b.code)) { + for (const row of tlsByIp) { + const ip = String(row.scope ?? '').trim() + if (!ip || ip.includes('/')) continue // skip CIDR in by_ip if any + const prev = ipMap.get(ip) + const detail: TelemtErrorIpDetail = { + ip, + source: 'tls', + badOrProbe: row.bad_or_probe, + total: row.total, + lastSeenEpoch: row.last_seen_epoch_secs ?? null, + ja4: row.ja4, + ja3: row.ja3, + } + if (!prev) { + ipMap.set(ip, detail) + } else { + ipMap.set(ip, { + ...prev, + source: prev.source === 'event' ? 'event' : 'tls', + badOrProbe: row.bad_or_probe ?? prev.badOrProbe, + total: row.total ?? prev.total, + ja4: row.ja4 ?? prev.ja4, + ja3: row.ja3 ?? prev.ja3, + lastSeenEpoch: Math.max( + prev.lastSeenEpoch ?? 0, + row.last_seen_epoch_secs ?? 0, + ) || null, + }) + } + } + } + + const ipDetails = [...ipMap.values()].sort( + (a, b) => (b.badOrProbe ?? 0) - (a.badOrProbe ?? 0) || a.ip.localeCompare(b.ip), + ) + const logs: TelemtErrorLogEvent[] = matchedEvents + .slice() + .sort((a, b) => (b.ts_epoch_secs ?? 0) - (a.ts_epoch_secs ?? 0)) + .slice(0, 40) + .map((ev) => ({ + seq: ev.seq, + tsEpoch: ev.ts_epoch_secs ?? null, + eventType: String(ev.event_type ?? 'event'), + context: String(ev.context ?? ''), + })) + + const lastFromLogs = logs[0]?.tsEpoch ?? null + const lastFromIps = ipDetails.reduce((acc, d) => { + const t = d.lastSeenEpoch ?? null + if (t == null) return acc + if (acc == null) return t + return Math.max(acc, t) + }, null) + + const kindTotal = b.kind === 'connection' ? connTotal : hsTotal + return { + id: `${b.kind}:${b.code}`, + kind: b.kind, + code: b.code, + labelRu: labels.ru, + labelEn: labels.en, + total: b.total, + ips: ipDetails.map((d) => d.ip), + ipDetails, + logs, + lastSeenEpoch: Math.max(lastFromLogs ?? 0, lastFromIps ?? 0) || null, + stageHint: b.kind === 'handshake' ? stageHintForClass(b.code, stages) : null, + sharePct: kindTotal > 0 ? Math.round((b.total / kindTotal) * 100) : 0, + } + }) + + return rows.sort((a, b) => b.total - a.total) +} + +/** Flat event log rows for the secondary data-grid. */ +export function buildErrorLogRows(events: ApiEventRecord[]): TelemtErrorLogEvent[] { + return events + .slice() + .sort((a, b) => (b.ts_epoch_secs ?? 0) - (a.ts_epoch_secs ?? 0)) + .map((ev) => ({ + seq: ev.seq, + tsEpoch: ev.ts_epoch_secs ?? null, + eventType: String(ev.event_type ?? 'event'), + context: String(ev.context ?? ''), + })) +} + +export function parseEventsPayload(payload: unknown): ApiEventRecord[] { + if (!payload || typeof payload !== 'object') return [] + const root = payload as Record + const data = (root.data ?? root) as Record + const nested = (data.data ?? data) as Record + if (Array.isArray(nested.events)) return nested.events as ApiEventRecord[] + if (Array.isArray(data.events)) return data.events as ApiEventRecord[] + if (Array.isArray(root.events)) return root.events as ApiEventRecord[] + return [] +} + +export function parseTlsByIp(payload: unknown): TlsFingerprintRow[] { + if (!payload || typeof payload !== 'object') return [] + const root = payload as Record + const data = (root.data ?? root) as Record + const nested = (data.data ?? data) as Record + if (Array.isArray(nested.by_ip)) return nested.by_ip as TlsFingerprintRow[] + if (Array.isArray(data.by_ip)) return data.by_ip as TlsFingerprintRow[] + return [] +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index d8aab36..0385b85 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as ClientsRouteImport } from './routes/clients' import { Route as DashboardRouteImport } from './routes/dashboard' +import { Route as ErrorsRouteImport } from './routes/errors' import { Route as LoginRouteImport } from './routes/login' import { Route as RuntimeRouteImport } from './routes/runtime' import { Route as SecurityRouteImport } from './routes/security' @@ -34,6 +35,11 @@ const DashboardRoute = DashboardRouteImport.update({ path: '/dashboard', getParentRoute: () => rootRouteImport, } as any) +const ErrorsRoute = ErrorsRouteImport.update({ + id: '/errors', + path: '/errors', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -69,6 +75,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/clients': typeof ClientsRoute '/dashboard': typeof DashboardRoute + '/errors': typeof ErrorsRoute '/login': typeof LoginRoute '/runtime': typeof RuntimeRoute '/security': typeof SecurityRoute @@ -80,6 +87,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/clients': typeof ClientsRoute '/dashboard': typeof DashboardRoute + '/errors': typeof ErrorsRoute '/login': typeof LoginRoute '/runtime': typeof RuntimeRoute '/security': typeof SecurityRoute @@ -92,6 +100,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/clients': typeof ClientsRoute '/dashboard': typeof DashboardRoute + '/errors': typeof ErrorsRoute '/login': typeof LoginRoute '/runtime': typeof RuntimeRoute '/security': typeof SecurityRoute @@ -105,6 +114,7 @@ export interface FileRouteTypes { | '/' | '/clients' | '/dashboard' + | '/errors' | '/login' | '/runtime' | '/security' @@ -116,6 +126,7 @@ export interface FileRouteTypes { | '/' | '/clients' | '/dashboard' + | '/errors' | '/login' | '/runtime' | '/security' @@ -127,6 +138,7 @@ export interface FileRouteTypes { | '/' | '/clients' | '/dashboard' + | '/errors' | '/login' | '/runtime' | '/security' @@ -139,6 +151,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute ClientsRoute: typeof ClientsRoute DashboardRoute: typeof DashboardRoute + ErrorsRoute: typeof ErrorsRoute LoginRoute: typeof LoginRoute RuntimeRoute: typeof RuntimeRoute SecurityRoute: typeof SecurityRoute @@ -170,6 +183,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardRouteImport parentRoute: typeof rootRouteImport } + '/errors': { + id: '/errors' + path: '/errors' + fullPath: '/errors' + preLoaderRoute: typeof ErrorsRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -219,6 +239,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ClientsRoute: ClientsRoute, DashboardRoute: DashboardRoute, + ErrorsRoute: ErrorsRoute, LoginRoute: LoginRoute, RuntimeRoute: RuntimeRoute, SecurityRoute: SecurityRoute, diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx index e1688be..2168696 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/dashboard.tsx @@ -1,7 +1,8 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { ActivityIcon, + ArrowRightIcon, ClockIcon, ServerIcon, ShieldAlertIcon, @@ -12,6 +13,7 @@ import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit' import { PageHeader } from '@/components/page-header' import { MetricListFrame, MetricRow, RankedBarList, StatusBadge } from '@/components/metric-list' import { UI_SURFACE } from '@/lib/ui-surface' +import { Button } from '@telemt/ui/components/button' import { api } from '@/lib/api-client' import { resolveErrorClassLabel } from '@/lib/telemt-error-classes' import { @@ -84,19 +86,7 @@ function DashboardPage() { } }) .sort((a, b) => b.value - a.value) - .slice(0, 6) - const hsClasses = (data.handshake_failures_by_class ?? []) - .map((c) => { - const labels = resolveErrorClassLabel(c.class) - return { - label: labels.code, - title: labels.ru, - subtitle: `${labels.en} · ${labels.code}`, - value: Number(c.total) || 0, - } - }) - .sort((a, b) => b.value - a.value) - .slice(0, 6) + .slice(0, 3) const items: KpiStatItem[] = [ { @@ -165,21 +155,24 @@ function DashboardPage() { } > - - - - +
+ +
diff --git a/apps/web/src/routes/errors.tsx b/apps/web/src/routes/errors.tsx new file mode 100644 index 0000000..ead01b5 --- /dev/null +++ b/apps/web/src/routes/errors.tsx @@ -0,0 +1,18 @@ +'use no memo' + +import { createFileRoute } from '@tanstack/react-router' + +import { ErrorsGridView } from '@/components/errors/errors-grid-view' + +/** + * Errors — separate section with data-grid: classes, IPs, API/logs. + * Preview: https://reui.io/preview/base/data-grid-base-2 + * Docs: https://reui.io/blocks + */ +export const Route = createFileRoute('/errors')({ + component: ErrorsPage, +}) + +function ErrorsPage() { + return +}