Refactor ErrorsGridView component to streamline imports and enhance filtering functionality. Update pagination handling and integrate new log columns for improved data presentation. Adjust related components for consistency and performance optimization.
Build and Push Telemt Panel Docker Image / build-and-push (push) Successful in 2m15s
Build and Push Telemt Panel Docker Image / create-release (push) Skipped

This commit is contained in:
Denozordec
2026-08-04 21:58:20 +07:00
parent d17a403f12
commit 3c065e8de1
15 changed files with 1522 additions and 1113 deletions
@@ -0,0 +1,47 @@
'use no memo'
import { type ColumnDef } from '@tanstack/react-table'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
export interface ClientRow {
id: string
username: string
createdAt: string
}
/**
* Managed clients columns.
* Preview: https://reui.io/preview/base/data-grid-base-1
* Docs: https://reui.io/docs/components/base/data-grid
*/
export function createClientsColumns(): ColumnDef<ClientRow, unknown>[] {
return [
{
id: 'username',
accessorKey: 'username',
header: ({ column }) => (
<DataGridColumnHeader title="Username" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="font-medium">{row.original.username}</span>
),
enableSorting: true,
size: 200,
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: ({ column }) => (
<DataGridColumnHeader title="Created" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{row.original.createdAt}
</span>
),
enableSorting: true,
size: 200,
},
]
}
@@ -1,60 +1,24 @@
'use no memo'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, 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 { 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 { createFilter, type Filter } from '@/components/reui/filters'
import { getActiveFilters } from '@/components/reui-kit/filter-utils'
import { ResourcePage } from '@/components/reui-kit/resource-page'
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 {
createErrorsLogColumns,
useErrorsLogFilterFields,
} from '@/components/errors/errors-log-columns'
import { TooltipProvider } from '@telemt/ui/components/tooltip'
import { api } from '@/lib/api-client'
import {
formatEpoch,
unwrapData,
type SummaryData,
} from '@/lib/telemt'
import { unwrapData, type SummaryData } from '@/lib/telemt'
import {
buildErrorLogRows,
buildTelemtErrorRows,
@@ -69,99 +33,26 @@ 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,
},
]
function createDefaultLogFilters(): Filter[] {
return [createFilter('eventType', 'contains', [''])]
}
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)
}
/**
* Errors section — data-grid-base-2 DNA.
* Preview: https://reui.io/preview/base/data-grid-base-2
* Docs: https://reui.io/blocks
* Errors section — ResourcePage ×2 (classes + events log).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Docs: https://reui.io/blocks · https://reui.io/docs/components/base/data-grid
*/
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 [logFilters, setLogFilters] = useState<Filter[]>(createDefaultLogFilters)
const summary = useQuery({
queryKey: ['telemt', 'summary'],
@@ -205,32 +96,6 @@ export function ErrorsGridView() {
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)
@@ -240,199 +105,114 @@ export function ErrorsGridView() {
() => createErrorsColumns({ onOpen: handleOpen }),
[handleOpen],
)
const logColumns = useMemo(() => createLogColumns(), [])
const logColumns = useMemo(() => createErrorsLogColumns(), [])
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 logFilterFields = useErrorsLogFilterFields()
const getFilterFieldValue = useCallback(
(item: TelemtErrorRow, field: string) => {
if (field === 'ips') return item.ips.join(' ')
return (item as unknown as Record<string, unknown>)[field]
},
[],
)
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 getLogFilterFieldValue = useCallback(
(item: TelemtErrorLogEvent, field: string) => {
if (field === 'ips') return extractIpsFromText(item.context).join(' ')
return (item as unknown as Record<string, unknown>)[field]
},
[],
)
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}
<ResourcePage
title="Ошибки соединений"
description={`Классы отказов из stats/summary + IP из events и TLS fingerprints · ${formatNumberSafe(data.connections_bad_total)} bad total`}
columns={columns}
data={rows}
getRowId={(row) => row.id}
isLoading={isLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
onRowClick={(row) => handleOpen(row)}
emptyMessage={
!isLoading && filteredData.length === 0
? 'Нет классов ошибок. Сбросьте фильтры или дождитесь статистики Telemt.'
isError={summary.isError}
error={
summary.error instanceof Error
? summary.error
: summary.error
? new Error(String(summary.error))
: null
}
onRetry={() => void summary.refetch()}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={
getActiveFilters(filters).length > 0
? () => setFilters(createDefaultFilters())
: undefined
}
getFilterFieldValue={getFilterFieldValue}
onRowClick={handleOpen}
pinLastColumn
pageSize={10}
initialSorting={[{ id: 'total', desc: true }]}
enableColumnVisibility
emptyState={{
title: 'Нет классов ошибок',
description: 'Дождитесь статистики Telemt.',
}}
emptyMessage="Нет классов ошибок. Сбросьте фильтры или дождитесь статистики Telemt."
primaryAction={
<ShieldAlertIcon
className="text-muted-foreground size-5"
aria-hidden
/>
}
tableLayout={{
dense: true,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
dense: true,
rowsPinnable: false,
}}
>
<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}
<ResourcePage
title="Журнал events"
description="Сырые runtime-логи API · IP извлекаются из context"
columns={logColumns}
data={logRows}
getRowId={(row, i) => String(row.seq ?? `${row.tsEpoch}-${i}`)}
isLoading={events.isLoading && !events.data}
loadingMode="skeleton"
recordCount={filteredLogs.length}
emptyMessage={
filteredLogs.length === 0
? 'Нет записей в runtime/events/recent (нужен runtime_edge_enabled).'
filterFields={logFilterFields}
filters={logFilters}
onFiltersChange={setLogFilters}
onClearFilters={
getActiveFilters(logFilters).length > 0
? () => setLogFilters(createDefaultLogFilters())
: undefined
}
getFilterFieldValue={getLogFilterFieldValue}
pageSize={10}
initialSorting={[{ id: 'tsEpoch', desc: true }]}
enableColumnVisibility
emptyState={{
title: 'Нет записей в журнале',
description:
'Нет записей в runtime/events/recent (нужен runtime_edge_enabled).',
}}
emptyMessage="Нет записей в runtime/events/recent (нужен runtime_edge_enabled)."
tableLayout={{
dense: true,
columnsResizable: true,
columnsVisibility: true,
dense: true,
rowsPinnable: false,
}}
>
<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>
@@ -444,9 +224,3 @@ export function ErrorsGridView() {
</>
)
}
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)
}
@@ -0,0 +1,129 @@
'use no memo'
import { useMemo } from 'react'
import { type ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { type FilterFieldConfig } from '@/components/reui/filters'
import { formatEpoch } from '@/lib/telemt'
import {
extractIpsFromText,
type TelemtErrorLogEvent,
} from '@/lib/telemt-errors'
/**
* Errors journal (runtime events) columns.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Docs: https://reui.io/docs/components/base/data-grid
*/
export function createErrorsLogColumns(): ColumnDef<TelemtErrorLogEvent, unknown>[] {
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,
},
]
}
export function useErrorsLogFilterFields(): FilterFieldConfig[] {
return 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…',
},
],
[],
)
}
@@ -36,16 +36,9 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { DATA_GRID_PAGINATION_LABELS } from './grid-tokens'
const PAGINATION_LABELS = {
rowsPerPageLabel: 'Строк на странице',
info: '{from}{to} из {count}',
previousPageLabel: 'Предыдущая страница',
nextPageLabel: 'Следующая страница',
pageLabel: 'Страница {page}',
previousPagesLabel: 'Предыдущие страницы',
nextPagesLabel: 'Следующие страницы',
} as const
const PAGINATION_LABELS = DATA_GRID_PAGINATION_LABELS
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
if (headerTitle) return headerTitle
@@ -124,6 +117,28 @@ export interface FrameDataGridProps<TData extends object> {
columnVisibilityStorageKey?: string
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
initialColumnVisibility?: VisibilityState
/**
* Without Frame shell — embed inside an existing Frame/FramePanel.
* Preview: https://reui.io/docs/components/base/data-grid
*/
plain?: boolean
/** Override DataGrid tableLayout flags (merged over dense defaults). */
tableLayout?: {
dense?: boolean
cellBorder?: boolean
rowBorder?: boolean
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: 'auto' | 'fixed'
columnsVisibility?: boolean
columnsResizable?: boolean
columnsPinnable?: boolean
columnsMovable?: boolean
rowsPinnable?: boolean
}
className?: string
}
@@ -172,6 +187,7 @@ function FrameDataGridBody<TData extends object>({
footerContent,
showPagination,
enableColumnVisibility,
tableLayout,
}: {
table: ReturnType<typeof useReactTable<TData>>
data: TData[]
@@ -183,6 +199,7 @@ function FrameDataGridBody<TData extends object>({
footerContent?: ReactNode
showPagination: boolean
enableColumnVisibility: boolean
tableLayout?: FrameDataGridProps<TData>['tableLayout']
}) {
return (
<DataGrid
@@ -191,19 +208,22 @@ function FrameDataGridBody<TData extends object>({
onRowClick={onRowClick}
emptyMessage={emptyTitle}
tableLayout={{
dense,
stripped: true,
rowBorder: true,
headerSticky: true,
headerBackground: true,
headerBorder: true,
width: 'auto',
columnsVisibility: enableColumnVisibility,
columnsResizable: false,
columnsPinnable: false,
columnsMovable: false,
dense: tableLayout?.dense ?? dense,
stripped: tableLayout?.stripped ?? true,
rowBorder: tableLayout?.rowBorder ?? true,
headerSticky: tableLayout?.headerSticky ?? true,
headerBackground: tableLayout?.headerBackground ?? true,
headerBorder: tableLayout?.headerBorder ?? true,
width: tableLayout?.width ?? 'auto',
columnsVisibility:
tableLayout?.columnsVisibility ?? enableColumnVisibility,
columnsResizable: tableLayout?.columnsResizable ?? false,
columnsPinnable: tableLayout?.columnsPinnable ?? false,
columnsMovable: tableLayout?.columnsMovable ?? false,
rowsDraggable: false,
rowsPinnable: false,
rowsPinnable: tableLayout?.rowsPinnable ?? false,
cellBorder: tableLayout?.cellBorder,
rowRounded: tableLayout?.rowRounded,
}}
tableClassNames={{
header: 'text-xs font-medium text-muted-foreground',
@@ -250,6 +270,8 @@ export function FrameDataGrid<TData extends object>({
columnVisibilityTrigger,
columnVisibilityStorageKey,
initialColumnVisibility,
plain = false,
tableLayout,
className,
}: FrameDataGridProps<TData>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
@@ -365,13 +387,23 @@ export function FrameDataGrid<TData extends object>({
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
if (data.length === 0) {
const emptyBody = (
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
)
if (plain) {
return (
<div className={cn('flex min-h-48 w-full flex-col items-center justify-center', className)}>
{emptyBody}
</div>
)
}
return (
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
{hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
) : null}
<FramePanel className="flex min-h-72 w-full flex-col items-center justify-center">
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
{emptyBody}
</FramePanel>
</Frame>
)
@@ -389,9 +421,14 @@ export function FrameDataGrid<TData extends object>({
footerContent={footerContent}
showPagination={showPagination}
enableColumnVisibility={enableColumnVisibility}
tableLayout={tableLayout}
/>
)
if (plain) {
return <div className={cn('w-full', className)}>{gridBody}</div>
}
return (
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
{hasHeader ? (
@@ -0,0 +1,33 @@
/**
* Shared DataGrid chrome tokens for ops lists.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Docs: https://reui.io/docs/components/base/data-grid
*/
export const DATA_GRID_PAGINATION_LABELS = {
rowsPerPageLabel: 'Строк на странице',
info: '{from}{to} из {count}',
previousPageLabel: 'Предыдущая страница',
nextPageLabel: 'Следующая страница',
pageLabel: 'Страница {page}',
previousPagesLabel: 'Предыдущие страницы',
nextPagesLabel: 'Следующие страницы',
} as const
/** Semantic Progress indicator tones — no raw emerald/amber/red. */
export function progressToneClass(
score: number | null | undefined,
): string {
const n = Number(score)
if (!Number.isFinite(n)) {
return '**:data-[slot=progress-indicator]:bg-muted-foreground'
}
if (n >= 75) return '**:data-[slot=progress-indicator]:bg-success'
if (n >= 40) return '**:data-[slot=progress-indicator]:bg-warning'
return '**:data-[slot=progress-indicator]:bg-destructive'
}
/** Status dot on outline Badge — semantic tokens only. */
export function statusDotClass(ok: boolean): string {
return ok ? 'bg-success' : 'bg-muted-foreground'
}
@@ -1,5 +1,10 @@
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export {
DATA_GRID_PAGINATION_LABELS,
progressToneClass,
statusDotClass,
} from './grid-tokens'
export {
KpiStatGrid,
KpiStatCard,
@@ -5,9 +5,13 @@ import {
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnOrderState,
type OnChangeFn,
type PaginationState,
type RowPinningState,
type RowSelectionState,
type SortingState,
type VisibilityState,
} from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
@@ -44,6 +48,7 @@ import {
FrameDataGrid,
type FrameDataGridProps,
} from './frame-data-grid'
import { DATA_GRID_PAGINATION_LABELS } from './grid-tokens'
export interface ResourcePageTab {
id: string
@@ -51,6 +56,8 @@ export interface ResourcePageTab {
count?: number
}
type TableLayoutConfig = NonNullable<FrameDataGridProps<object>['tableLayout']>
type SimpleGridPassthrough<T extends object> = Pick<
FrameDataGridProps<T>,
| 'onRowClick'
@@ -67,6 +74,8 @@ type SimpleGridPassthrough<T extends object> = Pick<
| 'columnVisibilityTrigger'
| 'columnVisibilityStorageKey'
| 'initialColumnVisibility'
| 'tableLayout'
| 'plain'
| 'className'
>
@@ -93,6 +102,7 @@ export interface ResourcePageProps<T extends object> extends SimpleGridPassthrou
onRetry?: () => void
primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode }
emptyMessage?: ReactNode
pageSize?: number
enableRowSelection?: boolean
selectionToolbar?: (ctx: {
@@ -103,6 +113,8 @@ export interface ResourcePageProps<T extends object> extends SimpleGridPassthrou
toolbarExtra?: ReactNode
hideHeader?: boolean
pinLastColumn?: boolean
/** Enable row pinning (data-grid-base-2 DNA). */
enableRowPinning?: boolean
/** FrameDataGrid aliases when used as simple list. */
emptyTitle?: string
emptyDescription?: string
@@ -156,6 +168,15 @@ function ResourceLoadError({
const noopFiltersChange = (_filters: Filter[]) => {}
const defaultGetFilterFieldValue = (_item: unknown, _field: string) => undefined
const DEFAULT_FILTERED_LAYOUT: TableLayoutConfig = {
dense: true,
columnsVisibility: true,
columnsResizable: true,
columnsPinnable: true,
columnsMovable: true,
rowsPinnable: false,
}
function ResourcePageSimple<T extends object>({
title = '',
description,
@@ -191,10 +212,14 @@ function ResourcePageSimple<T extends object>({
columnVisibilityTrigger,
columnVisibilityStorageKey,
initialColumnVisibility,
tableLayout,
plain,
className,
}: ResourcePageProps<T>) {
if (isLoading) return <ResourcePageSkeleton />
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
if (isLoading && data.length === 0) return <ResourcePageSkeleton />
if (isError && data.length === 0) {
return <ResourceLoadError error={error} onRetry={onRetry} />
}
const headerActions = primaryAction ?? actions
const resolvedEmpty = emptyState ?? (
@@ -235,6 +260,8 @@ function ResourcePageSimple<T extends object>({
columnVisibilityTrigger={columnVisibilityTrigger}
columnVisibilityStorageKey={columnVisibilityStorageKey}
initialColumnVisibility={initialColumnVisibility}
tableLayout={tableLayout}
plain={plain}
className={className}
/>
</div>
@@ -263,24 +290,65 @@ function ResourcePageFiltered<T extends object>({
primaryAction,
actions,
emptyState,
emptyTitle,
emptyDescription,
emptyAction,
emptyMessage,
pageSize = 10,
enableRowSelection = false,
selectionToolbar,
toolbarExtra,
hideHeader = false,
pinLastColumn = false,
enableRowPinning = false,
onRowClick,
dense = true,
initialSorting,
enableColumnVisibility = true,
columnVisibility: columnVisibilityProp,
onColumnVisibilityChange,
initialColumnVisibility,
tableLayout,
className,
}: ResourcePageProps<T>) {
const headerActions = primaryAction ?? actions
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab
const showFilters = filterFields.length > 0
const [sorting, setSorting] = useState<SortingState>([])
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [rowPinning, setRowPinning] = useState<RowPinningState>({
top: [],
bottom: [],
})
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>(() =>
columns.map((c) => c.id as string).filter(Boolean),
)
const [internalColumnVisibility, setInternalColumnVisibility] =
useState<VisibilityState>(() => initialColumnVisibility ?? {})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize,
})
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
const columnVisibility = isColumnVisibilityControlled
? columnVisibilityProp
: internalColumnVisibility
const setColumnVisibility: OnChangeFn<VisibilityState> =
isColumnVisibilityControlled
? (onColumnVisibilityChange ?? (() => undefined))
: setInternalColumnVisibility
const layout: TableLayoutConfig = {
...DEFAULT_FILTERED_LAYOUT,
dense,
rowsPinnable: enableRowPinning,
columnsVisibility: enableColumnVisibility,
...tableLayout,
}
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
@@ -312,6 +380,15 @@ function ResourcePageFiltered<T extends object>({
return counts
}, [tabs, tabFilter, data, filters, getFilterFieldValue, showFilters])
const resolvedRowPinning = useMemo(() => {
if (!enableRowPinning) return { top: [] as string[], bottom: [] as string[] }
const availableIds = new Set(filteredData.map((row, i) => getRowId(row, i)))
return {
top: (rowPinning.top ?? []).filter((id) => availableIds.has(id)),
bottom: (rowPinning.bottom ?? []).filter((id) => availableIds.has(id)),
}
}, [enableRowPinning, filteredData, getRowId, rowPinning.bottom, rowPinning.top])
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
@@ -323,18 +400,41 @@ function ResourcePageFiltered<T extends object>({
setRowSelection({})
}, [])
const lastColId = pinLastColumn
? (columns[columns.length - 1]?.id ?? '')
: ''
const table = useReactTable({
data: filteredData,
columns,
getRowId: (row) => getRowId(row),
state: { sorting, rowSelection, pagination },
getRowId: (row, index) => getRowId(row, index),
state: {
sorting,
rowSelection,
pagination,
columnVisibility,
columnOrder,
...(enableRowPinning ? { rowPinning: resolvedRowPinning } : {}),
},
enableRowSelection,
enableRowPinning,
keepPinnedRows: enableRowPinning,
enableColumnPinning: Boolean(pinLastColumn || layout.columnsPinnable),
enableHiding: enableColumnVisibility,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
onColumnVisibilityChange: setColumnVisibility,
onColumnOrderChange: setColumnOrder,
onRowPinningChange: enableRowPinning ? setRowPinning : undefined,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
...(pinLastColumn && lastColId
? { columnPinning: { right: [lastColId] } }
: {}),
},
})
const handleTabChange = useCallback(
@@ -369,25 +469,39 @@ function ResourcePageFiltered<T extends object>({
[tabs, tabCounts],
)
if (isLoading) return <ResourcePageSkeleton />
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
const resolvedEmpty = emptyState ?? (
emptyTitle || emptyDescription || emptyAction
? {
title: emptyTitle ?? 'Нет записей',
description: emptyDescription,
action: emptyAction,
}
: undefined
)
if (data.length === 0 && emptyState) {
if (isLoading && data.length === 0) return <ResourcePageSkeleton />
if (isError && data.length === 0) {
return <ResourceLoadError error={error} onRetry={onRetry} />
}
if (!isLoading && data.length === 0 && resolvedEmpty) {
return (
<Frame dense spacing="sm" className="w-full">
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-center justify-center p-0">
<EmptyState
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
title={resolvedEmpty.title}
description={resolvedEmpty.description}
action={resolvedEmpty.action}
/>
</FramePanel>
</Frame>
)
}
const pinnedCount = resolvedRowPinning.top?.length ?? 0
return (
<div className="w-full">
<div className={className ? `w-full ${className}` : 'w-full'}>
{selectionToolbar && selectedCount > 0
? selectionToolbar({
selectedIds,
@@ -398,8 +512,14 @@ function ResourcePageFiltered<T extends object>({
<DataGrid
table={table}
recordCount={filteredData.length}
emptyMessage="Нет записей по выбранным фильтрам."
tableLayout={{ dense: true }}
isLoading={isLoading}
loadingMode="skeleton"
onRowClick={onRowClick}
emptyMessage={
emptyMessage ??
'Нет записей по выбранным фильтрам. Сбросьте фильтры.'
}
tableLayout={layout}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
@@ -425,6 +545,17 @@ function ResourcePageFiltered<T extends object>({
<span>{selectedCount} выбрано</span>
</>
) : null}
{pinnedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="tabular-nums">
{pinnedCount} закреплено
</span>
</>
) : null}
</FrameDescription>
) : null}
</div>
@@ -469,6 +600,15 @@ function ResourcePageFiltered<T extends object>({
)}
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra}
{enableRowPinning && pinnedCount > 0 ? (
<Button
type="button"
variant="outline"
onClick={() => setRowPinning({ top: [], bottom: [] })}
>
Открепить все
</Button>
) : null}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
@@ -483,23 +623,28 @@ function ResourcePageFiltered<T extends object>({
</div>
</div>
{(showFilters || toolbarExtra || selectedCount > 0) ? <Separator /> : null}
{(showFilters || toolbarExtra || selectedCount > 0 || pinnedCount > 0) ? (
<Separator />
) : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
{isError ? (
<div className="px-(--frame-panel-header-px) py-6">
<ResourceLoadError error={error} onRetry={onRetry} />
</div>
) : (
<>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
{...DATA_GRID_PAGINATION_LABELS}
/>
</FrameFooter>
</>
)}
</FramePanel>
</Frame>
</DataGrid>
@@ -511,6 +656,7 @@ function ResourcePageFiltered<T extends object>({
* Ops list page: Frame + DataGrid (+ optional ReUI Filters / tabs).
* Without filterFields/tabs → simple CRUD grid (FrameDataGrid under the hood).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Advanced DNA: https://reui.io/preview/base/data-grid-base-2
*/
export function ResourcePage<T extends object>(props: ResourcePageProps<T>) {
const showFilters = (props.filterFields?.length ?? 0) > 0
+135 -236
View File
@@ -1,55 +1,32 @@
'use no memo'
import { useMemo } from 'react'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { MetricListFrame, MetricRow, StatusBadge } from '@/components/metric-list'
import { cn } from '@telemt/ui/lib/utils'
import { Progress } from '@telemt/ui/components/progress'
import { FrameDataGrid } from '@/components/reui-kit/frame-data-grid'
import {
createDcColumns,
createFamilyColumns,
createWriterColumns,
type DcGridRow,
type FamilyGridRow,
type WriterGridRow,
} from '@/components/runtime/runtime-grid-columns'
import {
formatEpoch,
formatNumber,
formatRttMs,
type DcStatusData,
type MeWritersData,
type RuntimeMePoolStateData,
type RuntimeMeQualityData,
} from '@/lib/telemt'
function rttTone(ms: number | null | undefined): 'success-light' | 'warning-light' | 'destructive-light' | 'secondary' {
if (ms == null || !Number.isFinite(ms)) return 'secondary'
if (ms < 80) return 'success-light'
if (ms < 180) return 'warning-light'
return 'destructive-light'
}
function coverageTone(pct: number | null | undefined): string {
const n = Number(pct)
if (!Number.isFinite(n)) return '**:data-[slot=progress-indicator]:bg-muted-foreground'
if (n >= 90) return '**:data-[slot=progress-indicator]:bg-emerald-500'
if (n >= 60) return '**:data-[slot=progress-indicator]:bg-amber-500'
return '**:data-[slot=progress-indicator]:bg-red-500'
}
function WriterStateBadge({
state,
degraded,
draining,
}: {
state?: string
degraded?: boolean
draining?: boolean
}) {
if (draining) return <Badge variant="warning-light" size="sm">draining</Badge>
if (degraded) return <Badge variant="destructive-light" size="sm">degraded</Badge>
if (state === 'active') return <Badge variant="success-light" size="sm">active</Badge>
if (state === 'warm') return <Badge variant="outline" size="sm">warm</Badge>
return <Badge variant="secondary" size="sm">{state ?? '—'}</Badge>
}
/**
* ME Telemt status — stats-7 + DC/endpoint RTT tables (telemt_panel SoT).
* Preview: https://reui.io/preview/base/stats-7 · https://reui.io/preview/base/list-11
* Docs: https://reui.io/blocks · https://reui.io/docs/components/base/frame
* ME Telemt status — stats-7 + DC/endpoint RTT DataGrids (telemt_panel SoT).
* Preview: https://reui.io/preview/base/stats-7 · https://reui.io/preview/base/settings-14
* Docs: https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame
*/
export function MePoolPanel({
pool,
@@ -71,28 +48,81 @@ export function MePoolPanel({
const refill = pool.data?.refill
const q = quality.data
const dcRows =
(dcs.dcs?.length ? dcs.dcs : null) ??
(q?.dc_rtt ?? []).map((row) => ({
dc: row.dc,
rtt_ms: row.rtt_ema_ms,
alive_writers: row.alive_writers,
required_writers: row.required_writers,
coverage_pct: row.coverage_pct,
endpoints: [] as string[],
endpoint_writers: [] as Array<{ endpoint?: string; active_writers?: number }>,
available_endpoints: undefined as number | undefined,
available_pct: undefined as number | undefined,
load: undefined as number | undefined,
fresh_alive_writers: undefined as number | undefined,
fresh_coverage_pct: undefined as number | undefined,
}))
const dcRows: DcGridRow[] = useMemo(() => {
const source =
(dcs.dcs?.length ? dcs.dcs : null) ??
(q?.dc_rtt ?? []).map((row) => ({
dc: row.dc,
rtt_ms: row.rtt_ema_ms,
alive_writers: row.alive_writers,
required_writers: row.required_writers,
coverage_pct: row.coverage_pct,
endpoints: [] as string[],
endpoint_writers: [] as Array<{
endpoint?: string
active_writers?: number
}>,
load: undefined as number | undefined,
}))
return (source ?? []).map((row) => {
const endpoints = row.endpoints?.length
? row.endpoints
: ((row.endpoint_writers ?? [])
.map((e) => e.endpoint)
.filter(Boolean) as string[])
return {
id: `dc-${row.dc}`,
dc: row.dc as number | string,
rtt_ms: row.rtt_ms,
alive_writers: row.alive_writers,
required_writers: row.required_writers,
coverage_pct: row.coverage_pct,
load: row.load,
endpoints,
endpoint_writers: row.endpoint_writers ?? [],
}
})
}, [dcs.dcs, q?.dc_rtt])
const writerRows = [...(writers.writers ?? [])].sort((a, b) => {
const ra = a.rtt_ema_ms ?? Number.POSITIVE_INFINITY
const rb = b.rtt_ema_ms ?? Number.POSITIVE_INFINITY
return ra - rb
})
const writerTotal = writers.writers?.length ?? 0
const writerRows: WriterGridRow[] = useMemo(() => {
return [...(writers.writers ?? [])]
.sort((a, b) => {
const ra = a.rtt_ema_ms ?? Number.POSITIVE_INFINITY
const rb = b.rtt_ema_ms ?? Number.POSITIVE_INFINITY
return ra - rb
})
.slice(0, 40)
.map((w) => ({
id: `${w.writer_id}-${w.endpoint}`,
endpoint: w.endpoint,
dc: w.dc,
state: w.state,
degraded: w.degraded,
draining: w.draining,
rtt_ema_ms: w.rtt_ema_ms,
bound_clients: w.bound_clients,
generation: w.generation,
writer_id: w.writer_id,
}))
}, [writers.writers])
const familyRows: FamilyGridRow[] = useMemo(
() =>
(q?.family_states ?? []).map((f) => ({
id: String(f.family ?? 'family'),
family: f.family,
state: f.state,
fail_streak: f.fail_streak,
recover_success_streak: f.recover_success_streak,
})),
[q?.family_states],
)
const dcColumns = useMemo(() => createDcColumns(), [])
const writerColumns = useMemo(() => createWriterColumns(), [])
const familyColumns = useMemo(() => createFamilyColumns(), [])
const writerCards = [
{
@@ -190,7 +220,7 @@ export function MePoolPanel({
</FramePanel>
</Frame>
{/* Per-DC servers + RTT — list-11 DNA. Preview: https://reui.io/preview/base/list-11 */}
{/* Per-DC servers + RTT — DataGrid. Preview: https://reui.io/preview/base/settings-14 */}
<MetricListFrame
title="DC в пуле"
description="Серверы (endpoints) Telegram DC, RTT и покрытие writers"
@@ -203,96 +233,20 @@ export function MePoolPanel({
/>
}
>
{dcRows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{dcs.reason ??
'Нет данных /v1/stats/dcs (нужен minimal_runtime_enabled) и нет dc_rtt из me_quality.'}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-3 font-medium">DC</th>
<th className="py-2 pr-3 font-medium">RTT</th>
<th className="py-2 pr-3 font-medium">Writers</th>
<th className="py-2 pr-3 font-medium">Coverage</th>
<th className="py-2 pr-3 font-medium">Load</th>
<th className="py-2 font-medium">Endpoints</th>
</tr>
</thead>
<tbody>
{dcRows.map((row) => {
const coverage = Number(row.coverage_pct ?? 0)
const endpoints =
row.endpoints?.length
? row.endpoints
: (row.endpoint_writers ?? []).map((e) => e.endpoint).filter(Boolean)
return (
<tr key={`dc-${row.dc}`} className="border-b border-border/50 align-top">
<td className="py-2.5 pr-3 font-medium tabular-nums">
DC {formatNumber(row.dc)}
</td>
<td className="py-2.5 pr-3">
<Badge variant={rttTone(row.rtt_ms ?? null)} size="sm">
{formatRttMs(row.rtt_ms)}
</Badge>
</td>
<td className="py-2.5 pr-3 tabular-nums">
{formatNumber(row.alive_writers)} / {formatNumber(row.required_writers)}
</td>
<td className="py-2.5 pr-3 min-w-28">
<div className="flex flex-col gap-1">
<Progress
value={Math.min(100, Math.max(0, coverage))}
className={cn('h-1.5', coverageTone(coverage))}
/>
<span className="text-muted-foreground text-[10px] tabular-nums">
{Number.isFinite(coverage) ? `${Math.round(coverage)}%` : '—'}
</span>
</div>
</td>
<td className="py-2.5 pr-3 tabular-nums">
{row.load != null ? formatNumber(row.load) : '—'}
</td>
<td className="py-2.5">
{endpoints.length === 0 ? (
<span className="text-muted-foreground"></span>
) : (
<ul className="flex flex-col gap-1">
{endpoints.slice(0, 4).map((ep) => {
const writersOnEp = (row.endpoint_writers ?? []).find(
(w) => w.endpoint === ep,
)
return (
<li
key={String(ep)}
className="flex flex-wrap items-center gap-2 font-mono text-xs"
>
<span>{ep}</span>
{writersOnEp?.active_writers != null ? (
<Badge variant="outline" size="sm">
{formatNumber(writersOnEp.active_writers)} w
</Badge>
) : null}
</li>
)
})}
{endpoints.length > 4 ? (
<li className="text-muted-foreground text-xs">
+{endpoints.length - 4} ещё
</li>
) : null}
</ul>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<FrameDataGrid
plain
columns={dcColumns}
data={dcRows}
rowId={(row) => row.id}
emptyTitle={
dcs.reason
? String(dcs.reason)
: 'Нет данных /v1/stats/dcs (нужен minimal_runtime_enabled) и нет dc_rtt из me_quality.'
}
pagination={dcRows.length > 10}
pageSize={10}
dense
/>
</MetricListFrame>
{/* Per-writer / endpoint latency */}
@@ -303,104 +257,49 @@ export function MePoolPanel({
trailing={
<StatusBadge
ok={writers.middle_proxy_enabled !== false || writerRows.length > 0}
okLabel={`${writerRows.length} writers`}
okLabel={`${writerTotal} writers`}
failLabel={writers.reason ? String(writers.reason) : 'Нет writers'}
/>
}
>
{writerRows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{writers.reason ??
'Нет данных /v1/stats/me-writers. Включите minimal_runtime_enabled в Telemt API.'}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-3 font-medium">Endpoint</th>
<th className="py-2 pr-3 font-medium">DC</th>
<th className="py-2 pr-3 font-medium">Состояние</th>
<th className="py-2 pr-3 font-medium">RTT</th>
<th className="py-2 pr-3 font-medium">Клиенты</th>
<th className="py-2 font-medium">Gen</th>
</tr>
</thead>
<tbody>
{writerRows.slice(0, 40).map((w) => (
<tr
key={`${w.writer_id}-${w.endpoint}`}
className="border-b border-border/50"
>
<td className="py-2 pr-3 font-mono text-xs">{w.endpoint ?? '—'}</td>
<td className="py-2 pr-3 tabular-nums">
{w.dc != null ? formatNumber(w.dc) : '—'}
</td>
<td className="py-2 pr-3">
<WriterStateBadge
state={w.state}
degraded={w.degraded}
draining={w.draining}
/>
</td>
<td className="py-2 pr-3">
<Badge variant={rttTone(w.rtt_ema_ms ?? null)} size="sm">
{formatRttMs(w.rtt_ema_ms)}
</Badge>
</td>
<td className="py-2 pr-3 tabular-nums">
{formatNumber(w.bound_clients)}
</td>
<td className="py-2 tabular-nums text-muted-foreground">
{formatNumber(w.generation)}
</td>
</tr>
))}
</tbody>
</table>
{writerRows.length > 40 ? (
<p className="text-muted-foreground pt-2 text-xs">
Показаны 40 из {writerRows.length} writers (сортировка по RTT).
</p>
) : null}
</div>
)}
<div className="flex flex-col gap-2">
<FrameDataGrid
plain
columns={writerColumns}
data={writerRows}
rowId={(row) => row.id}
emptyTitle={
writers.reason
? String(writers.reason)
: 'Нет данных /v1/stats/me-writers. Включите minimal_runtime_enabled в Telemt API.'
}
pagination={writerRows.length > 10}
pageSize={10}
dense
/>
{writerTotal > 40 ? (
<p className="text-muted-foreground text-xs">
Показаны 40 из {writerTotal} writers (сортировка по RTT).
</p>
) : null}
</div>
</MetricListFrame>
{(q?.family_states ?? []).length > 0 ? (
{familyRows.length > 0 ? (
<MetricListFrame
title="Family states"
description="Состояние маршрутов IPv4 / IPv6"
className="w-full"
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-3 font-medium">Family</th>
<th className="py-2 pr-3 font-medium">State</th>
<th className="py-2 pr-3 font-medium">Fail streak</th>
<th className="py-2 font-medium">Recover streak</th>
</tr>
</thead>
<tbody>
{(q?.family_states ?? []).map((f) => (
<tr key={f.family} className="border-b border-border/50">
<td className="py-2 pr-3 font-medium">{f.family ?? '—'}</td>
<td className="py-2 pr-3">
<Badge variant="outline" size="sm">
{f.state ?? '—'}
</Badge>
</td>
<td className="py-2 pr-3 tabular-nums">{formatNumber(f.fail_streak)}</td>
<td className="py-2 tabular-nums">
{formatNumber(f.recover_success_streak)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<FrameDataGrid
plain
columns={familyColumns}
data={familyRows}
rowId={(row) => row.id}
emptyTitle="Нет family states"
pagination={false}
dense
/>
</MetricListFrame>
) : null}
@@ -0,0 +1,385 @@
'use no memo'
import { type ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { progressToneClass } from '@/components/reui-kit/grid-tokens'
import { cn } from '@telemt/ui/lib/utils'
import { Progress } from '@telemt/ui/components/progress'
import {
formatNumber,
formatRttMs,
} from '@/lib/telemt'
function rttTone(
ms: number | null | undefined,
): 'success-light' | 'warning-light' | 'destructive-light' | 'secondary' {
if (ms == null || !Number.isFinite(ms)) return 'secondary'
if (ms < 80) return 'success-light'
if (ms < 180) return 'warning-light'
return 'destructive-light'
}
function WriterStateBadge({
state,
degraded,
draining,
}: {
state?: string
degraded?: boolean
draining?: boolean
}) {
if (draining) return <Badge variant="warning-light" size="sm">draining</Badge>
if (degraded) return <Badge variant="destructive-light" size="sm">degraded</Badge>
if (state === 'active') return <Badge variant="success-light" size="sm">active</Badge>
if (state === 'warm') return <Badge variant="outline" size="sm">warm</Badge>
return <Badge variant="secondary" size="sm">{state ?? '—'}</Badge>
}
export interface DcGridRow {
id: string
dc: number | string
rtt_ms?: number | null
alive_writers?: number | null
required_writers?: number | null
coverage_pct?: number | null
load?: number | null
endpoints: string[]
endpoint_writers: Array<{ endpoint?: string; active_writers?: number }>
}
export interface WriterGridRow {
id: string
endpoint?: string
dc?: number | null
state?: string
degraded?: boolean
draining?: boolean
rtt_ema_ms?: number | null
bound_clients?: number | null
generation?: number | null
writer_id?: string | number
}
export interface FamilyGridRow {
id: string
family?: string
state?: string
fail_streak?: number | null
recover_success_streak?: number | null
}
export interface TopConnGridRow {
id: string
username: string
current_connections: number
total_octets: number
}
/**
* Runtime / ME Pool grids.
* Preview: https://reui.io/preview/base/settings-14
* Docs: https://reui.io/docs/components/base/data-grid
*/
export function createDcColumns(): ColumnDef<DcGridRow, unknown>[] {
return [
{
id: 'dc',
accessorKey: 'dc',
header: ({ column }) => (
<DataGridColumnHeader title="DC" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="font-medium tabular-nums">
DC {formatNumber(row.original.dc)}
</span>
),
size: 80,
},
{
id: 'rtt',
accessorFn: (row) => row.rtt_ms ?? Number.POSITIVE_INFINITY,
header: ({ column }) => (
<DataGridColumnHeader title="RTT" visibility={false} column={column} />
),
cell: ({ row }) => (
<Badge variant={rttTone(row.original.rtt_ms ?? null)} size="sm">
{formatRttMs(row.original.rtt_ms)}
</Badge>
),
size: 100,
},
{
id: 'writers',
header: ({ column }) => (
<DataGridColumnHeader title="Writers" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatNumber(row.original.alive_writers)} /{' '}
{formatNumber(row.original.required_writers)}
</span>
),
size: 110,
},
{
id: 'coverage',
accessorFn: (row) => Number(row.coverage_pct ?? 0),
header: ({ column }) => (
<DataGridColumnHeader title="Coverage" visibility={false} column={column} />
),
cell: ({ row }) => {
const coverage = Number(row.original.coverage_pct ?? 0)
return (
<div className="flex min-w-28 flex-col gap-1">
<Progress
value={Math.min(100, Math.max(0, coverage))}
className={cn('h-1.5', progressToneClass(coverage))}
/>
<span className="text-muted-foreground text-[10px] tabular-nums">
{Number.isFinite(coverage) ? `${Math.round(coverage)}%` : '—'}
</span>
</div>
)
},
size: 120,
},
{
id: 'load',
accessorKey: 'load',
header: ({ column }) => (
<DataGridColumnHeader title="Load" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="tabular-nums">
{row.original.load != null ? formatNumber(row.original.load) : '—'}
</span>
),
size: 80,
},
{
id: 'endpoints',
header: ({ column }) => (
<DataGridColumnHeader title="Endpoints" visibility={false} column={column} />
),
cell: ({ row }) => {
const endpoints = row.original.endpoints
if (endpoints.length === 0) {
return <span className="text-muted-foreground"></span>
}
return (
<ul className="flex flex-col gap-1">
{endpoints.slice(0, 4).map((ep) => {
const writersOnEp = row.original.endpoint_writers.find(
(w) => w.endpoint === ep,
)
return (
<li
key={String(ep)}
className="flex flex-wrap items-center gap-2 font-mono text-xs"
>
<span>{ep}</span>
{writersOnEp?.active_writers != null ? (
<Badge variant="outline" size="sm">
{formatNumber(writersOnEp.active_writers)} w
</Badge>
) : null}
</li>
)
})}
{endpoints.length > 4 ? (
<li className="text-muted-foreground text-xs">
+{endpoints.length - 4} ещё
</li>
) : null}
</ul>
)
},
size: 220,
},
]
}
export function createWriterColumns(): ColumnDef<WriterGridRow, unknown>[] {
return [
{
id: 'endpoint',
accessorKey: 'endpoint',
header: ({ column }) => (
<DataGridColumnHeader title="Endpoint" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.endpoint ?? '—'}</span>
),
size: 180,
},
{
id: 'dc',
accessorKey: 'dc',
header: ({ column }) => (
<DataGridColumnHeader title="DC" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="tabular-nums">
{row.original.dc != null ? formatNumber(row.original.dc) : '—'}
</span>
),
size: 70,
},
{
id: 'state',
header: ({ column }) => (
<DataGridColumnHeader title="Состояние" visibility={false} column={column} />
),
cell: ({ row }) => (
<WriterStateBadge
state={row.original.state}
degraded={row.original.degraded}
draining={row.original.draining}
/>
),
size: 110,
},
{
id: 'rtt',
accessorFn: (row) => row.rtt_ema_ms ?? Number.POSITIVE_INFINITY,
header: ({ column }) => (
<DataGridColumnHeader title="RTT" visibility={false} column={column} />
),
cell: ({ row }) => (
<Badge variant={rttTone(row.original.rtt_ema_ms ?? null)} size="sm">
{formatRttMs(row.original.rtt_ema_ms)}
</Badge>
),
size: 100,
},
{
id: 'clients',
accessorKey: 'bound_clients',
header: ({ column }) => (
<DataGridColumnHeader title="Клиенты" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatNumber(row.original.bound_clients)}
</span>
),
size: 90,
},
{
id: 'gen',
accessorKey: 'generation',
header: ({ column }) => (
<DataGridColumnHeader title="Gen" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatNumber(row.original.generation)}
</span>
),
size: 70,
},
]
}
export function createFamilyColumns(): ColumnDef<FamilyGridRow, unknown>[] {
return [
{
id: 'family',
accessorKey: 'family',
header: ({ column }) => (
<DataGridColumnHeader title="Family" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="font-medium">{row.original.family ?? '—'}</span>
),
size: 100,
},
{
id: 'state',
accessorKey: 'state',
header: ({ column }) => (
<DataGridColumnHeader title="State" visibility={false} column={column} />
),
cell: ({ row }) => (
<Badge variant="outline" size="sm">
{row.original.state ?? '—'}
</Badge>
),
size: 100,
},
{
id: 'fail',
accessorKey: 'fail_streak',
header: ({ column }) => (
<DataGridColumnHeader title="Fail streak" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatNumber(row.original.fail_streak)}
</span>
),
size: 110,
},
{
id: 'recover',
accessorKey: 'recover_success_streak',
header: ({ column }) => (
<DataGridColumnHeader
title="Recover streak"
visibility={false}
column={column}
/>
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatNumber(row.original.recover_success_streak)}
</span>
),
size: 120,
},
]
}
export function createTopConnColumns(): ColumnDef<TopConnGridRow, unknown>[] {
return [
{
id: 'username',
accessorKey: 'username',
header: ({ column }) => (
<DataGridColumnHeader title="Пользователь" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="font-medium">{row.original.username}</span>
),
size: 180,
},
{
id: 'connections',
accessorKey: 'current_connections',
header: ({ column }) => (
<DataGridColumnHeader title="Соединения" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatNumber(row.original.current_connections)}
</span>
),
size: 120,
},
{
id: 'octets',
accessorKey: 'total_octets',
header: ({ column }) => (
<DataGridColumnHeader title="Октеты" visibility={false} column={column} />
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatNumber(row.original.total_octets)}
</span>
),
size: 120,
},
]
}
@@ -0,0 +1,134 @@
'use no memo'
import { useMemo } from 'react'
import { type ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
createFilter,
type FilterFieldConfig,
} from '@/components/reui/filters'
export interface ServerRow {
id: string
name: string
status: string
hostname?: string | null
lastSeenAt?: string | null
agentVersion?: string | null
mode?: string
}
function statusBadgeVariant(
status: string,
): 'success-light' | 'warning-light' | 'destructive-light' | 'secondary' | 'outline' {
const s = status.toLowerCase()
if (s === 'online' || s === 'active' || s === 'ok') return 'success-light'
if (s === 'degraded' || s === 'pending') return 'warning-light'
if (s === 'offline' || s === 'error' || s === 'failed') return 'destructive-light'
return 'outline'
}
/**
* Fleet servers columns.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Docs: https://reui.io/docs/components/base/data-grid
*/
export function createServersColumns(): ColumnDef<ServerRow, unknown>[] {
return [
{
id: 'name',
accessorKey: 'name',
header: ({ column }) => (
<DataGridColumnHeader title="Имя" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="font-medium">{row.original.name}</span>
),
enableSorting: true,
size: 160,
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }) => (
<DataGridColumnHeader title="Статус" visibility={true} column={column} />
),
cell: ({ row }) => (
<Badge variant={statusBadgeVariant(row.original.status)} size="sm">
{row.original.status}
</Badge>
),
enableSorting: true,
size: 120,
},
{
id: 'hostname',
accessorKey: 'hostname',
header: ({ column }) => (
<DataGridColumnHeader title="Hostname" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground font-mono text-xs">
{row.original.hostname ?? '—'}
</span>
),
enableSorting: true,
size: 180,
},
{
id: 'lastSeenAt',
accessorKey: 'lastSeenAt',
header: ({ column }) => (
<DataGridColumnHeader title="Last seen" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{row.original.lastSeenAt ?? '—'}
</span>
),
enableSorting: true,
size: 160,
},
]
}
export function useServersFilterFields(): FilterFieldConfig[] {
return useMemo(
() => [
{
key: 'name',
label: 'Имя',
type: 'text',
operators: [
{ value: 'contains', label: 'содержит' },
{ value: 'is', label: 'равно' },
],
},
{
key: 'status',
label: 'Статус',
type: 'select',
operators: [{ value: 'is', label: 'равно' }],
options: [
{ value: 'online', label: 'online' },
{ value: 'offline', label: 'offline' },
{ value: 'degraded', label: 'degraded' },
{ value: 'pending', label: 'pending' },
],
},
{
key: 'hostname',
label: 'Hostname',
type: 'text',
operators: [{ value: 'contains', label: 'содержит' }],
},
],
[],
)
}
export function createDefaultServerFilters() {
return [createFilter('name', 'contains', [''])]
}
@@ -15,6 +15,7 @@ import { toast } from 'sonner'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridTableRowPin } from '@/components/reui/data-grid/data-grid-table'
import { progressToneClass, statusDotClass } from '@/components/reui-kit/grid-tokens'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { cn } from '@telemt/ui/lib/utils'
import {
@@ -55,7 +56,7 @@ export function EnabledBadge({ enabled }: { enabled: boolean }) {
<span
className={cn(
'size-1.5 shrink-0 rounded-full!',
enabled ? 'bg-emerald-500' : 'bg-muted-foreground',
statusDotClass(enabled),
)}
/>
{enabled ? 'Вкл' : 'Выкл'}
@@ -69,12 +70,7 @@ function ConnectionsCell({ user }: { user: UserInfo }) {
typeof user.max_tcp_conns === 'number' && user.max_tcp_conns > 0
const cap = hasMax ? user.max_tcp_conns! : Math.max(current, 10)
const score = Math.min(100, Math.round((current / cap) * 100))
const indicatorClass =
score >= 75
? '**:data-[slot=progress-indicator]:bg-emerald-500'
: score >= 40
? '**:data-[slot=progress-indicator]:bg-amber-500'
: '**:data-[slot=progress-indicator]:bg-red-500'
const indicatorClass = progressToneClass(score)
const label = hasMax
? `${formatNumber(current)} / ${formatNumber(user.max_tcp_conns)}`
: `${formatNumber(current)} / ∞`
@@ -134,12 +130,9 @@ function QuotaCell({ user }: { user: UserInfo }) {
)
}
const pct = Math.min(100, Math.round((used / quota) * 100))
const indicatorClass =
pct >= 90
? '**:data-[slot=progress-indicator]:bg-red-500'
: pct >= 70
? '**:data-[slot=progress-indicator]:bg-amber-500'
: '**:data-[slot=progress-indicator]:bg-emerald-500'
const indicatorClass = progressToneClass(
pct >= 90 ? 20 : pct >= 70 ? 50 : 90,
)
return (
<div className="flex min-w-0 flex-col gap-1">
+144 -328
View File
@@ -1,52 +1,20 @@
'use no memo'
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react'
import { useCallback, useMemo, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type PaginationState,
type RowPinningState,
type SortingState,
type VisibilityState,
} from '@tanstack/react-table'
import {
CircleDotIcon,
FileDownIcon,
FilterIcon,
FilterXIcon,
MoreHorizontalIcon,
PinOffIcon,
SettingsIcon,
UserIcon,
UserPlusIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { VisibilityState } from '@tanstack/react-table'
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 { createFilter, type Filter } from '@/components/reui/filters'
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 { ResourcePage } from '@/components/reui-kit/resource-page'
import { UserDetailSheet } from '@/components/users/user-detail-sheet'
import { UserShareLinks } from '@/components/users/user-share-links'
import {
@@ -54,10 +22,6 @@ import {
useUsersFilterFields,
} from '@/components/users/users-columns'
import { Button } from '@telemt/ui/components/button'
import {
ButtonGroup,
ButtonGroupText,
} from '@telemt/ui/components/button-group'
import {
Dialog,
DialogContent,
@@ -76,7 +40,6 @@ import {
} from '@telemt/ui/components/dropdown-menu'
import { Field, FieldGroup, FieldLabel } from '@telemt/ui/components/field'
import { Input } from '@telemt/ui/components/input'
import { Separator } from '@telemt/ui/components/separator'
import { TooltipProvider } from '@telemt/ui/components/tooltip'
import { api, ApiError } from '@/lib/api-client'
import {
@@ -88,24 +51,15 @@ import {
type UserShareLink,
} from '@/lib/telemt'
function serializeActiveFiltersKey(active: Filter[]) {
return JSON.stringify(
active.map((f) => ({
field: f.field,
operator: f.operator,
values: f.values,
})),
)
}
function createDefaultUserFilters(): Filter[] {
return [createFilter('username', 'contains', [''])]
}
/**
* Users grid — 1:1 DNA of ReUI data-grid-base-2.
* Preview: https://reui.io/preview/base/data-grid-base-2
* Docs: https://reui.io/blocks
* Users grid — ResourcePage + data-grid-base-2 advanced DNA.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Advanced: https://reui.io/preview/base/data-grid-base-2
* Docs: https://reui.io/blocks · https://reui.io/docs/components/base/data-grid
*/
export function UsersGridView() {
const qc = useQueryClient()
@@ -116,14 +70,8 @@ export function UsersGridView() {
const [secret, setSecret] = useState('')
const [createdSecret, setCreatedSecret] = useState<string | null>(null)
const [createdLinks, setCreatedLinks] = useState<UserShareLink[]>([])
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'username', desc: false },
])
const [activeTab, setActiveTab] = useState('all')
const [filters, setFilters] = useState<Filter[]>(createDefaultUserFilters)
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
max_tcp_conns: false,
expiration_rfc3339: false,
@@ -131,11 +79,6 @@ export function UsersGridView() {
active_unique_ips: false,
total_octets: false,
})
const [rowPinning, setRowPinning] = useState<RowPinningState>({
top: [],
bottom: [],
})
const [filters, setFilters] = useState<Filter[]>(createDefaultUserFilters)
const usersQuery = useQuery({
queryKey: ['telemt', 'users'],
@@ -148,55 +91,18 @@ export function UsersGridView() {
[usersQuery.data],
)
const [filteredData, setFilteredData] = useState<UserInfo[]>([])
const isInitialLoad = useRef(true)
const lastAppliedActiveKey = useRef(
serializeActiveFiltersKey(getActiveFilters(createDefaultUserFilters())),
)
const resolvedRowPinning = useMemo(() => {
const availableIds = new Set(filteredData.map((u) => u.username))
return {
top: (rowPinning.top ?? []).filter((id) => availableIds.has(id)),
bottom: (rowPinning.bottom ?? []).filter((id) => availableIds.has(id)),
}
}, [filteredData, rowPinning.bottom, rowPinning.top])
const filterFields = useUsersFilterFields()
const applyFilters = useCallback(
(source: UserInfo[], nextFilters: Filter[]) => {
return applyFiltersToData(source, nextFilters, (item, field) => {
if (field === 'enabled') return item.enabled === false ? 'off' : 'on'
if (field === 'ip') {
return [
...(item.active_unique_ips_list ?? []),
...(item.recent_unique_ips_list ?? []),
].join(' ')
}
return (item as unknown as Record<string, unknown>)[field]
})
},
[],
)
const handleFiltersChange = useCallback(
(newFilters: Filter[]) => {
setFilters(newFilters)
const newActive = getActiveFilters(newFilters)
const nextKey = serializeActiveFiltersKey(newActive)
if (nextKey === lastAppliedActiveKey.current) return
lastAppliedActiveKey.current = nextKey
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
setFilteredData(applyFilters(rows, newFilters))
},
[applyFilters, rows],
)
useEffect(() => {
setFilteredData(applyFilters(rows, filters))
if (isInitialLoad.current) isInitialLoad.current = false
}, [rows, filters, applyFilters])
const getFilterFieldValue = useCallback((item: UserInfo, field: string) => {
if (field === 'enabled') return item.enabled === false ? 'off' : 'on'
if (field === 'ip') {
return [
...(item.active_unique_ips_list ?? []),
...(item.recent_unique_ips_list ?? []),
].join(' ')
}
return (item as unknown as Record<string, unknown>)[field]
}, [])
const handleOpen = useCallback((user: UserInfo) => {
setSelectedUsername(user.username)
@@ -227,35 +133,6 @@ export function UsersGridView() {
[handleOpen, removeUser],
)
const [columnOrder, setColumnOrder] = useState<string[]>(() =>
columns.map((c) => c.id as string),
)
const table = useReactTable({
columns,
data: filteredData,
pageCount: Math.ceil(filteredData.length / pagination.pageSize) || 1,
getRowId: (row) => row.username,
state: {
pagination,
sorting,
columnOrder,
columnVisibility,
rowPinning: resolvedRowPinning,
},
enableRowPinning: true,
keepPinnedRows: true,
onColumnOrderChange: setColumnOrder,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onSortingChange: setSorting,
onRowPinningChange: setRowPinning,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const create = useMutation({
mutationFn: async () => {
const body: { username: string; secret?: string } = {
@@ -319,198 +196,137 @@ export function UsersGridView() {
}
}
const showClearButton = filters.length > 0
const showCreated = Boolean(createdSecret) || createdLinks.length > 0
const handleClearFilters = useCallback(() => {
setFilters(createDefaultUserFilters())
}, [])
const handleExportCsv = useCallback(() => {
const lines = [
'username,enabled,connections,active_ips,traffic',
...rows.map(
(u) =>
`${u.username},${u.enabled !== false},${u.current_connections ?? 0},${u.active_unique_ips ?? 0},${u.total_octets ?? 0}`,
),
]
const blob = new Blob([lines.join('\n')], {
type: 'text/csv;charset=utf-8',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'telemt-users.csv'
a.click()
URL.revokeObjectURL(url)
toast.success('CSV экспортирован')
}, [rows])
const tabs = useMemo(
() => [
{ id: 'all', label: 'Все' },
{ id: 'on', label: 'Вкл' },
{ id: 'off', label: 'Выкл' },
],
[],
)
const tabFilter = useCallback((item: UserInfo, tabId: string) => {
if (tabId === 'on') return item.enabled !== false
if (tabId === 'off') return item.enabled === false
return true
}, [])
return (
<>
<TooltipProvider delay={200}>
<DataGrid
table={table}
isLoading={usersQuery.isLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
onRowClick={(row) => handleOpen(row)}
emptyMessage={
!usersQuery.isLoading && filteredData.length === 0
? 'Нет пользователей по фильтрам. Сбросьте фильтры или создайте аккаунт.'
: undefined
<ResourcePage
title="Пользователи"
description={
usersQuery.isLoading
? 'Загрузка…'
: `${rows.length} аккаунтов Telemt`
}
tableLayout={{
rowsPinnable: true,
columnsPinnable: true,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
dense: true,
columns={columns}
data={rows}
getRowId={(row) => row.username}
isLoading={usersQuery.isLoading}
isError={usersQuery.isError}
error={
usersQuery.error instanceof Error
? usersQuery.error
: usersQuery.error
? new Error(String(usersQuery.error))
: null
}
onRetry={() => void usersQuery.refetch()}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={
getActiveFilters(filters).length > 0 ? handleClearFilters : undefined
}
getFilterFieldValue={getFilterFieldValue}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={tabFilter}
onRowClick={handleOpen}
enableRowPinning
enableColumnVisibility
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
pinLastColumn
pageSize={5}
initialSorting={[{ id: 'username', desc: false }]}
emptyState={{
title: 'Нет пользователей',
description: 'Создайте первый аккаунт Telemt.',
}}
>
<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 id="page-heading" className="text-balance">
Пользователи
</FrameTitle>
<FrameDescription className="text-xs text-pretty">
{usersQuery.isLoading
? 'Загрузка…'
: `${rows.length} аккаунтов Telemt`}
</FrameDescription>
</div>
<Button type="button" size="default" onClick={() => setCreateOpen(true)}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
</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={handleFiltersChange}
size="default"
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Фильтры"
>
<FilterIcon aria-hidden />
Filters
</Button>
}
/>
<div className="flex flex-wrap items-center gap-2">
{(resolvedRowPinning.top?.length ?? 0) > 0 && (
<ButtonGroup>
<Button
type="button"
size="default"
variant="outline"
onClick={() => setRowPinning({ top: [], bottom: [] })}
>
<PinOffIcon aria-hidden />
Unpin all
</Button>
<ButtonGroupText className="text-muted-foreground px-3 text-sm">
{resolvedRowPinning.top?.length}{' '}
{resolvedRowPinning.top?.length === 1
? 'user'
: 'users'}{' '}
pinned
</ButtonGroupText>
</ButtonGroup>
)}
{showClearButton && (
<Button
type="button"
size="default"
variant="outline"
className="shrink-0"
onClick={() => {
const next = createDefaultUserFilters()
lastAppliedActiveKey.current = serializeActiveFiltersKey(
getActiveFilters(next),
)
setFilters(next)
setFilteredData(applyFilters(rows, next))
}}
disabled={usersQuery.isLoading}
>
<FilterXIcon aria-hidden />
Clear
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="default"
variant="outline"
aria-label="Действия таблицы"
>
<MoreHorizontalIcon aria-hidden="true" />
Actions
</Button>
}
/>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
const lines = [
'username,enabled,connections,active_ips,traffic',
...filteredData.map(
(u) =>
`${u.username},${u.enabled !== false},${u.current_connections ?? 0},${u.active_unique_ips ?? 0},${u.total_octets ?? 0}`,
),
]
const blob = new Blob([lines.join('\n')], {
type: 'text/csv;charset=utf-8',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'telemt-users.csv'
a.click()
URL.revokeObjectURL(url)
toast.success('CSV экспортирован')
}}
>
<FileDownIcon aria-hidden="true" />
Export CSV
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.message('Фильтр по статусу', {
description: 'Используйте Filters → Статус.',
})
}
>
<CircleDotIcon aria-hidden="true" />
По статусу
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
toast.info('Настройки колонок', {
description:
'В заголовках таблицы можно показать или скрыть поля.',
})
}
>
<SettingsIcon aria-hidden="true" />
Column settings
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Separator />
{usersQuery.isError ? (
<div className="flex flex-col items-center gap-2 px-6 py-10 text-center">
<UserIcon className="text-muted-foreground size-8" aria-hidden />
<p className="text-destructive text-sm">
Не удалось загрузить пользователей.
</p>
</div>
) : (
<>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</>
)}
</FramePanel>
</Frame>
</DataGrid>
emptyMessage="Нет пользователей по фильтрам. Сбросьте фильтры или создайте аккаунт."
primaryAction={
<Button type="button" size="default" onClick={() => setCreateOpen(true)}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
}
toolbarExtra={
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="default"
variant="outline"
aria-label="Действия таблицы"
>
<MoreHorizontalIcon aria-hidden="true" />
Действия
</Button>
}
/>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleExportCsv}>
<FileDownIcon aria-hidden="true" />
Экспорт CSV
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
toast.info('Настройки колонок', {
description:
'В заголовках таблицы можно показать или скрыть поля.',
})
}
>
Настройки колонок
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
}
/>
</TooltipProvider>
<UserDetailSheet
+22 -37
View File
@@ -1,7 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { UsersIcon } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { useMemo, useState, type FormEvent } from 'react'
import {
Frame,
@@ -11,22 +10,20 @@ import {
FrameDescription,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { FrameDataGrid } from '@/components/reui-kit/frame-data-grid'
import { Button } from '@telemt/ui/components/button'
import { Input } from '@telemt/ui/components/input'
import { api } from '@/lib/api-client'
import {
createClientsColumns,
type ClientRow,
} from '@/components/clients/clients-columns'
/** Managed clients — fleet catalog; @see https://reui.io/preview/base/form-7 */
/** Managed clients — @see https://reui.io/preview/base/data-grid-base-1 */
export const Route = createFileRoute('/clients')({
component: ClientsPage,
})
interface ClientRow {
id: string
username: string
createdAt: string
}
function ClientsPage() {
const qc = useQueryClient()
const [username, setUsername] = useState('')
@@ -59,6 +56,7 @@ function ClientsPage() {
}
const rows = clients.data ?? []
const columns = useMemo(() => createClientsColumns(), [])
return (
<div className="flex flex-col gap-4 md:gap-6">
@@ -73,7 +71,7 @@ function ClientsPage() {
В standalone пользователь сразу создаётся на локальном Telemt.
</FrameDescription>
</FrameHeader>
<FramePanel>
<FramePanel className="flex flex-col gap-3">
<form onSubmit={handleSubmit} className="flex flex-wrap items-end gap-3">
<div className="flex min-w-40 flex-1 flex-col gap-1.5">
<label className="text-muted-foreground text-xs" htmlFor="client-user">
@@ -101,35 +99,22 @@ function ClientsPage() {
</Button>
</form>
{create.isError ? (
<p className="text-destructive mt-3 text-sm">{create.error.message}</p>
<p className="text-destructive text-sm">{create.error.message}</p>
) : null}
</FramePanel>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Каталог</FrameTitle>
<FrameDescription>Локальная таблица managed_clients.</FrameDescription>
</FrameHeader>
<FramePanel>
{rows.length === 0 ? (
<EmptyState
icon={UsersIcon}
title="Пока нет клиентов"
description="Создайте запись выше."
centered={false}
/>
) : (
<ul className="flex flex-col gap-2 text-sm">
{rows.map((c) => (
<li key={c.id} className="flex items-center justify-between border-b py-2">
<span className="font-medium">{c.username}</span>
<span className="text-muted-foreground text-xs">{c.createdAt}</span>
</li>
))}
</ul>
)}
</FramePanel>
</Frame>
<FrameDataGrid
title="Каталог"
description="Локальная таблица managed_clients."
columns={columns}
data={rows}
rowId={(row) => row.id}
emptyTitle="Пока нет клиентов"
emptyDescription="Создайте запись выше."
pageSize={10}
dense
/>
</div>
)
}
+96 -56
View File
@@ -1,9 +1,15 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { PageHeader } from '@/components/page-header'
import { MetricListFrame, MetricRow, StatusBadge } from '@/components/metric-list'
import { MePoolPanel } from '@/components/runtime/me-pool-panel'
import {
createTopConnColumns,
type TopConnGridRow,
} from '@/components/runtime/runtime-grid-columns'
import { FrameDataGrid } from '@/components/reui-kit/frame-data-grid'
import { Badge } from '@/components/reui/badge'
import { api } from '@/lib/api-client'
import {
@@ -19,6 +25,7 @@ import {
/**
* Runtime — gates, connections, ME pool/quality.
* ME: https://reui.io/preview/base/stats-7 · https://reui.io/blocks
* Top connections grid: https://reui.io/docs/components/base/data-grid
* Frame: https://reui.io/docs/components/base/frame
*/
export const Route = createFileRoute('/runtime')({
@@ -72,17 +79,39 @@ function RuntimePage() {
const conn = asRecord(unwrapData(connSummary.data) ?? connSummary.data)
const connPayload = asRecord(conn.data ?? conn)
const totals = asRecord(connPayload.totals)
const topByConn = Array.isArray((connPayload.top as { by_connections?: unknown })?.by_connections)
? ((connPayload.top as { by_connections: Array<Record<string, unknown>> }).by_connections)
const topByConn = Array.isArray(
(connPayload.top as { by_connections?: unknown })?.by_connections,
)
? (
connPayload.top as {
by_connections: Array<Record<string, unknown>>
}
).by_connections
: []
const topRows: TopConnGridRow[] = useMemo(
() =>
topByConn.slice(0, 10).map((row) => ({
id: String(row.username),
username: String(row.username),
current_connections: Number(row.current_connections ?? 0),
total_octets: Number(row.total_octets ?? 0),
})),
[topByConn],
)
const topColumns = useMemo(() => createTopConnColumns(), [])
const eventPayload = asRecord(unwrapData(events.data) ?? events.data)
const eventList = Array.isArray(eventPayload.events)
? (eventPayload.events as Array<Record<string, unknown>>)
: Array.isArray(eventPayload.data)
? (eventPayload.data as Array<Record<string, unknown>>)
: Array.isArray((eventPayload.data as { events?: unknown })?.events)
? ((eventPayload.data as { events: Array<Record<string, unknown>> }).events)
? (
eventPayload.data as {
events: Array<Record<string, unknown>>
}
).events
: []
const accepting = gateData.accepting_new_connections === true
@@ -102,7 +131,13 @@ function RuntimePage() {
<MetricListFrame
title="Шлюзы"
description="Admission и ME"
trailing={<StatusBadge ok={accepting} okLabel="Приём открыт" failLabel="Приём закрыт" />}
trailing={
<StatusBadge
ok={accepting}
okLabel="Приём открыт"
failLabel="Приём закрыт"
/>
}
>
<MetricRow
label="Новые соединения"
@@ -111,17 +146,30 @@ function RuntimePage() {
<MetricRow
label="ME runtime"
value={
<Badge variant={meReady ? 'success-light' : 'warning-light'} size="sm">
{String(gateData.me_runtime_ready ?? pool.status ?? (meReady ? 'ready' : '—'))}
<Badge
variant={meReady ? 'success-light' : 'warning-light'}
size="sm"
>
{String(
gateData.me_runtime_ready ??
pool.status ??
(meReady ? 'ready' : '—'),
)}
</Badge>
}
/>
<MetricRow label="Режим маршрута" value={String(gateData.route_mode ?? '—')} />
<MetricRow
label="Режим маршрута"
value={String(gateData.route_mode ?? '—')}
/>
<MetricRow
label="Reroute active"
value={gateData.reroute_active === true ? 'Да' : 'Нет'}
/>
<MetricRow label="Startup" value={String(gateData.startup_status ?? '—')} />
<MetricRow
label="Startup"
value={String(gateData.startup_status ?? '—')}
/>
</MetricListFrame>
<MetricListFrame title="Соединения" description="Текущая нагрузка">
@@ -129,12 +177,18 @@ function RuntimePage() {
label="Сейчас"
value={formatNumber(totals.current_connections)}
/>
<MetricRow label="Через ME" value={formatNumber(totals.current_connections_me)} />
<MetricRow
label="Через ME"
value={formatNumber(totals.current_connections_me)}
/>
<MetricRow
label="Direct"
value={formatNumber(totals.current_connections_direct)}
/>
<MetricRow label="Активные пользователи" value={formatNumber(totals.active_users)} />
<MetricRow
label="Активные пользователи"
value={formatNumber(totals.active_users)}
/>
</MetricListFrame>
</div>
@@ -152,38 +206,17 @@ function RuntimePage() {
/>
<div className="grid gap-4 md:grid-cols-2 md:gap-6">
<MetricListFrame
<FrameDataGrid
title="Топ по соединениям"
description="Пользователи с наибольшим числом сессий"
className="md:col-span-2"
>
{topByConn.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет активных пользователей</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-4 font-medium">Пользователь</th>
<th className="py-2 pr-4 font-medium">Соединения</th>
<th className="py-2 font-medium">Октеты</th>
</tr>
</thead>
<tbody>
{topByConn.slice(0, 10).map((row) => (
<tr key={String(row.username)} className="border-b border-border/50">
<td className="py-2 pr-4 font-medium">{String(row.username)}</td>
<td className="py-2 pr-4 tabular-nums">
{formatNumber(row.current_connections)}
</td>
<td className="py-2 tabular-nums">{formatNumber(row.total_octets)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</MetricListFrame>
columns={topColumns}
data={topRows}
rowId={(row) => row.id}
emptyTitle="Нет активных пользователей"
pagination={false}
dense
/>
<MetricListFrame
title="Недавние события"
@@ -194,24 +227,31 @@ function RuntimePage() {
<p className="text-muted-foreground text-sm">Событий пока нет</p>
) : (
<div className="flex flex-col gap-2">
{eventList.slice(-12).reverse().map((ev, i) => (
<div
key={String(ev.seq ?? i)}
className="flex flex-col gap-0.5 border-b border-border/50 py-2 last:border-b-0"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{String(ev.event_type ?? 'event')}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{ev.ts_epoch_secs
? new Date(Number(ev.ts_epoch_secs) * 1000).toLocaleString('ru-RU')
: '—'}
</span>
{eventList
.slice(-12)
.reverse()
.map((ev, i) => (
<div
key={String(ev.seq ?? i)}
className="flex flex-col gap-0.5 border-b border-border/50 py-2 last:border-b-0"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">
{String(ev.event_type ?? 'event')}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{ev.ts_epoch_secs
? new Date(
Number(ev.ts_epoch_secs) * 1000,
).toLocaleString('ru-RU')
: '—'}
</span>
</div>
<p className="text-muted-foreground text-xs break-all">
{String(ev.context ?? '')}
</p>
</div>
<p className="text-muted-foreground text-xs break-all">
{String(ev.context ?? '')}
</p>
</div>
))}
))}
</div>
)}
</MetricListFrame>
+48 -62
View File
@@ -1,7 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ServerIcon } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { useCallback, useMemo, useState, type FormEvent } from 'react'
import {
Frame,
@@ -11,31 +10,29 @@ import {
FrameDescription,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { ResourcePage } from '@/components/reui-kit/resource-page'
import { getActiveFilters } from '@/components/reui-kit/filter-utils'
import type { Filter } from '@/components/reui/filters'
import { Button } from '@telemt/ui/components/button'
import { Input } from '@telemt/ui/components/input'
import { Badge } from '@/components/reui/badge'
import { api } from '@/lib/api-client'
import {
createDefaultServerFilters,
createServersColumns,
useServersFilterFields,
type ServerRow,
} from '@/components/servers/servers-columns'
/** Fleet servers — @see https://reui.io/preview/base/empty-state-12 */
/** Fleet servers — @see https://reui.io/preview/base/data-grid-filtering-2 */
export const Route = createFileRoute('/servers')({
component: ServersPage,
})
interface ServerRow {
id: string
name: string
status: string
hostname?: string | null
lastSeenAt?: string | null
agentVersion?: string | null
mode?: string
}
function ServersPage() {
const qc = useQueryClient()
const [label, setLabel] = useState('node')
const [installCmd, setInstallCmd] = useState<string | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultServerFilters)
const servers = useQuery({
queryKey: ['servers'],
@@ -61,6 +58,17 @@ function ServersPage() {
}
const rows = servers.data ?? []
const columns = useMemo(() => createServersColumns(), [])
const filterFields = useServersFilterFields()
const getFilterFieldValue = useCallback((item: ServerRow, field: string) => {
if (field === 'status') return item.status.toLowerCase()
return (item as unknown as Record<string, unknown>)[field]
}, [])
const handleClearFilters = useCallback(() => {
setFilters(createDefaultServerFilters())
}, [])
return (
<div className="flex flex-col gap-4 md:gap-6">
@@ -101,53 +109,31 @@ function ServersPage() {
</FramePanel>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Список серверов</FrameTitle>
<FrameDescription>Агенты, подключённые к control plane.</FrameDescription>
</FrameHeader>
<FramePanel>
{servers.isLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : rows.length === 0 ? (
<EmptyState
icon={ServerIcon}
title="Пока нет серверов"
description="Создайте enrollment-токен и запустите install-agent.sh на ноде."
centered={false}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-4">Имя</th>
<th className="py-2 pr-4">Статус</th>
<th className="py-2 pr-4">Hostname</th>
<th className="py-2">Last seen</th>
</tr>
</thead>
<tbody>
{rows.map((s) => (
<tr key={s.id} className="border-b border-border/50">
<td className="py-2 pr-4 font-medium">{s.name}</td>
<td className="py-2 pr-4">
<Badge variant="outline" size="sm">
{s.status}
</Badge>
</td>
<td className="py-2 pr-4">{s.hostname ?? '—'}</td>
<td className="py-2 text-muted-foreground text-xs">
{s.lastSeenAt ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</FramePanel>
</Frame>
<ResourcePage
title="Список серверов"
description="Агенты, подключённые к control plane."
columns={columns}
data={rows}
getRowId={(row) => row.id}
isLoading={servers.isLoading}
isError={servers.isError}
error={servers.error}
onRetry={() => void servers.refetch()}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={
getActiveFilters(filters).length > 0 ? handleClearFilters : undefined
}
getFilterFieldValue={getFilterFieldValue}
emptyState={{
title: 'Пока нет серверов',
description:
'Создайте enrollment-токен и запустите install-agent.sh на ноде.',
}}
pageSize={10}
dense
/>
</div>
)
}