Add Errors route and integrate into navigation and dashboard
Build and Push Telemt Panel Docker Image / build-and-push (push) Successful in 2m17s
Build and Push Telemt Panel Docker Image / create-release (push) Skipped

This commit is contained in:
Denozordec
2026-08-04 21:39:45 +07:00
parent 33af19ba0c
commit d17a403f12
8 changed files with 1263 additions and 24 deletions
@@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full flex-col gap-0 sm:max-w-lg">
<SheetHeader className="border-b pb-4">
<SheetTitle>{row?.labelRu ?? 'Ошибка'}</SheetTitle>
<SheetDescription>
{row ? (
<span className="font-mono text-xs">
{row.labelEn} · {row.code}
</span>
) : (
'Детали класса отказа'
)}
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4 py-4">
{!row ? null : (
<div className="flex flex-col gap-5">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Сводка API</h3>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Тип</dt>
<dd>
<Badge
variant={
row.kind === 'connection'
? 'destructive-light'
: 'warning-light'
}
size="sm"
>
{row.kind === 'connection' ? 'Соединение' : 'Handshake'}
</Badge>
</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Счётчик</dt>
<dd className="font-medium tabular-nums">
{formatNumber(row.total)} ({row.sharePct}%)
</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Последний раз</dt>
<dd className="tabular-nums text-xs">
{row.lastSeenEpoch != null
? formatEpoch(row.lastSeenEpoch)
: '—'}
</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Код</dt>
<dd className="font-mono text-xs">{row.code}</dd>
</div>
</dl>
{row.stageHint ? (
<p className="text-muted-foreground font-mono text-xs">
stages: {row.stageHint}
</p>
) : null}
</section>
<Separator />
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">
IP ({formatNumber(row.ipDetails.length)})
</h3>
{row.ipDetails.length === 0 ? (
<p className="text-muted-foreground text-sm">
В events/TLS fingerprint пока нет IP, связанных с этим классом.
Счётчик приходит из stats/summary без per-IP разбивки.
</p>
) : (
<ul className="flex flex-col gap-2">
{row.ipDetails.map((d) => (
<li
key={d.ip}
className="bg-muted/40 flex flex-col gap-1 rounded-md px-3 py-2"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{d.ip}</span>
<Badge variant="outline" size="sm">
{d.source === 'tls' ? 'TLS probe' : 'event'}
</Badge>
{d.badOrProbe != null ? (
<Badge variant="destructive-light" size="sm">
bad {formatNumber(d.badOrProbe)}
</Badge>
) : null}
</div>
<div className="text-muted-foreground flex flex-col gap-0.5 text-[11px]">
{d.lastSeenEpoch != null ? (
<span>last {formatEpoch(d.lastSeenEpoch)}</span>
) : null}
{d.ja4 ? (
<span className="break-all font-mono">JA4 {d.ja4}</span>
) : null}
{d.ja3 ? (
<span className="break-all font-mono">JA3 {d.ja3}</span>
) : null}
</div>
</li>
))}
</ul>
)}
</section>
<Separator />
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">
Логи / events ({formatNumber(row.logs.length)})
</h3>
{row.logs.length === 0 ? (
<p className="text-muted-foreground text-sm">
В `/v1/runtime/events/recent` нет записей с этим классом в
event_type/context.
</p>
) : (
<ul className="flex flex-col gap-2">
{row.logs.map((log, i) => (
<li
key={`${log.seq ?? i}-${log.tsEpoch}`}
className="flex flex-col gap-1 border-b border-border/50 py-2 last:border-b-0"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{log.eventType}</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{log.tsEpoch != null ? formatEpoch(log.tsEpoch) : '—'}
</span>
</div>
<p className="text-muted-foreground break-all font-mono text-[11px]">
{log.context || '—'}
</p>
</li>
))}
</ul>
)}
</section>
</div>
)}
</ScrollArea>
</SheetContent>
</Sheet>
)
}
@@ -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 (
<Badge
variant={kind === 'connection' ? 'destructive-light' : 'warning-light'}
size="sm"
>
{kind === 'connection' ? 'Соединение' : 'Handshake'}
</Badge>
)
}
function IpBadges({ ips }: { ips: string[] }) {
if (ips.length === 0) {
return <span className="text-muted-foreground text-xs">нет IP в логах</span>
}
const shown = ips.slice(0, 3)
const rest = ips.length - shown.length
return (
<div className="flex min-w-0 flex-wrap items-center gap-1" title={ips.join(', ')}>
{shown.map((ip) => (
<Badge key={ip} variant="secondary" size="sm" className="font-mono text-[10px]">
{ip}
</Badge>
))}
{rest > 0 ? (
<Badge variant="outline" size="sm">
+{rest}
</Badge>
) : null}
</div>
)
}
export function createErrorsColumns(opts: {
onOpen: (row: TelemtErrorRow) => void
}): ColumnDef<TelemtErrorRow>[] {
return [
{
id: 'labelRu',
accessorKey: 'labelRu',
header: ({ column }) => (
<DataGridColumnHeader title="Ошибка" visibility={true} column={column} />
),
cell: ({ row }) => (
<button
type="button"
className="flex min-w-0 flex-col gap-0.5 text-left"
onClick={() => opts.onOpen(row.original)}
>
<span className="text-foreground line-clamp-2 font-medium">
{row.original.labelRu}
</span>
<span className="text-muted-foreground line-clamp-1 font-mono text-[11px]">
{row.original.labelEn} · {row.original.code}
</span>
</button>
),
minSize: 260,
enableSorting: true,
enableHiding: false,
enableResizing: true,
meta: {
autoSize: true,
skeleton: (
<div className="flex flex-col gap-1">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-40" />
</div>
),
},
},
{
id: 'kind',
accessorKey: 'kind',
header: ({ column }) => (
<DataGridColumnHeader title="Тип" visibility={true} column={column} />
),
cell: ({ row }) => <KindBadge kind={row.original.kind} />,
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: { skeleton: <Skeleton className="h-5 w-20 rounded-full" /> },
},
{
id: 'total',
accessorKey: 'total',
header: ({ column }) => (
<DataGridColumnHeader title="Счётчик" visibility={true} column={column} />
),
cell: ({ row }) => (
<div className="flex flex-col gap-0.5">
<span className="tabular-nums font-medium">
{formatNumber(row.original.total)}
</span>
<span className="text-muted-foreground text-[10px] tabular-nums">
{row.original.sharePct}% в группе
</span>
</div>
),
size: 110,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: { skeleton: <Skeleton className="h-4 w-12" /> },
},
{
id: 'ips',
accessorFn: (row) => row.ips.join(' '),
header: ({ column }) => (
<DataGridColumnHeader title="IP" visibility={true} column={column} />
),
cell: ({ row }) => <IpBadges ips={row.original.ips} />,
size: 240,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: (
<div className="flex gap-1">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
),
},
},
{
id: 'ipCount',
accessorFn: (row) => row.ips.length,
header: ({ column }) => (
<DataGridColumnHeader title="IP #" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="tabular-nums">{formatNumber(row.original.ips.length)}</span>
),
size: 80,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: { skeleton: <Skeleton className="h-4 w-8" /> },
},
{
id: 'lastSeenEpoch',
accessorKey: 'lastSeenEpoch',
header: ({ column }) => (
<DataGridColumnHeader title="Последний" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{row.original.lastSeenEpoch != null
? formatEpoch(row.original.lastSeenEpoch)
: '—'}
</span>
),
size: 150,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: { skeleton: <Skeleton className="h-4 w-28" /> },
},
{
id: 'logs',
accessorFn: (row) => row.logs.length,
header: ({ column }) => (
<DataGridColumnHeader title="Логи" visibility={true} column={column} />
),
cell: ({ row }) => {
const first = row.original.logs[0]
return (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="tabular-nums text-xs">
{formatNumber(row.original.logs.length)} событий
</span>
{first ? (
<span className="text-muted-foreground line-clamp-1 font-mono text-[10px]">
{first.eventType}: {first.context || '—'}
</span>
) : (
<span className="text-muted-foreground text-[10px]">нет в ring buffer</span>
)}
</div>
)
},
size: 220,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: (
<div className="flex flex-col gap-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-40" />
</div>
),
},
},
{
id: 'stageHint',
accessorKey: 'stageHint',
header: ({ column }) => (
<DataGridColumnHeader title="Stage API" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground line-clamp-2 font-mono text-[10px]">
{row.original.stageHint ?? '—'}
</span>
),
size: 160,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: { skeleton: <Skeleton className="h-3 w-24" /> },
},
{
id: 'actions',
header: '',
cell: ({ row }) => <RowActions row={row} onOpen={opts.onOpen} />,
size: 52,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: { skeleton: <Skeleton className="size-7 rounded-md" /> },
},
]
}
function RowActions({
row,
onOpen,
}: {
row: Row<TelemtErrorRow>
onOpen: (row: TelemtErrorRow) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="Действия"
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => onOpen(row.original)}>
<EyeIcon className="size-4" aria-hidden />
Открыть
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
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' },
],
},
],
[],
)
}
@@ -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<TelemtErrorLogEvent>[] {
return [
{
id: 'tsEpoch',
accessorKey: 'tsEpoch',
header: ({ column }) => (
<DataGridColumnHeader title="Время" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{row.original.tsEpoch != null ? formatEpoch(row.original.tsEpoch) : '—'}
</span>
),
size: 150,
},
{
id: 'eventType',
accessorKey: 'eventType',
header: ({ column }) => (
<DataGridColumnHeader title="event_type" visibility={true} column={column} />
),
cell: ({ row }) => (
<Badge variant="outline" size="sm" className="font-mono">
{row.original.eventType}
</Badge>
),
size: 180,
},
{
id: 'ips',
accessorFn: (row) => extractIpsFromText(row.context).join(' '),
header: ({ column }) => (
<DataGridColumnHeader title="IP" visibility={true} column={column} />
),
cell: ({ row }) => {
const ips = extractIpsFromText(row.original.context)
if (ips.length === 0) {
return <span className="text-muted-foreground text-xs"></span>
}
return (
<div className="flex flex-wrap gap-1">
{ips.slice(0, 3).map((ip) => (
<Badge key={ip} variant="secondary" size="sm" className="font-mono text-[10px]">
{ip}
</Badge>
))}
</div>
)
},
size: 180,
},
{
id: 'context',
accessorKey: 'context',
header: ({ column }) => (
<DataGridColumnHeader title="context / лог" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground line-clamp-2 break-all font-mono text-[11px]">
{row.original.context || '—'}
</span>
),
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<TelemtErrorRow | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [logPagination, setLogPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'total', desc: true },
])
const [logSorting, setLogSorting] = useState<SortingState>([
{ id: 'tsEpoch', desc: true },
])
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
const [logFilters, setLogFilters] = useState<Filter[]>(() => [
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<SummaryData>(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<string, unknown>)[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<string, unknown>)[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 (
<>
<TooltipProvider delay={200}>
<div className="flex flex-col gap-4 md:gap-6">
<DataGrid
table={table}
isLoading={isLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
onRowClick={(row) => handleOpen(row)}
emptyMessage={
!isLoading && filteredData.length === 0
? 'Нет классов ошибок. Сбросьте фильтры или дождитесь статистики Telemt.'
: undefined
}
tableLayout={{
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
dense: true,
}}
>
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle className="text-balance">Ошибки соединений</FrameTitle>
<FrameDescription className="text-xs text-pretty">
Классы отказов из stats/summary + IP из events и TLS fingerprints ·{' '}
{formatNumberSafe(data.connections_bad_total)} bad total
</FrameDescription>
</div>
<ShieldAlertIcon className="text-muted-foreground size-5" aria-hidden />
</FrameHeader>
<FramePanel className="p-0 shadow-none">
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-2.5">
<Filters
filters={filters}
fields={filterFields}
onChange={setFilters}
size="default"
trigger={
<Button type="button" size="default" variant="outline" aria-label="Фильтры">
<FilterIcon aria-hidden />
Filters
</Button>
}
/>
{activeFilters.length > 0 ? (
<Button
type="button"
size="default"
variant="outline"
onClick={() => setFilters(createDefaultFilters())}
>
<FilterXIcon aria-hidden />
Clear
</Button>
) : null}
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
<DataGrid
table={logTable}
isLoading={events.isLoading && !events.data}
loadingMode="skeleton"
recordCount={filteredLogs.length}
emptyMessage={
filteredLogs.length === 0
? 'Нет записей в runtime/events/recent (нужен runtime_edge_enabled).'
: undefined
}
tableLayout={{
columnsResizable: true,
columnsVisibility: true,
dense: true,
}}
>
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle className="text-balance">Журнал events</FrameTitle>
<FrameDescription className="text-xs text-pretty">
Сырые runtime-логи API · IP извлекаются из context
</FrameDescription>
</div>
</FrameHeader>
<FramePanel className="p-0 shadow-none">
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-2.5">
<Filters
filters={logFilters}
fields={logFilterFields}
onChange={setLogFilters}
size="default"
trigger={
<Button type="button" size="default" variant="outline" aria-label="Фильтры логов">
<FilterIcon aria-hidden />
Filters
</Button>
}
/>
{getActiveFilters(logFilters).length > 0 ? (
<Button
type="button"
size="default"
variant="outline"
onClick={() =>
setLogFilters([createFilter('eventType', 'contains', [''])])
}
>
<FilterXIcon aria-hidden />
Clear
</Button>
) : null}
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
</div>
</TooltipProvider>
<ErrorDetailSheet
row={selected}
open={sheetOpen}
onOpenChange={setSheetOpen}
/>
</>
)
}
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)
}
@@ -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 },
+267
View File
@@ -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<string, TelemtErrorIpDetail>()
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<number | null>((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<string, unknown>
const data = (root.data ?? root) as Record<string, unknown>
const nested = (data.data ?? data) as Record<string, unknown>
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<string, unknown>
const data = (root.data ?? root) as Record<string, unknown>
const nested = (data.data ?? data) as Record<string, unknown>
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 []
}
+21
View File
@@ -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,
+17 -24
View File
@@ -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() {
</MetricListFrame>
<MetricListFrame
title="Ошибки соединений"
description="Топ классов отказов"
title="Ошибки"
description="Топ классов — полный разбор с IP в разделе Ошибки"
trailing={
<ShieldAlertIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
}
>
<RankedBarList items={badClasses} emptyLabel="Ошибок соединений нет" />
</MetricListFrame>
<MetricListFrame
title="Ошибки handshake"
description="Топ классов handshake"
className="md:col-span-2"
>
<RankedBarList items={hsClasses} emptyLabel="Ошибок handshake нет" />
<div className="pt-3">
<Button
type="button"
variant="outline"
size="sm"
render={<Link to="/errors" />}
>
Открыть ошибки
<ArrowRightIcon className="size-3.5" aria-hidden />
</Button>
</div>
</MetricListFrame>
</div>
</div>
+18
View File
@@ -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 <ErrorsGridView />
}