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.
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+
+ )
+}
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 }) => (
+ opts.onOpen(row.original)}
+ >
+
+ {row.original.labelRu}
+
+
+ {row.original.labelEn} · {row.original.code}
+
+
+ ),
+ 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 ? (
+ setFilters(createDefaultFilters())}
+ >
+
+ Clear
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Журнал events
+
+ Сырые runtime-логи API · IP извлекаются из context
+
+
+
+
+
+
+
+ Filters
+
+ }
+ />
+ {getActiveFilters(logFilters).length > 0 ? (
+
+ setLogFilters([createFilter('eventType', 'contains', [''])])
+ }
+ >
+
+ Clear
+
+ ) : 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
+}