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