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,
|
||||
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 },
|
||||
|
||||
@@ -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 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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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