feat(web): Lists/Rules UX parity с Агентами и правило kpi-hybrid
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m50s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Hybrid row icons, row click, icon actions; DetailPanel header; ToggleGroup режима фильтра (settings-3).

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 02:36:09 +07:00
co-authored by Cursor
parent 90d50c2556
commit 41e4ca1410
12 changed files with 432 additions and 171 deletions
+58 -67
View File
@@ -14,7 +14,6 @@ import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
DetailPanel,
PageHeader,
PageShell,
ResourcePage,
} from '@/components/reui-kit'
@@ -300,7 +299,9 @@ function ListDetailPage() {
if (listQ.isLoading) {
return (
<PageShell>
<PageHeader title="Список" description="Загрузка…" />
<DetailPanel>
<DetailPanel.Header title="Список" description="Загрузка…" />
</DetailPanel>
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-20 w-full" />
@@ -313,83 +314,73 @@ function ListDetailPage() {
if (!detail) {
return (
<PageShell>
<PageHeader
title="Список не найден"
actions={
<Button
variant="outline"
size="sm"
render={<Link to="/lists" />}
>
К спискам
</Button>
}
/>
<DetailPanel>
<DetailPanel.Header
title="Список не найден"
actions={
<Button
variant="outline"
size="sm"
render={<Link to="/lists" />}
>
К спискам
</Button>
}
/>
</DetailPanel>
</PageShell>
)
}
return (
<PageShell>
<PageHeader
title={detail.name}
description={
manual
? 'IP, CIDR, домены и вложенные списки'
: 'Записи из внешнего источника (только чтение)'
}
actions={
<>
<Button
variant="outline"
size="sm"
render={<Link to="/lists" />}
>
<ArrowLeftIcon className="size-3.5" />
Списки
</Button>
<Button
size="sm"
variant="outline"
disabled={refresh.isPending}
onClick={() => refresh.mutate()}
>
<RefreshCwIcon
className={
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
}
/>
Обновить
</Button>
{manual ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
Добавить
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="text-destructive"
onClick={() => setDeleteListOpen(true)}
>
<Trash2 className="size-3.5" />
Удалить
</Button>
</>
}
/>
<DetailPanel>
<DetailPanel.Header
title={detail.name}
description={
manual
? 'Ручной список'
: detail.type === 'json_url'
? 'JSON по URL'
: 'EvoBGP community'
? 'IP, CIDR, домены и вложенные списки'
: 'Записи из внешнего источника (только чтение)'
}
actions={
<>
<Button
variant="outline"
size="sm"
render={<Link to="/lists" />}
>
<ArrowLeftIcon className="size-3.5" />
Списки
</Button>
<Button
size="sm"
variant="outline"
disabled={refresh.isPending}
onClick={() => refresh.mutate()}
>
<RefreshCwIcon
className={
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
}
/>
Обновить
</Button>
{manual ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
Добавить
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="text-destructive"
onClick={() => setDeleteListOpen(true)}
>
<Trash2 className="size-3.5" />
Удалить
</Button>
</>
}
actions={<StatusBadge status={detail.type} />}
/>
<DetailPanel.Metrics
+3 -1
View File
@@ -1,7 +1,7 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { RefreshCwIcon } from 'lucide-react'
import { Plus, RefreshCwIcon } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import type { Filter } from '@/components/reui/filters'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
@@ -152,6 +152,7 @@ function ListsPage() {
Обновить
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
Создать
</Button>
</>
@@ -185,6 +186,7 @@ function ListsPage() {
description: 'Создайте первый список для политики firewall.',
action: (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
Создать
</Button>
),
+17 -49
View File
@@ -14,7 +14,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
import { Badge } from '@/components/reui/badge'
import { PolicyModeToggle } from '@/components/rules/policy-mode-toggle'
import {
agentsQueryOptions,
listsQueryOptions,
@@ -56,6 +56,12 @@ import {
} from '@/components/reui/frame'
export const Route = createFileRoute('/_auth/rules/$setId')({
loader: async ({ context: { queryClient }, params }) => {
const set = await queryClient.ensureQueryData(
policySetQueryOptions(params.setId),
)
return { breadcrumb: set.name }
},
component: PolicySetDetailPage,
})
@@ -286,10 +292,12 @@ function PolicySetDetailPage() {
<Switch
checked={set.enabled}
onCheckedChange={(v) => patchSet.mutate({ enabled: v })}
disabled={patchSet.isPending}
aria-label={set.enabled ? 'Выключить набор' : 'Включить набор'}
/>
<StatusBadge
status={set.enabled ? 'enabled' : 'disabled'}
/>
<span className="text-muted-foreground text-sm">
{set.enabled ? 'Включён' : 'Выключен'}
</span>
</div>
<Button variant="outline" size="sm" render={<Link to="/rules" />}>
Назад
@@ -332,51 +340,11 @@ function PolicySetDetailPage() {
/>
<DetailPanel.Section>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Режим фильтра</FrameTitle>
<FrameDescription>
Blacklist: блокировать deny. Whitelist: пропускать только
allow, остальное (forward) DROP.
</FrameDescription>
</FrameHeader>
<FramePanel>
<div className="flex flex-wrap gap-2">
<Button
variant={
policyMode === 'blacklist' ? 'default' : 'outline'
}
size="sm"
onClick={() =>
patchSet.mutate({ policy_mode: 'blacklist' })
}
>
Чёрный список
</Button>
<Button
variant={
policyMode === 'whitelist' ? 'default' : 'outline'
}
size="sm"
onClick={() =>
patchSet.mutate({ policy_mode: 'whitelist' })
}
>
Белый список
</Button>
<Badge
variant={
policyMode === 'whitelist'
? 'warning-light'
: 'secondary'
}
size="sm"
>
{policyMode}
</Badge>
</div>
</FramePanel>
</Frame>
<PolicyModeToggle
value={policyMode}
disabled={patchSet.isPending}
onChange={(mode) => patchSet.mutate({ policy_mode: mode })}
/>
</DetailPanel.Section>
<DetailPanel.Section
+37 -32
View File
@@ -1,9 +1,9 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import type { ColumnDef } from '@tanstack/react-table'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Trash2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
@@ -11,6 +11,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
import { policySetsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
@@ -33,7 +34,7 @@ export const Route = createFileRoute('/_auth/rules/')({
/**
* Policy sets list — ResourcePage (Frame + tabs + Filters + DataGrid).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · stats-12
* Empty: https://reui.io/preview/base/empty-state-12
* Create sheet: https://reui.io/preview/base/sheet-8
*/
@@ -119,17 +120,14 @@ function PolicySetsPage() {
<DataGridColumnHeader column={column} title="Набор" />
),
cell: ({ row }) => (
<Link
to="/rules/$setId"
params={{ setId: row.original.id }}
className="min-w-0"
>
<div className="flex min-w-0 items-center gap-3">
<PolicySetIcon mode={row.original.policy_mode} />
<DataGridPrimaryCell
accent="primary"
title={row.original.name}
subtitle={row.original.description ?? undefined}
/>
</Link>
</div>
),
},
{
@@ -190,16 +188,18 @@ function PolicySetsPage() {
cell: ({ row }) => (
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="outline"
render={
<Link
to="/rules/$setId"
params={{ setId: row.original.id }}
/>
}
size="icon-sm"
variant="ghost"
aria-label="Открыть"
onClick={(e) => {
e.stopPropagation()
void navigate({
to: '/rules/$setId',
params: { setId: row.original.id },
})
}}
>
Открыть
<Pencil className="size-3.5" />
</Button>
{row.original.id !== 'set-shared-default' ? (
<Button
@@ -207,7 +207,10 @@ function PolicySetsPage() {
variant="ghost"
className="text-destructive"
aria-label="Удалить"
onClick={() => setDeleteId(row.original.id)}
onClick={(e) => {
e.stopPropagation()
setDeleteId(row.original.id)
}}
>
<Trash2 className="size-3.5" />
</Button>
@@ -216,7 +219,14 @@ function PolicySetsPage() {
),
},
],
[],
[navigate],
)
const addButton = (
<Button size="sm" onClick={() => setSheetOpen(true)}>
<Plus data-icon="inline-start" />
Новый набор
</Button>
)
return (
@@ -224,11 +234,7 @@ function PolicySetsPage() {
<PageHeader
title="Наборы правил"
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
actions={
<Button size="sm" onClick={() => setSheetOpen(true)}>
Новый набор
</Button>
}
actions={addButton}
/>
<ResourcePage
@@ -254,14 +260,13 @@ function PolicySetsPage() {
isError={setsQ.isError}
error={setsQ.error}
onRetry={() => void setsQ.refetch()}
onRowClick={(row) =>
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
}
emptyState={{
title: 'Нет наборов',
description: 'Создайте первый набор правил политики.',
action: (
<Button size="sm" onClick={() => setSheetOpen(true)}>
Новый набор
</Button>
),
action: addButton,
}}
/>