Add Errors route and integrate into navigation and dashboard
This commit is contained in:
@@ -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,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
Activity,
|
Activity,
|
||||||
|
ShieldAlert,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { CSSProperties, ReactNode } from 'react'
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
@@ -60,6 +61,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: 'Telemt',
|
label: 'Telemt',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/users', label: 'Пользователи', icon: Users },
|
{ to: '/users', label: 'Пользователи', icon: Users },
|
||||||
|
{ to: '/errors', label: 'Ошибки', icon: ShieldAlert },
|
||||||
{ to: '/runtime', label: 'Runtime', icon: Activity },
|
{ to: '/runtime', label: 'Runtime', icon: Activity },
|
||||||
{ to: '/security', label: 'Безопасность', icon: Shield },
|
{ to: '/security', label: 'Безопасность', icon: Shield },
|
||||||
{ to: '/servers', label: 'Серверы', icon: Server, fleetOnly: true },
|
{ to: '/servers', label: 'Серверы', icon: Server, fleetOnly: true },
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ClientsRouteImport } from './routes/clients'
|
import { Route as ClientsRouteImport } from './routes/clients'
|
||||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||||
|
import { Route as ErrorsRouteImport } from './routes/errors'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as RuntimeRouteImport } from './routes/runtime'
|
import { Route as RuntimeRouteImport } from './routes/runtime'
|
||||||
import { Route as SecurityRouteImport } from './routes/security'
|
import { Route as SecurityRouteImport } from './routes/security'
|
||||||
@@ -34,6 +35,11 @@ const DashboardRoute = DashboardRouteImport.update({
|
|||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ErrorsRoute = ErrorsRouteImport.update({
|
||||||
|
id: '/errors',
|
||||||
|
path: '/errors',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
id: '/login',
|
id: '/login',
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -69,6 +75,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/clients': typeof ClientsRoute
|
'/clients': typeof ClientsRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
|
'/errors': typeof ErrorsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/runtime': typeof RuntimeRoute
|
'/runtime': typeof RuntimeRoute
|
||||||
'/security': typeof SecurityRoute
|
'/security': typeof SecurityRoute
|
||||||
@@ -80,6 +87,7 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/clients': typeof ClientsRoute
|
'/clients': typeof ClientsRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
|
'/errors': typeof ErrorsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/runtime': typeof RuntimeRoute
|
'/runtime': typeof RuntimeRoute
|
||||||
'/security': typeof SecurityRoute
|
'/security': typeof SecurityRoute
|
||||||
@@ -92,6 +100,7 @@ export interface FileRoutesById {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/clients': typeof ClientsRoute
|
'/clients': typeof ClientsRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
|
'/errors': typeof ErrorsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/runtime': typeof RuntimeRoute
|
'/runtime': typeof RuntimeRoute
|
||||||
'/security': typeof SecurityRoute
|
'/security': typeof SecurityRoute
|
||||||
@@ -105,6 +114,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/clients'
|
| '/clients'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
|
| '/errors'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/runtime'
|
| '/runtime'
|
||||||
| '/security'
|
| '/security'
|
||||||
@@ -116,6 +126,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/clients'
|
| '/clients'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
|
| '/errors'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/runtime'
|
| '/runtime'
|
||||||
| '/security'
|
| '/security'
|
||||||
@@ -127,6 +138,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/clients'
|
| '/clients'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
|
| '/errors'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/runtime'
|
| '/runtime'
|
||||||
| '/security'
|
| '/security'
|
||||||
@@ -139,6 +151,7 @@ export interface RootRouteChildren {
|
|||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
ClientsRoute: typeof ClientsRoute
|
ClientsRoute: typeof ClientsRoute
|
||||||
DashboardRoute: typeof DashboardRoute
|
DashboardRoute: typeof DashboardRoute
|
||||||
|
ErrorsRoute: typeof ErrorsRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
RuntimeRoute: typeof RuntimeRoute
|
RuntimeRoute: typeof RuntimeRoute
|
||||||
SecurityRoute: typeof SecurityRoute
|
SecurityRoute: typeof SecurityRoute
|
||||||
@@ -170,6 +183,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DashboardRouteImport
|
preLoaderRoute: typeof DashboardRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/errors': {
|
||||||
|
id: '/errors'
|
||||||
|
path: '/errors'
|
||||||
|
fullPath: '/errors'
|
||||||
|
preLoaderRoute: typeof ErrorsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/login': {
|
'/login': {
|
||||||
id: '/login'
|
id: '/login'
|
||||||
path: '/login'
|
path: '/login'
|
||||||
@@ -219,6 +239,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
ClientsRoute: ClientsRoute,
|
ClientsRoute: ClientsRoute,
|
||||||
DashboardRoute: DashboardRoute,
|
DashboardRoute: DashboardRoute,
|
||||||
|
ErrorsRoute: ErrorsRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
RuntimeRoute: RuntimeRoute,
|
RuntimeRoute: RuntimeRoute,
|
||||||
SecurityRoute: SecurityRoute,
|
SecurityRoute: SecurityRoute,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
ShieldAlertIcon,
|
ShieldAlertIcon,
|
||||||
@@ -12,6 +13,7 @@ import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
|||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { MetricListFrame, MetricRow, RankedBarList, StatusBadge } from '@/components/metric-list'
|
import { MetricListFrame, MetricRow, RankedBarList, StatusBadge } from '@/components/metric-list'
|
||||||
import { UI_SURFACE } from '@/lib/ui-surface'
|
import { UI_SURFACE } from '@/lib/ui-surface'
|
||||||
|
import { Button } from '@telemt/ui/components/button'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import { resolveErrorClassLabel } from '@/lib/telemt-error-classes'
|
import { resolveErrorClassLabel } from '@/lib/telemt-error-classes'
|
||||||
import {
|
import {
|
||||||
@@ -84,19 +86,7 @@ function DashboardPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.value - a.value)
|
.sort((a, b) => b.value - a.value)
|
||||||
.slice(0, 6)
|
.slice(0, 3)
|
||||||
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)
|
|
||||||
|
|
||||||
const items: KpiStatItem[] = [
|
const items: KpiStatItem[] = [
|
||||||
{
|
{
|
||||||
@@ -165,21 +155,24 @@ function DashboardPage() {
|
|||||||
</MetricListFrame>
|
</MetricListFrame>
|
||||||
|
|
||||||
<MetricListFrame
|
<MetricListFrame
|
||||||
title="Ошибки соединений"
|
title="Ошибки"
|
||||||
description="Топ классов отказов"
|
description="Топ классов — полный разбор с IP в разделе Ошибки"
|
||||||
trailing={
|
trailing={
|
||||||
<ShieldAlertIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
<ShieldAlertIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<RankedBarList items={badClasses} emptyLabel="Ошибок соединений нет" />
|
<RankedBarList items={badClasses} emptyLabel="Ошибок соединений нет" />
|
||||||
</MetricListFrame>
|
<div className="pt-3">
|
||||||
|
<Button
|
||||||
<MetricListFrame
|
type="button"
|
||||||
title="Ошибки handshake"
|
variant="outline"
|
||||||
description="Топ классов handshake"
|
size="sm"
|
||||||
className="md:col-span-2"
|
render={<Link to="/errors" />}
|
||||||
>
|
>
|
||||||
<RankedBarList items={hsClasses} emptyLabel="Ошибок handshake нет" />
|
Открыть ошибки
|
||||||
|
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</MetricListFrame>
|
</MetricListFrame>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user