feat(web): enhance ResourcePage and AgentsPage with table layout and column pinning
- Updated ResourcePage to support customizable table layout and column pinning, improving data presentation and user interaction. - Introduced a new `formatAgentSeen` function in AgentsPage for better date formatting of agent last seen timestamps. - Adjusted column sizes and added min/max size constraints for various columns in AgentsPage to enhance layout consistency. - Enhanced the tooltip functionality for displaying agent last seen timestamps, improving user experience.
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
|
type ColumnPinningState,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
@@ -46,6 +47,10 @@ import {
|
|||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { applyFiltersToData } from './filter-utils'
|
import { applyFiltersToData } from './filter-utils'
|
||||||
|
|
||||||
|
type DataGridTableLayout = NonNullable<
|
||||||
|
ComponentProps<typeof DataGrid>['tableLayout']
|
||||||
|
>
|
||||||
|
|
||||||
export interface ResourcePageTab {
|
export interface ResourcePageTab {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
@@ -88,6 +93,10 @@ export interface ResourcePageProps<T extends object> {
|
|||||||
onSearchChange?: (query: string) => void
|
onSearchChange?: (query: string) => void
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string
|
||||||
getSearchText?: (item: T) => string
|
getSearchText?: (item: T) => string
|
||||||
|
/** Merge over defaults: dense + headerSticky + width fixed */
|
||||||
|
tableLayout?: Partial<DataGridTableLayout>
|
||||||
|
/** Pin columns (e.g. `{ right: ['actions'] }`) — enables column pinning */
|
||||||
|
columnPinning?: ColumnPinningState
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourcePageSkeleton() {
|
function ResourcePageSkeleton() {
|
||||||
@@ -140,12 +149,17 @@ export function ResourcePage<T extends object>({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
searchPlaceholder = 'Поиск…',
|
searchPlaceholder = 'Поиск…',
|
||||||
getSearchText,
|
getSearchText,
|
||||||
|
tableLayout: tableLayoutProp,
|
||||||
|
columnPinning: columnPinningProp,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
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 [sorting, setSorting] = useState<SortingState>([])
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(
|
||||||
|
() => columnPinningProp ?? {},
|
||||||
|
)
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -213,15 +227,32 @@ export function ResourcePage<T extends object>({
|
|||||||
setRowSelection({})
|
setRowSelection({})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const tableLayout = useMemo(
|
||||||
|
() => ({
|
||||||
|
dense: true,
|
||||||
|
headerSticky: true,
|
||||||
|
width: 'fixed' as const,
|
||||||
|
...tableLayoutProp,
|
||||||
|
...(columnPinningProp || Object.keys(columnPinning).length > 0
|
||||||
|
? { columnsPinnable: true }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
[tableLayoutProp, columnPinningProp, columnPinning],
|
||||||
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
columns,
|
columns,
|
||||||
getRowId,
|
getRowId,
|
||||||
state: { sorting, rowSelection, pagination },
|
state: { sorting, rowSelection, pagination, columnPinning },
|
||||||
enableRowSelection,
|
enableRowSelection,
|
||||||
|
enableColumnPinning: Boolean(
|
||||||
|
tableLayout.columnsPinnable || columnPinningProp,
|
||||||
|
),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
|
onColumnPinningChange: setColumnPinning,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
@@ -338,7 +369,7 @@ export function ResourcePage<T extends object>({
|
|||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredData.length}
|
recordCount={filteredData.length}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
tableLayout={{ dense: true }}
|
tableLayout={tableLayout}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
>
|
>
|
||||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
|
|||||||
@@ -76,6 +76,20 @@ function formatPackets(n: number | undefined, hasApply: boolean): string {
|
|||||||
return packetFmt.format(n)
|
return packetFmt.format(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatAgentSeen(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const t = Date.parse(iso)
|
||||||
|
if (Number.isNaN(t)) return '—'
|
||||||
|
return seenFmt.format(t)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agents ops console — Solutions Agents DNA.
|
* Agents ops console — Solutions Agents DNA.
|
||||||
* Preview: https://reui.io/preview/base/solution-agents-1 · stats-12 · data-grid-filtering-2
|
* Preview: https://reui.io/preview/base/solution-agents-1 · stats-12 · data-grid-filtering-2
|
||||||
@@ -265,6 +279,9 @@ function AgentsPage() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
|
size: 220,
|
||||||
|
minSize: 160,
|
||||||
|
maxSize: 320,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Имя" />
|
<DataGridColumnHeader column={column} title="Имя" />
|
||||||
),
|
),
|
||||||
@@ -284,6 +301,9 @@ function AgentsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
|
size: 110,
|
||||||
|
minSize: 100,
|
||||||
|
maxSize: 130,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Статус" />
|
<DataGridColumnHeader column={column} title="Статус" />
|
||||||
),
|
),
|
||||||
@@ -291,6 +311,9 @@ function AgentsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'apply',
|
id: 'apply',
|
||||||
|
size: 90,
|
||||||
|
minSize: 80,
|
||||||
|
maxSize: 110,
|
||||||
accessorFn: (row) => row.last_apply_status ?? '',
|
accessorFn: (row) => row.last_apply_status ?? '',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Apply" />
|
<DataGridColumnHeader column={column} title="Apply" />
|
||||||
@@ -316,6 +339,9 @@ function AgentsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dropped',
|
id: 'dropped',
|
||||||
|
size: 90,
|
||||||
|
minSize: 80,
|
||||||
|
maxSize: 110,
|
||||||
accessorFn: (row) => row.last_apply_packets_dropped ?? -1,
|
accessorFn: (row) => row.last_apply_packets_dropped ?? -1,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Dropped" />
|
<DataGridColumnHeader column={column} title="Dropped" />
|
||||||
@@ -346,6 +372,9 @@ function AgentsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'accepted',
|
id: 'accepted',
|
||||||
|
size: 90,
|
||||||
|
minSize: 80,
|
||||||
|
maxSize: 110,
|
||||||
accessorFn: (row) => row.last_apply_packets_accepted ?? -1,
|
accessorFn: (row) => row.last_apply_packets_accepted ?? -1,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Accepted" />
|
<DataGridColumnHeader column={column} title="Accepted" />
|
||||||
@@ -376,6 +405,9 @@ function AgentsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'install',
|
id: 'install',
|
||||||
|
size: 100,
|
||||||
|
minSize: 90,
|
||||||
|
maxSize: 120,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Install" />
|
<DataGridColumnHeader column={column} title="Install" />
|
||||||
@@ -392,13 +424,13 @@ function AgentsPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="max-w-[14rem] font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
onClick={(e) => handleCopyCurl(curl, e)}
|
onClick={(e) => handleCopyCurl(curl, e)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Copy data-icon="inline-start" className="size-3.5" />
|
<Copy data-icon="inline-start" className="size-3.5" />
|
||||||
<span className="truncate">{curl}</span>
|
curl
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-sm break-all font-mono text-xs">
|
<TooltipContent className="max-w-sm break-all font-mono text-xs">
|
||||||
{curl}
|
{curl}
|
||||||
@@ -409,18 +441,39 @@ function AgentsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'last_seen_at',
|
accessorKey: 'last_seen_at',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
maxSize: 160,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Seen" />
|
<DataGridColumnHeader column={column} title="Seen" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<DataGridMutedCell>
|
const iso = row.original.last_seen_at
|
||||||
{row.original.last_seen_at ?? '—'}
|
const short = formatAgentSeen(iso)
|
||||||
</DataGridMutedCell>
|
if (!iso || short === '—') {
|
||||||
),
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<span className="text-muted-foreground cursor-default text-xs tabular-nums" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{short}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="font-mono text-xs">{iso}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
|
size: 108,
|
||||||
|
minSize: 108,
|
||||||
|
maxSize: 108,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
|
enablePinning: true,
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const a = row.original
|
const a = row.original
|
||||||
@@ -553,6 +606,8 @@ function AgentsPage() {
|
|||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
searchPlaceholder="Поиск агентов…"
|
searchPlaceholder="Поиск агентов…"
|
||||||
getSearchText={getSearchText}
|
getSearchText={getSearchText}
|
||||||
|
tableLayout={{ columnsPinnable: true, width: 'fixed' }}
|
||||||
|
columnPinning={{ right: ['actions'] }}
|
||||||
onRowClick={(row) =>
|
onRowClick={(row) =>
|
||||||
void navigate({ to: '/agents/$id', params: { id: row.id } })
|
void navigate({ to: '/agents/$id', params: { id: row.id } })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user