feat(web): Lists/Rules UX parity с Агентами и правило kpi-hybrid
Hybrid row icons, row click, icon actions; DetailPanel header; ToggleGroup режима фильтра (settings-3). Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# KPI hybrid — только kit (stats-12 DNA)
|
||||||
|
|
||||||
|
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||||
|
|
||||||
|
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
|
||||||
|
|
||||||
|
## MUST
|
||||||
|
|
||||||
|
| Зона | Компонент / DNA |
|
||||||
|
|------|-----------------|
|
||||||
|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
||||||
|
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
|
||||||
|
| Row icon tiles (data-grid) | та же DNA (`AgentPlatformIcon` и клоны) — semantic `text-*` на `bg-muted` |
|
||||||
|
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
|
||||||
|
|
||||||
|
Импорты UI: `@evofw/ui/components/*`.
|
||||||
|
|
||||||
|
## NEVER
|
||||||
|
|
||||||
|
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||||
|
- Другой size / radius / solid brand fill вместо `bg-muted`
|
||||||
|
- `card-35` как замена stats-12 hybrid KPI
|
||||||
|
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||||
|
- Голый lucide `size-4` в name-cell без hybrid tile
|
||||||
@@ -36,7 +36,7 @@ alwaysApply: true
|
|||||||
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
|
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
|
||||||
| Filters | `@reui` | `@/components/reui/filters` |
|
| Filters | `@reui` | `@/components/reui/filters` |
|
||||||
| Frame surface | `@reui` | `@/components/reui/frame` |
|
| Frame surface | `@reui` | `@/components/reui/frame` |
|
||||||
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` (EvoBGP hybrid SoT) |
|
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
|
||||||
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
|
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
|
||||||
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
|
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
|
||||||
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
|
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Globe, List, Network } from 'lucide-react'
|
||||||
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
|
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||||
|
import { isManualListType } from '@evofw/shared'
|
||||||
|
|
||||||
|
type ListTypeIconProps = {
|
||||||
|
type: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI-style type tile for list rows.
|
||||||
|
* Preview DNA: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function ListTypeIcon({ type, className }: ListTypeIconProps) {
|
||||||
|
const kind = isManualListType(type)
|
||||||
|
? 'static'
|
||||||
|
: type === 'evobgp_community'
|
||||||
|
? 'evobgp_community'
|
||||||
|
: 'json_url'
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
kind === 'evobgp_community' ? Network : kind === 'json_url' ? Globe : List
|
||||||
|
const color =
|
||||||
|
kind === 'evobgp_community'
|
||||||
|
? 'text-info'
|
||||||
|
: kind === 'json_url'
|
||||||
|
? 'text-warning'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||||
|
color,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={
|
||||||
|
kind === 'evobgp_community'
|
||||||
|
? 'EvoBGP'
|
||||||
|
: kind === 'json_url'
|
||||||
|
? 'JSON URL'
|
||||||
|
: 'Ручной'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
<Icon aria-hidden />
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { ListIcon, RefreshCwIcon, Trash2 } from 'lucide-react'
|
import { RefreshCwIcon, Trash2 } from 'lucide-react'
|
||||||
import type { FilterFieldConfig } from '@/components/reui/filters'
|
import type { FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
DataGridPrimaryCell,
|
DataGridPrimaryCell,
|
||||||
} from '@/components/data-grid-cell'
|
} from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { ListTypeIcon } from '@/components/lists/list-type-icon'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { isManualListType, type IpList } from '@evofw/shared'
|
import { isManualListType, type IpList } from '@evofw/shared'
|
||||||
|
|
||||||
@@ -58,15 +58,8 @@ export function createListColumns(opts: {
|
|||||||
<DataGridColumnHeader column={column} title="Список" />
|
<DataGridColumnHeader column={column} title="Список" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Link
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
to="/lists/$id"
|
<ListTypeIcon type={row.original.type} />
|
||||||
params={{ id: row.original.id }}
|
|
||||||
className="flex min-w-0 items-center gap-2"
|
|
||||||
>
|
|
||||||
<ListIcon
|
|
||||||
className="text-muted-foreground size-4 shrink-0"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
accent="primary"
|
accent="primary"
|
||||||
title={row.original.name}
|
title={row.original.name}
|
||||||
@@ -78,7 +71,7 @@ export function createListColumns(opts: {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</div>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Список' },
|
meta: { headerTitle: 'Список' },
|
||||||
},
|
},
|
||||||
@@ -137,15 +130,6 @@ export function createListColumns(opts: {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
render={
|
|
||||||
<Link to="/lists/$id" params={{ id: row.original.id }} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Открыть
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from '@evofw/ui/components/toggle-group'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
|
||||||
|
type PolicyMode = 'blacklist' | 'whitelist'
|
||||||
|
|
||||||
|
type PolicyModeToggleProps = {
|
||||||
|
value: PolicyMode
|
||||||
|
onChange: (mode: PolicyMode) => void
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter mode — settings-3 ToggleGroup pattern.
|
||||||
|
* Preview: https://reui.io/preview/base/settings-3
|
||||||
|
* Docs: https://ui.shadcn.com/docs/components/base/toggle-group
|
||||||
|
*/
|
||||||
|
export function PolicyModeToggle({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
}: PolicyModeToggleProps) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn(className)}>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Режим фильтра</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Чёрный список: блокировать deny. Белый список: пропускать только
|
||||||
|
allow, остальное (forward) — DROP.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>
|
||||||
|
<ToggleGroup
|
||||||
|
multiple={false}
|
||||||
|
value={[value]}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
const mode = next[0]
|
||||||
|
if (mode === 'blacklist' || mode === 'whitelist') {
|
||||||
|
onChange(mode)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Режим фильтра"
|
||||||
|
className="flex flex-wrap justify-start gap-1"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="blacklist" aria-label="Чёрный список">
|
||||||
|
Чёрный список
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="whitelist" aria-label="Белый список">
|
||||||
|
Белый список
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Shield } from 'lucide-react'
|
||||||
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
|
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||||
|
|
||||||
|
type PolicySetIconProps = {
|
||||||
|
mode?: 'blacklist' | 'whitelist' | string | null
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI-style tile for policy set rows.
|
||||||
|
* Preview DNA: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function PolicySetIcon({ mode, className }: PolicySetIconProps) {
|
||||||
|
const isWhitelist = mode === 'whitelist'
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||||
|
isWhitelist ? 'text-warning' : 'text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={isWhitelist ? 'Whitelist' : 'Blacklist'}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
<Shield aria-hidden />
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import type { ColumnDef } from '@tanstack/react-table'
|
|||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import {
|
import {
|
||||||
DetailPanel,
|
DetailPanel,
|
||||||
PageHeader,
|
|
||||||
PageShell,
|
PageShell,
|
||||||
ResourcePage,
|
ResourcePage,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
@@ -300,7 +299,9 @@ function ListDetailPage() {
|
|||||||
if (listQ.isLoading) {
|
if (listQ.isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader title="Список" description="Загрузка…" />
|
<DetailPanel>
|
||||||
|
<DetailPanel.Header title="Список" description="Загрузка…" />
|
||||||
|
</DetailPanel>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Skeleton className="h-10 w-64" />
|
<Skeleton className="h-10 w-64" />
|
||||||
<Skeleton className="h-20 w-full" />
|
<Skeleton className="h-20 w-full" />
|
||||||
@@ -313,83 +314,73 @@ function ListDetailPage() {
|
|||||||
if (!detail) {
|
if (!detail) {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<DetailPanel>
|
||||||
title="Список не найден"
|
<DetailPanel.Header
|
||||||
actions={
|
title="Список не найден"
|
||||||
<Button
|
actions={
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
render={<Link to="/lists" />}
|
size="sm"
|
||||||
>
|
render={<Link to="/lists" />}
|
||||||
К спискам
|
>
|
||||||
</Button>
|
К спискам
|
||||||
}
|
</Button>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</DetailPanel>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<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>
|
||||||
<DetailPanel.Header
|
<DetailPanel.Header
|
||||||
title={detail.name}
|
title={detail.name}
|
||||||
description={
|
description={
|
||||||
manual
|
manual
|
||||||
? 'Ручной список'
|
? 'IP, CIDR, домены и вложенные списки'
|
||||||
: detail.type === 'json_url'
|
: 'Записи из внешнего источника (только чтение)'
|
||||||
? 'JSON по URL'
|
}
|
||||||
: 'EvoBGP community'
|
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
|
<DetailPanel.Metrics
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { RefreshCwIcon } from 'lucide-react'
|
import { Plus, RefreshCwIcon } from 'lucide-react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import type { Filter } from '@/components/reui/filters'
|
import type { Filter } from '@/components/reui/filters'
|
||||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||||
@@ -152,6 +152,7 @@ function ListsPage() {
|
|||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus data-icon="inline-start" />
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -185,6 +186,7 @@ function ListsPage() {
|
|||||||
description: 'Создайте первый список для политики firewall.',
|
description: 'Создайте первый список для политики firewall.',
|
||||||
action: (
|
action: (
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus data-icon="inline-start" />
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { PolicyModeToggle } from '@/components/rules/policy-mode-toggle'
|
||||||
import {
|
import {
|
||||||
agentsQueryOptions,
|
agentsQueryOptions,
|
||||||
listsQueryOptions,
|
listsQueryOptions,
|
||||||
@@ -56,6 +56,12 @@ import {
|
|||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/rules/$setId')({
|
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,
|
component: PolicySetDetailPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -286,10 +292,12 @@ function PolicySetDetailPage() {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={set.enabled}
|
checked={set.enabled}
|
||||||
onCheckedChange={(v) => patchSet.mutate({ enabled: v })}
|
onCheckedChange={(v) => patchSet.mutate({ enabled: v })}
|
||||||
|
disabled={patchSet.isPending}
|
||||||
|
aria-label={set.enabled ? 'Выключить набор' : 'Включить набор'}
|
||||||
/>
|
/>
|
||||||
<StatusBadge
|
<span className="text-muted-foreground text-sm">
|
||||||
status={set.enabled ? 'enabled' : 'disabled'}
|
{set.enabled ? 'Включён' : 'Выключен'}
|
||||||
/>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" render={<Link to="/rules" />}>
|
<Button variant="outline" size="sm" render={<Link to="/rules" />}>
|
||||||
Назад
|
Назад
|
||||||
@@ -332,51 +340,11 @@ function PolicySetDetailPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailPanel.Section>
|
<DetailPanel.Section>
|
||||||
<Frame dense spacing="sm">
|
<PolicyModeToggle
|
||||||
<FrameHeader>
|
value={policyMode}
|
||||||
<FrameTitle>Режим фильтра</FrameTitle>
|
disabled={patchSet.isPending}
|
||||||
<FrameDescription>
|
onChange={(mode) => patchSet.mutate({ policy_mode: mode })}
|
||||||
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>
|
|
||||||
</DetailPanel.Section>
|
</DetailPanel.Section>
|
||||||
|
|
||||||
<DetailPanel.Section
|
<DetailPanel.Section
|
||||||
|
|||||||
@@ -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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
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 type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
|
||||||
import { policySetsQueryOptions } from '@/queries'
|
import { policySetsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
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).
|
* 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
|
* Empty: https://reui.io/preview/base/empty-state-12
|
||||||
* Create sheet: https://reui.io/preview/base/sheet-8
|
* Create sheet: https://reui.io/preview/base/sheet-8
|
||||||
*/
|
*/
|
||||||
@@ -119,17 +120,14 @@ function PolicySetsPage() {
|
|||||||
<DataGridColumnHeader column={column} title="Набор" />
|
<DataGridColumnHeader column={column} title="Набор" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Link
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
to="/rules/$setId"
|
<PolicySetIcon mode={row.original.policy_mode} />
|
||||||
params={{ setId: row.original.id }}
|
|
||||||
className="min-w-0"
|
|
||||||
>
|
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
accent="primary"
|
accent="primary"
|
||||||
title={row.original.name}
|
title={row.original.name}
|
||||||
subtitle={row.original.description ?? undefined}
|
subtitle={row.original.description ?? undefined}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -190,16 +188,18 @@ function PolicySetsPage() {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
render={
|
aria-label="Открыть"
|
||||||
<Link
|
onClick={(e) => {
|
||||||
to="/rules/$setId"
|
e.stopPropagation()
|
||||||
params={{ setId: row.original.id }}
|
void navigate({
|
||||||
/>
|
to: '/rules/$setId',
|
||||||
}
|
params: { setId: row.original.id },
|
||||||
|
})
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Открыть
|
<Pencil className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{row.original.id !== 'set-shared-default' ? (
|
{row.original.id !== 'set-shared-default' ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -207,7 +207,10 @@ function PolicySetsPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
aria-label="Удалить"
|
aria-label="Удалить"
|
||||||
onClick={() => setDeleteId(row.original.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setDeleteId(row.original.id)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -216,7 +219,14 @@ function PolicySetsPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
const addButton = (
|
||||||
|
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||||
|
<Plus data-icon="inline-start" />
|
||||||
|
Новый набор
|
||||||
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -224,11 +234,7 @@ function PolicySetsPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Наборы правил"
|
title="Наборы правил"
|
||||||
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
|
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
|
||||||
actions={
|
actions={addButton}
|
||||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
|
||||||
Новый набор
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
@@ -254,14 +260,13 @@ function PolicySetsPage() {
|
|||||||
isError={setsQ.isError}
|
isError={setsQ.isError}
|
||||||
error={setsQ.error}
|
error={setsQ.error}
|
||||||
onRetry={() => void setsQ.refetch()}
|
onRetry={() => void setsQ.refetch()}
|
||||||
|
onRowClick={(row) =>
|
||||||
|
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
|
||||||
|
}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет наборов',
|
title: 'Нет наборов',
|
||||||
description: 'Создайте первый набор правил политики.',
|
description: 'Создайте первый набор правил политики.',
|
||||||
action: (
|
action: addButton,
|
||||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
|
||||||
Новый набор
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
|
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||||
|
import { type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@evofw/ui/lib/utils"
|
||||||
|
import { toggleVariants } from "@evofw/ui/components/toggle"
|
||||||
|
|
||||||
|
const ToggleGroupContext = React.createContext<
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
spacing: 2,
|
||||||
|
orientation: "horizontal",
|
||||||
|
})
|
||||||
|
|
||||||
|
function ToggleGroup({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
spacing = 2,
|
||||||
|
orientation = "horizontal",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ToggleGroupPrimitive.Props &
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive
|
||||||
|
data-slot="toggle-group"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
data-spacing={spacing}
|
||||||
|
data-orientation={orientation}
|
||||||
|
style={{ "--gap": spacing } as React.CSSProperties}
|
||||||
|
className={cn(
|
||||||
|
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider
|
||||||
|
value={{ variant, size, spacing, orientation }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleGroupItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||||
|
const context = React.useContext(ToggleGroupContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TogglePrimitive
|
||||||
|
data-slot="toggle-group-item"
|
||||||
|
data-variant={context.variant || variant}
|
||||||
|
data-size={context.size || size}
|
||||||
|
data-spacing={context.spacing}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TogglePrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem }
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@evofw/ui/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border border-input bg-transparent hover:bg-muted",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<TogglePrimitive
|
||||||
|
data-slot="toggle"
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
Reference in New Issue
Block a user