feat(web): enhance ResourcePage and AgentsPage with table layout and column pinning
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m0s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- 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:
Denozordec
2026-07-22 01:40:39 +07:00
parent 68d9246158
commit 817f0af191
2 changed files with 96 additions and 10 deletions
@@ -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">
+62 -7
View File
@@ -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 } })
} }