From 3c065e8de13da5de89a58ff23b7dbe8be7bb83ee Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 4 Aug 2026 21:58:20 +0700 Subject: [PATCH] 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. --- .../components/clients/clients-columns.tsx | 47 ++ .../components/errors/errors-grid-view.tsx | 432 ++++------------ .../components/errors/errors-log-columns.tsx | 129 +++++ .../components/reui-kit/frame-data-grid.tsx | 81 ++- .../src/components/reui-kit/grid-tokens.ts | 33 ++ apps/web/src/components/reui-kit/index.ts | 5 + .../src/components/reui-kit/resource-page.tsx | 206 ++++++-- .../src/components/runtime/me-pool-panel.tsx | 371 +++++--------- .../runtime/runtime-grid-columns.tsx | 385 ++++++++++++++ .../components/servers/servers-columns.tsx | 134 +++++ .../src/components/users/users-columns.tsx | 19 +- .../src/components/users/users-grid-view.tsx | 472 ++++++------------ apps/web/src/routes/clients.tsx | 59 +-- apps/web/src/routes/runtime.tsx | 152 +++--- apps/web/src/routes/servers.tsx | 110 ++-- 15 files changed, 1522 insertions(+), 1113 deletions(-) create mode 100644 apps/web/src/components/clients/clients-columns.tsx create mode 100644 apps/web/src/components/errors/errors-log-columns.tsx create mode 100644 apps/web/src/components/reui-kit/grid-tokens.ts create mode 100644 apps/web/src/components/runtime/runtime-grid-columns.tsx create mode 100644 apps/web/src/components/servers/servers-columns.tsx diff --git a/apps/web/src/components/clients/clients-columns.tsx b/apps/web/src/components/clients/clients-columns.tsx new file mode 100644 index 0000000..433e53b --- /dev/null +++ b/apps/web/src/components/clients/clients-columns.tsx @@ -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[] { + return [ + { + id: 'username', + accessorKey: 'username', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.username} + ), + enableSorting: true, + size: 200, + }, + { + id: 'createdAt', + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.createdAt} + + ), + enableSorting: true, + size: 200, + }, + ] +} diff --git a/apps/web/src/components/errors/errors-grid-view.tsx b/apps/web/src/components/errors/errors-grid-view.tsx index 824057b..53b894d 100644 --- a/apps/web/src/components/errors/errors-grid-view.tsx +++ b/apps/web/src/components/errors/errors-grid-view.tsx @@ -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[] { - return [ - { - id: 'tsEpoch', - accessorKey: 'tsEpoch', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.tsEpoch != null ? formatEpoch(row.original.tsEpoch) : '—'} - - ), - size: 150, - }, - { - id: 'eventType', - accessorKey: 'eventType', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.eventType} - - ), - size: 180, - }, - { - id: 'ips', - accessorFn: (row) => extractIpsFromText(row.context).join(' '), - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const ips = extractIpsFromText(row.original.context) - if (ips.length === 0) { - return - } - return ( -
- {ips.slice(0, 3).map((ip) => ( - - {ip} - - ))} -
- ) - }, - size: 180, - }, - { - id: 'context', - accessorKey: 'context', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.context || '—'} - - ), - minSize: 280, - }, - ] +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(null) const [sheetOpen, setSheetOpen] = useState(false) - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 10, - }) - const [logPagination, setLogPagination] = useState({ - pageIndex: 0, - pageSize: 10, - }) - const [sorting, setSorting] = useState([ - { id: 'total', desc: true }, - ]) - const [logSorting, setLogSorting] = useState([ - { id: 'tsEpoch', desc: true }, - ]) const [filters, setFilters] = useState(createDefaultFilters) - const [logFilters, setLogFilters] = useState(() => [ - createFilter('eventType', 'contains', ['']), - ]) + const [logFilters, setLogFilters] = useState(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)[field] - }), - [rows, filters], - ) - - const filteredLogs = useMemo( - () => - applyFiltersToData(logRows, logFilters, (item, field) => { - if (field === 'ips') return extractIpsFromText(item.context).join(' ') - return (item as unknown as Record)[field] - }), - [logRows, logFilters], - ) - - useEffect(() => { - setPagination((p) => ({ ...p, pageIndex: 0 })) - }, [filters]) - - useEffect(() => { - setLogPagination((p) => ({ ...p, pageIndex: 0 })) - }, [logFilters]) - const handleOpen = useCallback((row: TelemtErrorRow) => { setSelected(row) setSheetOpen(true) @@ -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)[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)[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 ( <>
- 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={ + + } tableLayout={{ + dense: true, columnsResizable: true, columnsMovable: true, columnsVisibility: true, - dense: true, + rowsPinnable: false, }} - > - - -
- Ошибки соединений - - Классы отказов из stats/summary + IP из events и TLS fingerprints ·{' '} - {formatNumberSafe(data.connections_bad_total)} bad total - -
- -
- -
- - - Filters - - } - /> - {activeFilters.length > 0 ? ( - - ) : null} -
- - - - - - - - -
- -
+ /> - 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, }} - > - - -
- Журнал events - - Сырые runtime-логи API · IP извлекаются из context - -
-
- -
- - - Filters - - } - /> - {getActiveFilters(logFilters).length > 0 ? ( - - ) : null} -
- - - - - - - - -
- -
+ />
@@ -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) -} diff --git a/apps/web/src/components/errors/errors-log-columns.tsx b/apps/web/src/components/errors/errors-log-columns.tsx new file mode 100644 index 0000000..3df3d52 --- /dev/null +++ b/apps/web/src/components/errors/errors-log-columns.tsx @@ -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[] { + return [ + { + id: 'tsEpoch', + accessorKey: 'tsEpoch', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.tsEpoch != null + ? formatEpoch(row.original.tsEpoch) + : '—'} + + ), + size: 150, + }, + { + id: 'eventType', + accessorKey: 'eventType', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.eventType} + + ), + size: 180, + }, + { + id: 'ips', + accessorFn: (row) => extractIpsFromText(row.context).join(' '), + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const ips = extractIpsFromText(row.original.context) + if (ips.length === 0) { + return + } + return ( +
+ {ips.slice(0, 3).map((ip) => ( + + {ip} + + ))} +
+ ) + }, + size: 180, + }, + { + id: 'context', + accessorKey: 'context', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.context || '—'} + + ), + minSize: 280, + }, + ] +} + +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…', + }, + ], + [], + ) +} diff --git a/apps/web/src/components/reui-kit/frame-data-grid.tsx b/apps/web/src/components/reui-kit/frame-data-grid.tsx index 9ef0071..1b04334 100644 --- a/apps/web/src/components/reui-kit/frame-data-grid.tsx +++ b/apps/web/src/components/reui-kit/frame-data-grid.tsx @@ -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 { 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({ footerContent, showPagination, enableColumnVisibility, + tableLayout, }: { table: ReturnType> data: TData[] @@ -183,6 +199,7 @@ function FrameDataGridBody({ footerContent?: ReactNode showPagination: boolean enableColumnVisibility: boolean + tableLayout?: FrameDataGridProps['tableLayout'] }) { return ( ({ 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({ columnVisibilityTrigger, columnVisibilityStorageKey, initialColumnVisibility, + plain = false, + tableLayout, className, }: FrameDataGridProps) { const [sorting, setSorting] = useState(initialSorting ?? []) @@ -365,13 +387,23 @@ export function FrameDataGrid({ const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger) if (data.length === 0) { + const emptyBody = ( + + ) + if (plain) { + return ( +
+ {emptyBody} +
+ ) + } return ( {hasHeader ? ( ) : null} - + {emptyBody} ) @@ -389,9 +421,14 @@ export function FrameDataGrid({ footerContent={footerContent} showPagination={showPagination} enableColumnVisibility={enableColumnVisibility} + tableLayout={tableLayout} /> ) + if (plain) { + return
{gridBody}
+ } + return ( {hasHeader ? ( diff --git a/apps/web/src/components/reui-kit/grid-tokens.ts b/apps/web/src/components/reui-kit/grid-tokens.ts new file mode 100644 index 0000000..44f0026 --- /dev/null +++ b/apps/web/src/components/reui-kit/grid-tokens.ts @@ -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' +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index f112821..1f47034 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -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, diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index 8d16ca2..1506f6f 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -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['tableLayout']> + type SimpleGridPassthrough = Pick< FrameDataGridProps, | 'onRowClick' @@ -67,6 +74,8 @@ type SimpleGridPassthrough = Pick< | 'columnVisibilityTrigger' | 'columnVisibilityStorageKey' | 'initialColumnVisibility' + | 'tableLayout' + | 'plain' | 'className' > @@ -93,6 +102,7 @@ export interface ResourcePageProps 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 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({ title = '', description, @@ -191,10 +212,14 @@ function ResourcePageSimple({ columnVisibilityTrigger, columnVisibilityStorageKey, initialColumnVisibility, + tableLayout, + plain, className, }: ResourcePageProps) { - if (isLoading) return - if (isError) return + if (isLoading && data.length === 0) return + if (isError && data.length === 0) { + return + } const headerActions = primaryAction ?? actions const resolvedEmpty = emptyState ?? ( @@ -235,6 +260,8 @@ function ResourcePageSimple({ columnVisibilityTrigger={columnVisibilityTrigger} columnVisibilityStorageKey={columnVisibilityStorageKey} initialColumnVisibility={initialColumnVisibility} + tableLayout={tableLayout} + plain={plain} className={className} /> @@ -263,24 +290,65 @@ function ResourcePageFiltered({ 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) { 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([]) + const [sorting, setSorting] = useState(initialSorting ?? []) const [rowSelection, setRowSelection] = useState({}) + const [rowPinning, setRowPinning] = useState({ + top: [], + bottom: [], + }) + const [columnOrder, setColumnOrder] = useState(() => + columns.map((c) => c.id as string).filter(Boolean), + ) + const [internalColumnVisibility, setInternalColumnVisibility] = + useState(() => initialColumnVisibility ?? {}) const [pagination, setPagination] = useState({ pageIndex: 0, pageSize, }) + const isColumnVisibilityControlled = columnVisibilityProp !== undefined + const columnVisibility = isColumnVisibilityControlled + ? columnVisibilityProp + : internalColumnVisibility + const setColumnVisibility: OnChangeFn = + 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({ 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({ 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({ [tabs, tabCounts], ) - if (isLoading) return - if (isError) return + const resolvedEmpty = emptyState ?? ( + emptyTitle || emptyDescription || emptyAction + ? { + title: emptyTitle ?? 'Нет записей', + description: emptyDescription, + action: emptyAction, + } + : undefined + ) - if (data.length === 0 && emptyState) { + if (isLoading && data.length === 0) return + if (isError && data.length === 0) { + return + } + + if (!isLoading && data.length === 0 && resolvedEmpty) { return ( ) } + const pinnedCount = resolvedRowPinning.top?.length ?? 0 + return ( -
+
{selectionToolbar && selectedCount > 0 ? selectionToolbar({ selectedIds, @@ -398,8 +512,14 @@ function ResourcePageFiltered({ {!hideHeader ? ( @@ -425,6 +545,17 @@ function ResourcePageFiltered({ {selectedCount} выбрано ) : null} + {pinnedCount > 0 ? ( + <> +
@@ -469,6 +600,15 @@ function ResourcePageFiltered({ )}
{toolbarExtra} + {enableRowPinning && pinnedCount > 0 ? ( + + ) : null} {selectedCount > 0 ? ( {selectedCount} выбрано @@ -483,23 +623,28 @@ function ResourcePageFiltered({
- {(showFilters || toolbarExtra || selectedCount > 0) ? : null} + {(showFilters || toolbarExtra || selectedCount > 0 || pinnedCount > 0) ? ( + + ) : null} - - - - - - - - - + {isError ? ( +
+ +
+ ) : ( + <> + + + + + + + + + )}
@@ -511,6 +656,7 @@ function ResourcePageFiltered({ * 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(props: ResourcePageProps) { const showFilters = (props.filterFields?.length ?? 0) > 0 diff --git a/apps/web/src/components/runtime/me-pool-panel.tsx b/apps/web/src/components/runtime/me-pool-panel.tsx index c216ce2..fa35806 100644 --- a/apps/web/src/components/runtime/me-pool-panel.tsx +++ b/apps/web/src/components/runtime/me-pool-panel.tsx @@ -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 draining - if (degraded) return degraded - if (state === 'active') return active - if (state === 'warm') return warm - return {state ?? '—'} -} - /** - * 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({ - {/* 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 */} } > - {dcRows.length === 0 ? ( -

- {dcs.reason ?? - 'Нет данных /v1/stats/dcs (нужен minimal_runtime_enabled) и нет dc_rtt из me_quality.'} -

- ) : ( -
- - - - - - - - - - - - - {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 ( - - - - - - - - - ) - })} - -
DCRTTWritersCoverageLoadEndpoints
- DC {formatNumber(row.dc)} - - - {formatRttMs(row.rtt_ms)} - - - {formatNumber(row.alive_writers)} / {formatNumber(row.required_writers)} - -
- - - {Number.isFinite(coverage) ? `${Math.round(coverage)}%` : '—'} - -
-
- {row.load != null ? formatNumber(row.load) : '—'} - - {endpoints.length === 0 ? ( - - ) : ( -
    - {endpoints.slice(0, 4).map((ep) => { - const writersOnEp = (row.endpoint_writers ?? []).find( - (w) => w.endpoint === ep, - ) - return ( -
  • - {ep} - {writersOnEp?.active_writers != null ? ( - - {formatNumber(writersOnEp.active_writers)} w - - ) : null} -
  • - ) - })} - {endpoints.length > 4 ? ( -
  • - +{endpoints.length - 4} ещё -
  • - ) : null} -
- )} -
-
- )} + 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 + />
{/* Per-writer / endpoint latency */} @@ -303,104 +257,49 @@ export function MePoolPanel({ trailing={ 0} - okLabel={`${writerRows.length} writers`} + okLabel={`${writerTotal} writers`} failLabel={writers.reason ? String(writers.reason) : 'Нет writers'} /> } > - {writerRows.length === 0 ? ( -

- {writers.reason ?? - 'Нет данных /v1/stats/me-writers. Включите minimal_runtime_enabled в Telemt API.'} -

- ) : ( -
- - - - - - - - - - - - - {writerRows.slice(0, 40).map((w) => ( - - - - - - - - - ))} - -
EndpointDCСостояниеRTTКлиентыGen
{w.endpoint ?? '—'} - {w.dc != null ? formatNumber(w.dc) : '—'} - - - - - {formatRttMs(w.rtt_ema_ms)} - - - {formatNumber(w.bound_clients)} - - {formatNumber(w.generation)} -
- {writerRows.length > 40 ? ( -

- Показаны 40 из {writerRows.length} writers (сортировка по RTT). -

- ) : null} -
- )} +
+ 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 ? ( +

+ Показаны 40 из {writerTotal} writers (сортировка по RTT). +

+ ) : null} +
- {(q?.family_states ?? []).length > 0 ? ( + {familyRows.length > 0 ? ( -
- - - - - - - - - - - {(q?.family_states ?? []).map((f) => ( - - - - - - - ))} - -
FamilyStateFail streakRecover streak
{f.family ?? '—'} - - {f.state ?? '—'} - - {formatNumber(f.fail_streak)} - {formatNumber(f.recover_success_streak)} -
-
+ row.id} + emptyTitle="Нет family states" + pagination={false} + dense + />
) : null} diff --git a/apps/web/src/components/runtime/runtime-grid-columns.tsx b/apps/web/src/components/runtime/runtime-grid-columns.tsx new file mode 100644 index 0000000..dbeaa21 --- /dev/null +++ b/apps/web/src/components/runtime/runtime-grid-columns.tsx @@ -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 draining + if (degraded) return degraded + if (state === 'active') return active + if (state === 'warm') return warm + return {state ?? '—'} +} + +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[] { + return [ + { + id: 'dc', + accessorKey: 'dc', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + DC {formatNumber(row.original.dc)} + + ), + size: 80, + }, + { + id: 'rtt', + accessorFn: (row) => row.rtt_ms ?? Number.POSITIVE_INFINITY, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatRttMs(row.original.rtt_ms)} + + ), + size: 100, + }, + { + id: 'writers', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatNumber(row.original.alive_writers)} /{' '} + {formatNumber(row.original.required_writers)} + + ), + size: 110, + }, + { + id: 'coverage', + accessorFn: (row) => Number(row.coverage_pct ?? 0), + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const coverage = Number(row.original.coverage_pct ?? 0) + return ( +
+ + + {Number.isFinite(coverage) ? `${Math.round(coverage)}%` : '—'} + +
+ ) + }, + size: 120, + }, + { + id: 'load', + accessorKey: 'load', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.load != null ? formatNumber(row.original.load) : '—'} + + ), + size: 80, + }, + { + id: 'endpoints', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const endpoints = row.original.endpoints + if (endpoints.length === 0) { + return + } + return ( +
    + {endpoints.slice(0, 4).map((ep) => { + const writersOnEp = row.original.endpoint_writers.find( + (w) => w.endpoint === ep, + ) + return ( +
  • + {ep} + {writersOnEp?.active_writers != null ? ( + + {formatNumber(writersOnEp.active_writers)} w + + ) : null} +
  • + ) + })} + {endpoints.length > 4 ? ( +
  • + +{endpoints.length - 4} ещё +
  • + ) : null} +
+ ) + }, + size: 220, + }, + ] +} + +export function createWriterColumns(): ColumnDef[] { + return [ + { + id: 'endpoint', + accessorKey: 'endpoint', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.endpoint ?? '—'} + ), + size: 180, + }, + { + id: 'dc', + accessorKey: 'dc', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.dc != null ? formatNumber(row.original.dc) : '—'} + + ), + size: 70, + }, + { + id: 'state', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + size: 110, + }, + { + id: 'rtt', + accessorFn: (row) => row.rtt_ema_ms ?? Number.POSITIVE_INFINITY, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatRttMs(row.original.rtt_ema_ms)} + + ), + size: 100, + }, + { + id: 'clients', + accessorKey: 'bound_clients', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatNumber(row.original.bound_clients)} + + ), + size: 90, + }, + { + id: 'gen', + accessorKey: 'generation', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatNumber(row.original.generation)} + + ), + size: 70, + }, + ] +} + +export function createFamilyColumns(): ColumnDef[] { + return [ + { + id: 'family', + accessorKey: 'family', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.family ?? '—'} + ), + size: 100, + }, + { + id: 'state', + accessorKey: 'state', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.state ?? '—'} + + ), + size: 100, + }, + { + id: 'fail', + accessorKey: 'fail_streak', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatNumber(row.original.fail_streak)} + + ), + size: 110, + }, + { + id: 'recover', + accessorKey: 'recover_success_streak', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatNumber(row.original.recover_success_streak)} + + ), + size: 120, + }, + ] +} + +export function createTopConnColumns(): ColumnDef[] { + return [ + { + id: 'username', + accessorKey: 'username', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.username} + ), + size: 180, + }, + { + id: 'connections', + accessorKey: 'current_connections', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatNumber(row.original.current_connections)} + + ), + size: 120, + }, + { + id: 'octets', + accessorKey: 'total_octets', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatNumber(row.original.total_octets)} + + ), + size: 120, + }, + ] +} diff --git a/apps/web/src/components/servers/servers-columns.tsx b/apps/web/src/components/servers/servers-columns.tsx new file mode 100644 index 0000000..56f0d88 --- /dev/null +++ b/apps/web/src/components/servers/servers-columns.tsx @@ -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[] { + return [ + { + id: 'name', + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.name} + ), + enableSorting: true, + size: 160, + }, + { + id: 'status', + accessorKey: 'status', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.status} + + ), + enableSorting: true, + size: 120, + }, + { + id: 'hostname', + accessorKey: 'hostname', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.hostname ?? '—'} + + ), + enableSorting: true, + size: 180, + }, + { + id: 'lastSeenAt', + accessorKey: 'lastSeenAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.lastSeenAt ?? '—'} + + ), + 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', [''])] +} diff --git a/apps/web/src/components/users/users-columns.tsx b/apps/web/src/components/users/users-columns.tsx index d8c56f7..f24ff3f 100644 --- a/apps/web/src/components/users/users-columns.tsx +++ b/apps/web/src/components/users/users-columns.tsx @@ -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 }) { {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 (
diff --git a/apps/web/src/components/users/users-grid-view.tsx b/apps/web/src/components/users/users-grid-view.tsx index 1433ab7..2bbd5dc 100644 --- a/apps/web/src/components/users/users-grid-view.tsx +++ b/apps/web/src/components/users/users-grid-view.tsx @@ -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(null) const [createdLinks, setCreatedLinks] = useState([]) - - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 5, - }) - const [sorting, setSorting] = useState([ - { id: 'username', desc: false }, - ]) + const [activeTab, setActiveTab] = useState('all') + const [filters, setFilters] = useState(createDefaultUserFilters) const [columnVisibility, setColumnVisibility] = useState({ 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({ - top: [], - bottom: [], - }) - const [filters, setFilters] = useState(createDefaultUserFilters) const usersQuery = useQuery({ queryKey: ['telemt', 'users'], @@ -148,55 +91,18 @@ export function UsersGridView() { [usersQuery.data], ) - const [filteredData, setFilteredData] = useState([]) - 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)[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)[field] + }, []) const handleOpen = useCallback((user: UserInfo) => { setSelectedUsername(user.username) @@ -227,35 +133,6 @@ export function UsersGridView() { [handleOpen, removeUser], ) - const [columnOrder, setColumnOrder] = useState(() => - 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 ( <> - handleOpen(row)} - emptyMessage={ - !usersQuery.isLoading && filteredData.length === 0 - ? 'Нет пользователей по фильтрам. Сбросьте фильтры или создайте аккаунт.' - : undefined + 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.', }} - > - - -
- - Пользователи - - - {usersQuery.isLoading - ? 'Загрузка…' - : `${rows.length} аккаунтов Telemt`} - -
- -
- -
- - - Filters - - } - /> -
- {(resolvedRowPinning.top?.length ?? 0) > 0 && ( - - - - {resolvedRowPinning.top?.length}{' '} - {resolvedRowPinning.top?.length === 1 - ? 'user' - : 'users'}{' '} - pinned - - - )} - {showClearButton && ( - - )} - - - -
-
- - {usersQuery.isError ? ( -
- -

- Не удалось загрузить пользователей. -

-
- ) : ( - <> - - - - - - - - - )} -
- -
+ emptyMessage="Нет пользователей по фильтрам. Сбросьте фильтры или создайте аккаунт." + primaryAction={ + + } + toolbarExtra={ + + + + } + />
createClientsColumns(), []) return (
@@ -73,7 +71,7 @@ function ClientsPage() { В standalone пользователь сразу создаётся на локальном Telemt. - +
) } diff --git a/apps/web/src/routes/runtime.tsx b/apps/web/src/routes/runtime.tsx index d51a601..14b51bb 100644 --- a/apps/web/src/routes/runtime.tsx +++ b/apps/web/src/routes/runtime.tsx @@ -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> }).by_connections) + const topByConn = Array.isArray( + (connPayload.top as { by_connections?: unknown })?.by_connections, + ) + ? ( + connPayload.top as { + by_connections: Array> + } + ).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>) : Array.isArray(eventPayload.data) ? (eventPayload.data as Array>) : Array.isArray((eventPayload.data as { events?: unknown })?.events) - ? ((eventPayload.data as { events: Array> }).events) + ? ( + eventPayload.data as { + events: Array> + } + ).events : [] const accepting = gateData.accepting_new_connections === true @@ -102,7 +131,13 @@ function RuntimePage() { } + trailing={ + + } > - {String(gateData.me_runtime_ready ?? pool.status ?? (meReady ? 'ready' : '—'))} + + {String( + gateData.me_runtime_ready ?? + pool.status ?? + (meReady ? 'ready' : '—'), + )} } /> - + - + @@ -129,12 +177,18 @@ function RuntimePage() { label="Сейчас" value={formatNumber(totals.current_connections)} /> - + - +
@@ -152,38 +206,17 @@ function RuntimePage() { />
- - {topByConn.length === 0 ? ( -

Нет активных пользователей

- ) : ( -
- - - - - - - - - - {topByConn.slice(0, 10).map((row) => ( - - - - - - ))} - -
ПользовательСоединенияОктеты
{String(row.username)} - {formatNumber(row.current_connections)} - {formatNumber(row.total_octets)}
-
- )} -
+ columns={topColumns} + data={topRows} + rowId={(row) => row.id} + emptyTitle="Нет активных пользователей" + pagination={false} + dense + /> Событий пока нет

) : (
- {eventList.slice(-12).reverse().map((ev, i) => ( -
-
- {String(ev.event_type ?? 'event')} - - {ev.ts_epoch_secs - ? new Date(Number(ev.ts_epoch_secs) * 1000).toLocaleString('ru-RU') - : '—'} - + {eventList + .slice(-12) + .reverse() + .map((ev, i) => ( +
+
+ + {String(ev.event_type ?? 'event')} + + + {ev.ts_epoch_secs + ? new Date( + Number(ev.ts_epoch_secs) * 1000, + ).toLocaleString('ru-RU') + : '—'} + +
+

+ {String(ev.context ?? '')} +

-

- {String(ev.context ?? '')} -

-
- ))} + ))}
)} diff --git a/apps/web/src/routes/servers.tsx b/apps/web/src/routes/servers.tsx index 109740e..33514a8 100644 --- a/apps/web/src/routes/servers.tsx +++ b/apps/web/src/routes/servers.tsx @@ -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(null) + const [filters, setFilters] = useState(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)[field] + }, []) + + const handleClearFilters = useCallback(() => { + setFilters(createDefaultServerFilters()) + }, []) return (
@@ -101,53 +109,31 @@ function ServersPage() { - - - Список серверов - Агенты, подключённые к control plane. - - - {servers.isLoading ? ( -

Загрузка…

- ) : rows.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - {rows.map((s) => ( - - - - - - - ))} - -
ИмяСтатусHostnameLast seen
{s.name} - - {s.status} - - {s.hostname ?? '—'} - {s.lastSeenAt ?? '—'} -
-
- )} -
- + 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 + />
) }