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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user