feat(web): enhance quick action grid and resource page with search functionality
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m50s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Updated QuickActionItem interface to support optional `onSelect` handler and `badgeLabel`.
- Refactored QuickActionGrid to conditionally render links or buttons based on the presence of a `to` property.
- Introduced search functionality in ResourcePage, allowing users to filter items based on a search query.
- Added search input to the ResourcePage toolbar, improving user experience for data management.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 04:03:32 +07:00
co-authored by Cursor
parent 0653313a57
commit 33d301b191
11 changed files with 1082 additions and 431 deletions
+128
View File
@@ -7,8 +7,10 @@ import {
CircleAlertIcon,
Copy,
Inbox,
ListIcon,
Pencil,
Plus,
ShieldIcon,
Trash2,
UserPlus,
WifiOff,
@@ -20,6 +22,7 @@ import {
KpiStatGrid,
PageHeader,
PageShell,
QuickActionGrid,
ResourcePage,
} from '@/components/reui-kit'
import {
@@ -63,6 +66,16 @@ import {
} from '@evofw/ui/components/tooltip'
import type { Agent } from '@evofw/shared'
const packetFmt = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
})
function formatPackets(n: number | undefined, hasApply: boolean): string {
if (!hasApply || n === undefined) return '—'
return packetFmt.format(n)
}
/**
* Agents ops console — Solutions Agents DNA.
* Preview: https://reui.io/preview/base/solution-agents-1 · stats-12 · data-grid-filtering-2
@@ -80,6 +93,7 @@ function AgentsPage() {
const { copyToClipboard } = useCopyToClipboard()
const [createOpen, setCreateOpen] = useState(false)
const [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all')
const [deleteId, setDeleteId] = useState<string | null>(null)
@@ -184,11 +198,59 @@ function AgentsPage() {
return undefined
}, [])
const getSearchText = useCallback(
(item: Agent) =>
[item.name, item.hostname ?? '', item.last_seen_ip ?? '']
.filter(Boolean)
.join(' '),
[],
)
const tabFilter = useCallback((item: Agent, tabId: string) => {
if (tabId === 'all') return true
return item.status === tabId
}, [])
const quickActions = useMemo(
() => [
{
id: 'add',
title: 'Добавить агента',
description: 'Invite + install one-liner',
icon: <Plus aria-hidden />,
iconClassName: 'text-primary [&_svg]:text-current',
badgeLabel: 'Открыть',
onSelect: () => setCreateOpen(true),
},
{
id: 'pending',
title: 'Pending',
description: `${counts.pending} ждут approve`,
icon: <Inbox aria-hidden />,
iconClassName: 'text-warning [&_svg]:text-current',
badgeLabel: 'Показать',
onSelect: () => setActiveTab('pending'),
},
{
id: 'rules',
title: 'Наборы правил',
description: 'Политика firewall',
to: '/rules',
icon: <ShieldIcon aria-hidden />,
iconClassName: 'text-info [&_svg]:text-current',
},
{
id: 'lists',
title: 'Списки',
description: 'IP / CIDR / community',
to: '/lists',
icon: <ListIcon aria-hidden />,
iconClassName: 'text-muted-foreground [&_svg]:text-current',
},
],
[counts.pending],
)
const handleCopyCurl = useCallback(
(curl: string, e?: MouseEvent) => {
e?.stopPropagation()
@@ -252,6 +314,66 @@ function AgentsPage() {
)
},
},
{
id: 'dropped',
accessorFn: (row) => row.last_apply_packets_dropped ?? -1,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Dropped" />
),
cell: ({ row }) => {
const a = row.original
const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
const text = formatPackets(a.last_apply_packets_dropped, hasApply)
if (text === '—') {
return <DataGridMutedCell>—</DataGridMutedCell>
}
return (
<Tooltip>
<TooltipTrigger
render={
<span className="text-warning cursor-default tabular-nums text-sm font-medium" />
}
>
{text}
</TooltipTrigger>
<TooltipContent>
Dropped с последнего apply
{a.last_apply_at ? ` · ${a.last_apply_at}` : ''}
</TooltipContent>
</Tooltip>
)
},
},
{
id: 'accepted',
accessorFn: (row) => row.last_apply_packets_accepted ?? -1,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Accepted" />
),
cell: ({ row }) => {
const a = row.original
const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
const text = formatPackets(a.last_apply_packets_accepted, hasApply)
if (text === '—') {
return <DataGridMutedCell>—</DataGridMutedCell>
}
return (
<Tooltip>
<TooltipTrigger
render={
<span className="text-success cursor-default tabular-nums text-sm font-medium" />
}
>
{text}
</TooltipTrigger>
<TooltipContent>
Accepted с последнего apply
{a.last_apply_at ? ` · ${a.last_apply_at}` : ''}
</TooltipContent>
</Tooltip>
)
},
},
{
id: 'install',
enableSorting: false,
@@ -369,6 +491,8 @@ function AgentsPage() {
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
<QuickActionGrid actions={quickActions} />
{counts.pending > 0 ? (
<Frame dense spacing="sm">
<FrameHeader className="flex-row items-start justify-between gap-3">
@@ -425,6 +549,10 @@ function AgentsPage() {
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="Поиск агентов…"
getSearchText={getSearchText}
onRowClick={(row) =>
void navigate({ to: '/agents/$id', params: { id: row.id } })
}