fix(web): открытие detail-роутов и паритет ReUI DataGrid с EvoBGP/CFDM
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m52s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Sibling routes index+$id, ColumnHeader/PrimaryCell/StatusBadge/ConfirmDialog, tabs и таблица entries списков.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 22:40:48 +07:00
co-authored by Cursor
parent 0bed4367c7
commit 7f0c1009de
10 changed files with 1040 additions and 360 deletions
@@ -0,0 +1,46 @@
import type { ReactNode } from 'react'
import { cn } from '@evofw/ui/lib/utils'
const ACCENT_CLASS = {
primary: 'font-medium text-primary',
default: 'font-medium text-foreground',
mono: 'font-mono text-sm text-primary',
} as const
export function DataGridPrimaryCell({
title,
subtitle,
accent = 'default',
className,
}: {
title: ReactNode
subtitle?: ReactNode
accent?: keyof typeof ACCENT_CLASS
className?: string
}) {
return (
<div className={cn('flex min-w-0 flex-col gap-0.5', className)}>
<span className={cn('truncate', ACCENT_CLASS[accent])}>{title}</span>
{subtitle ? (
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
) : null}
</div>
)
}
export function DataGridMutedCell({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<span
className={cn('whitespace-nowrap text-xs text-muted-foreground', className)}
>
{children}
</span>
)
}
+32 -2
View File
@@ -11,13 +11,24 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
synced: 'success-light',
ok: 'success-light',
up: 'success-light',
enabled: 'success-light',
approved: 'success-light',
allow: 'success-light',
pending_push: 'secondary',
pending: 'warning-light',
warning: 'warning-light',
degraded: 'warning-light',
conflict: 'destructive-light',
error: 'destructive-light',
expired: 'destructive-light',
down: 'destructive-light',
deny: 'destructive-light',
revoked: 'secondary',
disabled: 'secondary',
static: 'secondary',
domains: 'info-light',
json_url: 'info-light',
evobgp_community: 'info-light',
unknown: 'outline',
}
@@ -37,6 +48,13 @@ const STATUS_LABELS: Record<string, string> = {
active: 'Активен',
synced: 'Синхронизировано',
pending_push: 'Ожидает отправки',
pending: 'Pending',
approved: 'Approved',
revoked: 'Revoked',
enabled: 'Включён',
disabled: 'Выключен',
allow: 'allow',
deny: 'deny',
conflict: 'Конфликт',
error: 'Ошибка',
ok: 'OK',
@@ -45,6 +63,10 @@ const STATUS_LABELS: Record<string, string> = {
degraded: 'Slow',
down: 'Down',
expired: 'Истёк',
static: 'static',
domains: 'domains',
json_url: 'json_url',
evobgp_community: 'evobgp_community',
unknown: 'Неизвестно',
}
@@ -60,8 +82,16 @@ export function StatusBadge({
const variant = STATUS_VARIANT[status] ?? 'outline'
const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
return (
<Badge variant={variant} size="sm" radius="full" className={cn('gap-1.5', className)}>
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
<Badge
variant={variant}
size="sm"
radius="full"
className={cn('gap-1.5', className)}
>
<span
className={cn('size-1.5 shrink-0 rounded-full', dotColor)}
aria-hidden
/>
{label ?? STATUS_LABELS[status] ?? status}
</Badge>
)
+11
View File
@@ -32,6 +32,17 @@ export const listsQueryOptions = () =>
queryFn: () => apiFetch<{ items: IpList[] }>('/api/v1/lists'),
})
export const listQueryOptions = (id: string) =>
queryOptions({
queryKey: ['lists', id],
queryFn: () =>
apiFetch<
IpList & {
entries: string[]
}
>(`/api/v1/lists/${id}`),
})
export const policySetsQueryOptions = () =>
queryOptions({
queryKey: ['policy-sets'],
+100 -99
View File
@@ -11,14 +11,15 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
import { Route as AuthAgentsRouteImport } from './routes/_auth/agents'
import { Route as AuthListsRouteImport } from './routes/_auth/lists'
import { Route as AuthRulesRouteImport } from './routes/_auth/rules'
import { Route as AuthSettingsRouteImport } from './routes/_auth/settings'
import { Route as AuthStatsRouteImport } from './routes/_auth/stats'
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as AuthAgentsIdRouteImport } from './routes/_auth/agents.$id'
import { Route as AuthRulesSetIdRouteImport } from './routes/_auth/rules.$setId'
import { Route as AuthAgentsIndexRouteImport } from './routes/_auth/agents/index'
import { Route as AuthAgentsIdRouteImport } from './routes/_auth/agents/$id'
import { Route as AuthListsIndexRouteImport } from './routes/_auth/lists/index'
import { Route as AuthListsIdRouteImport } from './routes/_auth/lists/$id'
import { Route as AuthRulesIndexRouteImport } from './routes/_auth/rules/index'
import { Route as AuthRulesSetIdRouteImport } from './routes/_auth/rules/$setId'
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
@@ -29,21 +30,6 @@ const AuthIndexRoute = AuthIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAgentsRoute = AuthAgentsRouteImport.update({
id: '/agents',
path: '/agents',
getParentRoute: () => AuthRoute,
} as any)
const AuthListsRoute = AuthListsRouteImport.update({
id: '/lists',
path: '/lists',
getParentRoute: () => AuthRoute,
} as any)
const AuthRulesRoute = AuthRulesRouteImport.update({
id: '/rules',
path: '/rules',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsRoute = AuthSettingsRouteImport.update({
id: '/settings',
path: '/settings',
@@ -59,87 +45,113 @@ const AuthCallbackRoute = AuthCallbackRouteImport.update({
path: '/auth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthAgentsIndexRoute = AuthAgentsIndexRouteImport.update({
id: '/agents/',
path: '/agents/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAgentsIdRoute = AuthAgentsIdRouteImport.update({
id: '/$id',
path: '/$id',
getParentRoute: () => AuthAgentsRoute,
id: '/agents/$id',
path: '/agents/$id',
getParentRoute: () => AuthRoute,
} as any)
const AuthListsIndexRoute = AuthListsIndexRouteImport.update({
id: '/lists/',
path: '/lists/',
getParentRoute: () => AuthRoute,
} as any)
const AuthListsIdRoute = AuthListsIdRouteImport.update({
id: '/lists/$id',
path: '/lists/$id',
getParentRoute: () => AuthRoute,
} as any)
const AuthRulesIndexRoute = AuthRulesIndexRouteImport.update({
id: '/rules/',
path: '/rules/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRulesSetIdRoute = AuthRulesSetIdRouteImport.update({
id: '/$setId',
path: '/$setId',
getParentRoute: () => AuthRulesRoute,
id: '/rules/$setId',
path: '/rules/$setId',
getParentRoute: () => AuthRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof AuthIndexRoute
'/agents': typeof AuthAgentsRouteWithChildren
'/lists': typeof AuthListsRoute
'/rules': typeof AuthRulesRouteWithChildren
'/settings': typeof AuthSettingsRoute
'/stats': typeof AuthStatsRoute
'/auth/callback': typeof AuthCallbackRoute
'/agents/$id': typeof AuthAgentsIdRoute
'/lists/$id': typeof AuthListsIdRoute
'/rules/$setId': typeof AuthRulesSetIdRoute
'/agents/': typeof AuthAgentsIndexRoute
'/lists/': typeof AuthListsIndexRoute
'/rules/': typeof AuthRulesIndexRoute
}
export interface FileRoutesByTo {
'/agents': typeof AuthAgentsRouteWithChildren
'/lists': typeof AuthListsRoute
'/rules': typeof AuthRulesRouteWithChildren
'/settings': typeof AuthSettingsRoute
'/stats': typeof AuthStatsRoute
'/auth/callback': typeof AuthCallbackRoute
'/': typeof AuthIndexRoute
'/agents/$id': typeof AuthAgentsIdRoute
'/lists/$id': typeof AuthListsIdRoute
'/rules/$setId': typeof AuthRulesSetIdRoute
'/agents': typeof AuthAgentsIndexRoute
'/lists': typeof AuthListsIndexRoute
'/rules': typeof AuthRulesIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_auth': typeof AuthRouteWithChildren
'/_auth/agents': typeof AuthAgentsRouteWithChildren
'/_auth/lists': typeof AuthListsRoute
'/_auth/rules': typeof AuthRulesRouteWithChildren
'/_auth/settings': typeof AuthSettingsRoute
'/_auth/stats': typeof AuthStatsRoute
'/auth/callback': typeof AuthCallbackRoute
'/_auth/': typeof AuthIndexRoute
'/_auth/agents/$id': typeof AuthAgentsIdRoute
'/_auth/lists/$id': typeof AuthListsIdRoute
'/_auth/rules/$setId': typeof AuthRulesSetIdRoute
'/_auth/agents/': typeof AuthAgentsIndexRoute
'/_auth/lists/': typeof AuthListsIndexRoute
'/_auth/rules/': typeof AuthRulesIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/agents'
| '/lists'
| '/rules'
| '/settings'
| '/stats'
| '/auth/callback'
| '/agents/$id'
| '/lists/$id'
| '/rules/$setId'
| '/agents/'
| '/lists/'
| '/rules/'
fileRoutesByTo: FileRoutesByTo
to:
| '/agents'
| '/lists'
| '/rules'
| '/settings'
| '/stats'
| '/auth/callback'
| '/'
| '/agents/$id'
| '/lists/$id'
| '/rules/$setId'
| '/agents'
| '/lists'
| '/rules'
id:
| '__root__'
| '/_auth'
| '/_auth/agents'
| '/_auth/lists'
| '/_auth/rules'
| '/_auth/settings'
| '/_auth/stats'
| '/auth/callback'
| '/_auth/'
| '/_auth/agents/$id'
| '/_auth/lists/$id'
| '/_auth/rules/$setId'
| '/_auth/agents/'
| '/_auth/lists/'
| '/_auth/rules/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -163,27 +175,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/agents': {
id: '/_auth/agents'
path: '/agents'
fullPath: '/agents'
preLoaderRoute: typeof AuthAgentsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/lists': {
id: '/_auth/lists'
path: '/lists'
fullPath: '/lists'
preLoaderRoute: typeof AuthListsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/rules': {
id: '/_auth/rules'
path: '/rules'
fullPath: '/rules'
preLoaderRoute: typeof AuthRulesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings': {
id: '/_auth/settings'
path: '/settings'
@@ -205,63 +196,73 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/agents/': {
id: '/_auth/agents/'
path: '/agents'
fullPath: '/agents/'
preLoaderRoute: typeof AuthAgentsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/agents/$id': {
id: '/_auth/agents/$id'
path: '/$id'
path: '/agents/$id'
fullPath: '/agents/$id'
preLoaderRoute: typeof AuthAgentsIdRouteImport
parentRoute: typeof AuthAgentsRoute
parentRoute: typeof AuthRoute
}
'/_auth/lists/': {
id: '/_auth/lists/'
path: '/lists'
fullPath: '/lists/'
preLoaderRoute: typeof AuthListsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/lists/$id': {
id: '/_auth/lists/$id'
path: '/lists/$id'
fullPath: '/lists/$id'
preLoaderRoute: typeof AuthListsIdRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/rules/': {
id: '/_auth/rules/'
path: '/rules'
fullPath: '/rules/'
preLoaderRoute: typeof AuthRulesIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/rules/$setId': {
id: '/_auth/rules/$setId'
path: '/$setId'
path: '/rules/$setId'
fullPath: '/rules/$setId'
preLoaderRoute: typeof AuthRulesSetIdRouteImport
parentRoute: typeof AuthRulesRoute
parentRoute: typeof AuthRoute
}
}
}
interface AuthAgentsRouteChildren {
AuthAgentsIdRoute: typeof AuthAgentsIdRoute
}
const AuthAgentsRouteChildren: AuthAgentsRouteChildren = {
AuthAgentsIdRoute: AuthAgentsIdRoute,
}
const AuthAgentsRouteWithChildren = AuthAgentsRoute._addFileChildren(
AuthAgentsRouteChildren,
)
interface AuthRulesRouteChildren {
AuthRulesSetIdRoute: typeof AuthRulesSetIdRoute
}
const AuthRulesRouteChildren: AuthRulesRouteChildren = {
AuthRulesSetIdRoute: AuthRulesSetIdRoute,
}
const AuthRulesRouteWithChildren = AuthRulesRoute._addFileChildren(
AuthRulesRouteChildren,
)
interface AuthRouteChildren {
AuthAgentsRoute: typeof AuthAgentsRouteWithChildren
AuthListsRoute: typeof AuthListsRoute
AuthRulesRoute: typeof AuthRulesRouteWithChildren
AuthSettingsRoute: typeof AuthSettingsRoute
AuthStatsRoute: typeof AuthStatsRoute
AuthIndexRoute: typeof AuthIndexRoute
AuthAgentsIdRoute: typeof AuthAgentsIdRoute
AuthListsIdRoute: typeof AuthListsIdRoute
AuthRulesSetIdRoute: typeof AuthRulesSetIdRoute
AuthAgentsIndexRoute: typeof AuthAgentsIndexRoute
AuthListsIndexRoute: typeof AuthListsIndexRoute
AuthRulesIndexRoute: typeof AuthRulesIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAgentsRoute: AuthAgentsRouteWithChildren,
AuthListsRoute: AuthListsRoute,
AuthRulesRoute: AuthRulesRouteWithChildren,
AuthSettingsRoute: AuthSettingsRoute,
AuthStatsRoute: AuthStatsRoute,
AuthIndexRoute: AuthIndexRoute,
AuthAgentsIdRoute: AuthAgentsIdRoute,
AuthListsIdRoute: AuthListsIdRoute,
AuthRulesSetIdRoute: AuthRulesSetIdRoute,
AuthAgentsIndexRoute: AuthAgentsIndexRoute,
AuthListsIndexRoute: AuthListsIndexRoute,
AuthRulesIndexRoute: AuthRulesIndexRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
@@ -1,14 +1,15 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import {
BanIcon,
CheckCircle2Icon,
CpuIcon,
ClockIcon,
} from 'lucide-react'
import { PageHeader, PageShell, DetailPanel } from '@/components/reui-kit'
import { PageShell, DetailPanel } from '@/components/reui-kit'
import {
Frame,
FrameDescription,
@@ -16,7 +17,12 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import {
agentQueryOptions,
agentsQueryOptions,
@@ -36,6 +42,7 @@ import {
SelectValue,
} from '@evofw/ui/components/select'
import { Skeleton } from '@evofw/ui/components/skeleton'
import type { PolicySet } from '@evofw/shared'
export const Route = createFileRoute('/_auth/agents/$id')({
component: AgentDetailPage,
@@ -115,11 +122,81 @@ function AgentDetailPage() {
onError: (e: Error) => toast.error(e.message),
})
const setColumns: ColumnDef<PolicySet>[] = useMemo(
() => [
{
id: 'select',
enableSorting: false,
header: () => <span className="sr-only">Выбор</span>,
cell: ({ row }) => (
<Checkbox
checked={assignedIds.includes(row.original.id)}
onCheckedChange={(v) => {
setSelectedSets(
v
? [...assignedIds, row.original.id]
: assignedIds.filter((x) => x !== row.original.id),
)
}}
aria-label={`Назначить ${row.original.name}`}
/>
),
},
{
accessorKey: 'name',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Набор" />
),
cell: ({ row }) => (
<Link
to="/rules/$setId"
params={{ setId: row.original.id }}
className="min-w-0"
onClick={(e) => e.stopPropagation()}
>
<DataGridPrimaryCell
accent="primary"
title={row.original.name}
subtitle={row.original.description ?? undefined}
/>
</Link>
),
},
{
accessorKey: 'enabled',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => (
<StatusBadge
status={row.original.enabled ? 'enabled' : 'disabled'}
/>
),
},
{
accessorKey: 'rules_count',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Правила" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.rules_count ?? 0}</span>
),
},
],
[assignedIds],
)
const setsTable = useReactTable({
data: setsQ.data?.items ?? [],
columns: setColumns,
getCoreRowModel: getCoreRowModel(),
getRowId: (r) => r.id,
})
const a = agentQ.data
if (agentQ.isLoading || !a) {
return (
<PageShell>
<PageHeader title="Агент" description="Загрузка…" />
<Skeleton className="h-40 w-full rounded-xl" />
</PageShell>
)
@@ -133,23 +210,14 @@ function AgentDetailPage() {
description={`${a.platform} · gen ${a.policy_generation}`}
actions={
<div className="flex items-center gap-2">
<Badge
variant={
a.status === 'approved'
? 'success-light'
: a.status === 'pending'
? 'warning-light'
: 'secondary'
}
<StatusBadge status={a.status} />
<Button
variant="outline"
size="sm"
render={<Link to="/agents" />}
>
{a.status}
</Badge>
<Link to="/agents" className="inline-flex">
<Button variant="outline" type="button">
К списку
</Button>
</Link>
К списку
</Button>
</div>
}
/>
@@ -213,57 +281,31 @@ function AgentDetailPage() {
</FramePanel>
</Frame>
<Frame dense spacing="sm">
<Frame dense spacing="sm" className="lg:col-span-2">
<FrameHeader>
<FrameTitle>Наборы правил</FrameTitle>
<FrameDescription>
Можно назначить несколько мержатся при sync
</FrameDescription>
</FrameHeader>
<FramePanel>
<div className="flex flex-col gap-2">
{(setsQ.data?.items ?? []).map((s) => {
const checked = assignedIds.includes(s.id)
return (
<label
key={s.id}
className="flex cursor-pointer items-center gap-2 text-sm"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setSelectedSets(
v
? [...assignedIds, s.id]
: assignedIds.filter((x) => x !== s.id),
)
}}
/>
<Link
to="/rules/$setId"
params={{ setId: s.id }}
className="font-medium underline-offset-4 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{s.name}
</Link>
{!s.enabled ? (
<Badge variant="secondary" size="xs">
off
</Badge>
) : null}
</label>
)
})}
</div>
<FramePanel className="p-0">
<DataGrid
table={setsTable}
recordCount={(setsQ.data?.items ?? []).length}
tableLayout={{ dense: true }}
>
<DataGridTable />
</DataGrid>
</FramePanel>
<div className="border-t px-4 py-3">
<Button
className="mt-3"
size="sm"
disabled={saveSets.isPending || selectedSets === null}
onClick={() => saveSets.mutate()}
>
Сохранить наборы
</Button>
</FramePanel>
</div>
</Frame>
<Frame dense spacing="sm">
@@ -301,6 +343,7 @@ function AgentDetailPage() {
</Select>
</div>
<Button
size="sm"
onClick={() => addOverride.mutate()}
disabled={!cidr || addOverride.isPending}
>
@@ -337,6 +380,7 @@ function AgentDetailPage() {
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={!cloneFrom || clone.isPending}
onClick={() => clone.mutate()}
@@ -1,7 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Copy, Check } from 'lucide-react'
import { Copy, Check, 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'
@@ -17,7 +17,13 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
DataGridMutedCell,
DataGridPrimaryCell,
} from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { agentsQueryOptions, installContextQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
@@ -25,7 +31,7 @@ import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import type { Agent } from '@evofw/shared'
export const Route = createFileRoute('/_auth/agents')({
export const Route = createFileRoute('/_auth/agents/')({
component: AgentsPage,
})
@@ -36,6 +42,7 @@ function AgentsPage() {
const [name, setName] = useState('web-01')
const [filters, setFilters] = useState<Filter[]>([])
const [activeTab, setActiveTab] = useState('all')
const [deleteId, setDeleteId] = useState<string | null>(null)
const approve = useMutation({
mutationFn: (id: string) =>
@@ -61,6 +68,7 @@ function AgentsPage() {
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Удалён')
setDeleteId(null)
void qc.invalidateQueries({ queryKey: ['agents'] })
},
})
@@ -121,53 +129,68 @@ function AgentsPage() {
() => [
{
accessorKey: 'name',
header: 'Имя',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Имя" />
),
cell: ({ row }) => (
<Link
to="/agents/$id"
params={{ id: row.original.id }}
className="font-medium underline-offset-4 hover:underline"
className="min-w-0"
>
{row.original.name}
<DataGridPrimaryCell
accent="primary"
title={row.original.name}
subtitle={row.original.hostname ?? undefined}
/>
</Link>
),
},
{ accessorKey: 'platform', header: 'Платформа' },
{
accessorKey: 'platform',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Платформа" />
),
},
{
accessorKey: 'status',
header: 'Статус',
cell: ({ row }) => (
<Badge
variant={
row.original.status === 'approved'
? 'success-light'
: row.original.status === 'pending'
? 'warning-light'
: 'secondary'
}
size="sm"
>
{row.original.status}
</Badge>
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
accessorKey: 'policy_mode',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Режим" />
),
},
{ accessorKey: 'policy_mode', header: 'Режим' },
{
accessorKey: 'last_seen_at',
header: 'Seen',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Seen" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
<DataGridMutedCell>
{row.original.last_seen_at ?? '—'}
</span>
</DataGridMutedCell>
),
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const a = row.original
return (
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="outline"
render={<Link to="/agents/$id" params={{ id: a.id }} />}
>
Открыть
</Button>
{a.status === 'approved' ? (
<Button
size="sm"
@@ -178,18 +201,20 @@ function AgentsPage() {
</Button>
) : null}
<Button
size="sm"
size="icon-sm"
variant="ghost"
onClick={() => remove.mutate(a.id)}
className="text-destructive"
aria-label="Удалить"
onClick={() => setDeleteId(a.id)}
>
Удалить
<Trash2 className="size-3.5" />
</Button>
</div>
)
},
},
],
[revoke, remove],
[revoke],
)
return (
@@ -258,10 +283,11 @@ function AgentsPage() {
className="flex flex-wrap items-center justify-between gap-2 border-b py-2 last:border-0"
>
<div>
<div className="font-medium">{a.name}</div>
<div className="text-muted-foreground text-xs">
{a.platform} · {a.hostname ?? '—'} · {a.token_prefix}
</div>
<DataGridPrimaryCell
accent="primary"
title={a.name}
subtitle={`${a.platform} · ${a.hostname ?? '—'} · ${a.token_prefix}`}
/>
</div>
<div className="flex gap-2">
<Button
@@ -275,7 +301,7 @@ function AgentsPage() {
<Button
size="sm"
variant="outline"
onClick={() => remove.mutate(a.id)}
onClick={() => setDeleteId(a.id)}
>
Удалить
</Button>
@@ -316,6 +342,19 @@ function AgentsPage() {
description: 'Установите agent на сервер и одобрите запрос.',
}}
/>
<ConfirmDialog
open={deleteId !== null}
onOpenChange={(open) => {
if (!open) setDeleteId(null)
}}
title="Удалить агента?"
description="Агент и связанные назначения будут удалены."
onConfirm={() => {
if (deleteId) remove.mutate(deleteId)
}}
disabled={remove.isPending}
/>
</PageShell>
)
}
+198
View File
@@ -0,0 +1,198 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { HashIcon, RefreshCwIcon, TagIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { PageShell, DetailPanel, ResourcePage } from '@/components/reui-kit'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { listQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton'
export const Route = createFileRoute('/_auth/lists/$id')({
component: ListDetailPage,
})
type EntryRow = { id: string; cidr: string }
/**
* IP list detail — entries as DataGrid table.
* Preview: https://reui.io/preview/base/data-grid-filtering-1 · empty-state-12
*/
function ListDetailPage() {
const { id } = Route.useParams()
const qc = useQueryClient()
const listQ = useQuery(listQueryOptions(id))
const [filters, setFilters] = useState<Filter[]>([])
const refresh = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/lists/${id}/refresh`, { method: 'POST' }),
onSuccess: () => {
toast.success('Обновлено')
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
})
const entries: EntryRow[] = useMemo(
() =>
(listQ.data?.entries ?? []).map((cidr, i) => ({
id: `${i}-${cidr}`,
cidr,
})),
[listQ.data?.entries],
)
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'cidr',
label: 'Entry',
type: 'text',
placeholder: 'Поиск CIDR / домен…',
},
],
[],
)
const columns: ColumnDef<EntryRow>[] = useMemo(
() => [
{
accessorKey: 'cidr',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Entry" />
),
cell: ({ row }) => (
<DataGridPrimaryCell accent="mono" title={row.original.cidr} />
),
},
],
[],
)
if (listQ.isLoading) {
return (
<PageShell>
<Skeleton className="h-10 w-64" />
<Skeleton className="h-48 w-full" />
</PageShell>
)
}
if (!listQ.data) {
return (
<PageShell>
<DetailPanel>
<DetailPanel.Header
title="Список не найден"
actions={
<Button variant="outline" size="sm" render={<Link to="/lists" />}>
К спискам
</Button>
}
/>
</DetailPanel>
</PageShell>
)
}
const list = listQ.data
return (
<PageShell>
<DetailPanel>
<DetailPanel.Header
title={list.name}
description={`Тип ${list.type}`}
actions={
<>
<StatusBadge status={list.type} />
{list.type !== 'static' ? (
<Button
size="sm"
variant="outline"
disabled={refresh.isPending}
onClick={() => refresh.mutate()}
>
<RefreshCwIcon
className={
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
}
/>
Refresh
</Button>
) : null}
<Button variant="outline" size="sm" render={<Link to="/lists" />}>
Назад
</Button>
</>
}
/>
<DetailPanel.Metrics
cards={[
{
id: 'count',
icon: <HashIcon aria-hidden />,
label: 'Entries',
description: String(entries.length),
},
{
id: 'type',
icon: <TagIcon aria-hidden />,
label: 'Тип',
description: list.type,
},
{
id: 'refreshed',
icon: <RefreshCwIcon aria-hidden />,
label: 'Refresh',
description: list.last_error
? list.last_error
: (list.refreshed_at ?? '—'),
},
]}
/>
<DetailPanel.Section
title="Содержимое"
description="CIDR / resolved prefixes из списка."
>
<ResourcePage
title="Entries"
hideHeader
data={entries}
columns={columns}
getRowId={(r) => r.id}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={(item, field) =>
field === 'cidr' ? item.cidr : undefined
}
emptyState={{
title: 'Нет entries',
description:
list.type === 'static'
? 'Добавьте CIDR при создании или обновите список.'
: 'Нажмите Refresh или проверьте источник.',
}}
/>
</DetailPanel.Section>
{list.last_error ? (
<DataGridMutedCell className="text-destructive px-1">
{list.last_error}
</DataGridMutedCell>
) : null}
</DetailPanel>
</PageShell>
)
}
@@ -1,10 +1,15 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link, 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'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { listsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
@@ -28,11 +33,17 @@ import {
} from '@evofw/ui/components/sheet'
import type { IpList } from '@evofw/shared'
export const Route = createFileRoute('/_auth/lists')({
export const Route = createFileRoute('/_auth/lists/')({
component: ListsPage,
})
/**
* IP lists ResourcePage.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Empty: https://reui.io/preview/base/empty-state-12
*/
function ListsPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const listsQ = useQuery(listsQueryOptions())
const [sheetOpen, setSheetOpen] = useState(false)
@@ -42,6 +53,8 @@ function ListsPage() {
>('static')
const [extra, setExtra] = useState('')
const [filters, setFilters] = useState<Filter[]>([])
const [activeTab, setActiveTab] = useState('all')
const [deleteId, setDeleteId] = useState<string | null>(null)
const create = useMutation({
mutationFn: async () => {
@@ -62,17 +75,18 @@ function ListsPage() {
} else {
config.community_id = extra.trim()
}
return apiFetch('/api/v1/lists', {
return apiFetch<{ id: string }>('/api/v1/lists', {
method: 'POST',
body: JSON.stringify({ name, type, config, entries }),
})
},
onSuccess: () => {
onSuccess: (row) => {
toast.success('Список создан')
setName('')
setExtra('')
setSheetOpen(false)
void qc.invalidateQueries({ queryKey: ['lists'] })
void navigate({ to: '/lists/$id', params: { id: row.id } })
},
onError: (e: Error) => toast.error(e.message),
})
@@ -90,6 +104,8 @@ function ListsPage() {
mutationFn: (id: string) =>
apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Удалён')
setDeleteId(null)
void qc.invalidateQueries({ queryKey: ['lists'] })
},
})
@@ -120,32 +136,72 @@ function ListsPage() {
return undefined
}, [])
const tabFilter = useCallback((item: IpList, tabId: string) => {
if (tabId === 'all') return true
return item.type === tabId
}, [])
const columns: ColumnDef<IpList>[] = useMemo(
() => [
{ accessorKey: 'name', header: 'Имя' },
{ accessorKey: 'type', header: 'Тип' },
{
accessorKey: 'name',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Имя" />
),
cell: ({ row }) => (
<Link
to="/lists/$id"
params={{ id: row.original.id }}
className="min-w-0"
>
<DataGridPrimaryCell accent="primary" title={row.original.name} />
</Link>
),
},
{
accessorKey: 'type',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Тип" />
),
cell: ({ row }) => <StatusBadge status={row.original.type} />,
},
{
accessorKey: 'entry_count',
header: 'Entries',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Entries" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.entry_count ?? 0}</span>
),
},
{
id: 'refresh',
header: 'Refresh',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Refresh" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
<DataGridMutedCell>
{row.original.last_error ?? row.original.refreshed_at ?? '—'}
</span>
</DataGridMutedCell>
),
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const l = row.original
return (
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="outline"
render={
<Link to="/lists/$id" params={{ id: l.id }} />
}
>
Открыть
</Button>
{l.type !== 'static' ? (
<Button
size="sm"
@@ -156,18 +212,20 @@ function ListsPage() {
</Button>
) : null}
<Button
size="sm"
size="icon-sm"
variant="ghost"
onClick={() => remove.mutate(l.id)}
className="text-destructive"
aria-label="Удалить"
onClick={() => setDeleteId(l.id)}
>
Удалить
<Trash2 className="size-3.5" />
</Button>
</div>
)
},
},
],
[refresh, remove],
[refresh],
)
return (
@@ -176,7 +234,9 @@ function ListsPage() {
title="Списки IP"
description="static · JSON URL · domains · EvoBGP community"
actions={
<Button onClick={() => setSheetOpen(true)}>Новый список</Button>
<Button size="sm" onClick={() => setSheetOpen(true)}>
Новый список
</Button>
}
/>
@@ -191,6 +251,16 @@ function ListsPage() {
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
tabs={[
{ id: 'all', label: 'Все' },
{ id: 'static', label: 'static' },
{ id: 'domains', label: 'domains' },
{ id: 'json_url', label: 'json_url' },
{ id: 'evobgp_community', label: 'evobgp' },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={tabFilter}
isLoading={listsQ.isLoading}
isError={listsQ.isError}
error={listsQ.error}
@@ -199,11 +269,26 @@ function ListsPage() {
title: 'Пусто',
description: 'Создайте первый список.',
action: (
<Button onClick={() => setSheetOpen(true)}>Новый список</Button>
<Button size="sm" onClick={() => setSheetOpen(true)}>
Новый список
</Button>
),
}}
/>
<ConfirmDialog
open={deleteId !== null}
onOpenChange={(open) => {
if (!open) setDeleteId(null)
}}
title="Удалить список?"
description="Entries и ссылки из правил останутся неконсистентны — удаляйте осторожно."
onConfirm={() => {
if (deleteId) remove.mutate(deleteId)
}}
disabled={remove.isPending}
/>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
@@ -223,7 +308,9 @@ function ListsPage() {
<FieldLabel>Тип</FieldLabel>
<Select
value={type}
onValueChange={(v) => setType(v as typeof type)}
onValueChange={(v) => {
if (v) setType(v as typeof type)
}}
>
<SelectTrigger>
<SelectValue />
@@ -1,23 +1,23 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import {
PageHeader,
PageShell,
DetailPanel,
} from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
ListIcon,
ShieldIcon,
UsersIcon,
Trash2,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { PageShell, DetailPanel, ResourcePage } from '@/components/reui-kit'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
agentsQueryOptions,
listsQueryOptions,
policySetQueryOptions,
policySetRulesQueryOptions,
} from '@/queries'
@@ -44,10 +44,19 @@ import {
} from '@evofw/ui/components/sheet'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { PolicyRule } from '@evofw/shared'
import { listsQueryOptions } from '@/queries'
import {
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
import type { Agent, PolicyRule } from '@evofw/shared'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
export const Route = createFileRoute('/_auth/rules/$setId')({
component: PolicySetDetailPage,
@@ -56,8 +65,8 @@ export const Route = createFileRoute('/_auth/rules/$setId')({
type SourceKind = 'list' | 'cidr' | 'hostname'
/**
* Policy set detail Frame + DataGrid + sheet-8 create rule.
* Preview: https://reui.io/preview/base/sheet-8 · https://reui.io/preview/base/data-grid-filtering-2
* Policy set detail DetailPanel + ResourcePage rules + agents DataGrid.
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · stats-12 · sheet-8
*/
function PolicySetDetailPage() {
const { setId } = Route.useParams()
@@ -75,9 +84,22 @@ function PolicySetDetailPage() {
const [cidr, setCidr] = useState('')
const [hostname, setHostname] = useState('')
const [selectedAgents, setSelectedAgents] = useState<string[] | null>(null)
const [ruleFilters, setRuleFilters] = useState<Filter[]>([])
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null)
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
const approvedAgents = useMemo(
() => (agentsQ.data?.items ?? []).filter((a) => a.status === 'approved'),
[agentsQ.data?.items],
)
const agentRowSelection = useMemo<RowSelectionState>(() => {
const sel: RowSelectionState = {}
for (const id of assignedIds) sel[id] = true
return sel
}, [assignedIds])
const patchSet = useMutation({
mutationFn: (body: { enabled?: boolean; name?: string }) =>
apiFetch(`/api/v1/policy-sets/${setId}`, {
@@ -93,7 +115,6 @@ function PolicySetDetailPage() {
const saveAgents = useMutation({
mutationFn: async () => {
// Assign this set to selected agents: merge with their other sets
const allAgents = agentsQ.data?.items ?? []
await Promise.all(
allAgents.map(async (a) => {
@@ -103,9 +124,7 @@ function PolicySetDetailPage() {
const others = current.items
.map((i) => i.set_id)
.filter((id) => id !== setId)
const next = assignedIds.includes(a.id)
? [...others, setId]
: others
const next = assignedIds.includes(a.id) ? [...others, setId] : others
await apiFetch(`/api/v1/agents/${a.id}/policy-sets`, {
method: 'PUT',
body: JSON.stringify({ set_ids: next }),
@@ -153,79 +172,175 @@ function PolicySetDetailPage() {
apiFetch(`/api/v1/rules/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Удалено')
setDeleteRuleId(null)
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
},
})
const rules = rulesQ.data?.items ?? []
const columns: ColumnDef<PolicyRule>[] = useMemo(
const ruleFilterFields: FilterFieldConfig[] = useMemo(
() => [
{ accessorKey: 'priority', header: 'Prio' },
{
accessorKey: 'action',
header: 'Action',
key: 'action',
label: 'Action',
type: 'select',
options: [
{ value: 'deny', label: 'deny' },
{ value: 'allow', label: 'allow' },
],
},
{
key: 'source',
label: 'Источник',
type: 'select',
options: [
{ value: 'list', label: 'list' },
{ value: 'cidr', label: 'CIDR' },
{ value: 'hostname', label: 'DNS' },
],
},
],
[],
)
const getRuleFilterValue = useCallback((item: PolicyRule, field: string) => {
if (field === 'action') return item.action
if (field === 'source') {
if (item.hostname) return 'hostname'
if (item.cidr) return 'cidr'
return 'list'
}
return undefined
}, [])
const ruleColumns: ColumnDef<PolicyRule>[] = useMemo(
() => [
{
accessorKey: 'priority',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Prio" />
),
cell: ({ row }) => (
<Badge
variant={
row.original.action === 'deny'
? 'destructive-light'
: 'success-light'
}
size="sm"
>
{row.original.action}
</Badge>
<span className="tabular-nums">{row.original.priority}</span>
),
},
{
accessorKey: 'action',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Action" />
),
cell: ({ row }) => <StatusBadge status={row.original.action} />,
},
{
id: 'source',
header: 'Источник',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Источник" />
),
cell: ({ row }) => {
const r = row.original
if (r.hostname) {
return (
<span className="text-xs">
DNS <span className="font-mono">{r.hostname}</span>
{typeof r.resolved_count === 'number'
? ` (${r.resolved_count} IP)`
: ''}
</span>
<DataGridPrimaryCell
accent="mono"
title={r.hostname}
subtitle={
typeof r.resolved_count === 'number'
? `${r.resolved_count} IP`
: 'DNS'
}
/>
)
}
if (r.cidr) {
return <span className="font-mono text-xs">{r.cidr}</span>
return <DataGridPrimaryCell accent="mono" title={r.cidr} />
}
return (
<span className="font-mono text-xs text-muted-foreground">
list:{r.list_id?.slice(0, 8)}
</span>
<DataGridPrimaryCell
accent="mono"
title={`list:${r.list_id?.slice(0, 8) ?? '—'}`}
/>
)
},
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<div className="flex justify-end">
<Button
size="sm"
size="icon-sm"
variant="ghost"
onClick={() => removeRule.mutate(row.original.id)}
className="text-destructive"
aria-label="Удалить"
onClick={() => setDeleteRuleId(row.original.id)}
>
Удалить
<Trash2 className="size-3.5" />
</Button>
</div>
),
},
],
[removeRule],
[],
)
const table = useReactTable({
data: rules,
columns,
const agentColumns: ColumnDef<Agent>[] = useMemo(
() => [
{
id: 'select',
enableSorting: false,
header: () => <span className="sr-only">Выбор</span>,
cell: ({ row }) => (
<Checkbox
checked={assignedIds.includes(row.original.id)}
onCheckedChange={(v) => {
setSelectedAgents(
v
? [...assignedIds, row.original.id]
: assignedIds.filter((id) => id !== row.original.id),
)
}}
aria-label={`Назначить ${row.original.name}`}
/>
),
},
{
accessorKey: 'name',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Агент" />
),
cell: ({ row }) => (
<DataGridPrimaryCell
accent="primary"
title={row.original.name}
subtitle={row.original.hostname ?? undefined}
/>
),
},
{
accessorKey: 'platform',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Платформа" />
),
},
{
accessorKey: 'status',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
],
[assignedIds],
)
const agentsTable = useReactTable({
data: approvedAgents,
columns: agentColumns,
getCoreRowModel: getCoreRowModel(),
getRowId: (r) => r.id,
state: { rowSelection: agentRowSelection },
})
const canCreate =
@@ -247,8 +362,16 @@ function PolicySetDetailPage() {
if (!setQ.data) {
return (
<PageShell>
<PageHeader title="Набор не найден" />
<Button render={<Link to="/rules" />}>К списку</Button>
<DetailPanel>
<DetailPanel.Header
title="Набор не найден"
actions={
<Button variant="outline" size="sm" render={<Link to="/rules" />}>
К списку
</Button>
}
/>
</DetailPanel>
</PageShell>
)
}
@@ -257,40 +380,80 @@ function PolicySetDetailPage() {
return (
<PageShell>
<PageHeader
title={set.name}
description={set.description ?? 'Набор правил политики'}
actions={
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch
checked={set.enabled}
onCheckedChange={(v) => patchSet.mutate({ enabled: v })}
/>
<span className="text-muted-foreground text-sm">
{set.enabled ? 'Включён' : 'Выключен'}
</span>
</div>
<Button variant="outline" render={<Link to="/rules" />}>
Назад
</Button>
<Button onClick={() => setRuleOpen(true)}>Правило</Button>
</div>
}
/>
<DetailPanel>
<DetailPanel.Header
title={set.name}
description={set.description ?? 'Набор правил политики'}
actions={
<>
<div className="flex items-center gap-2">
<Switch
checked={set.enabled}
onCheckedChange={(v) => patchSet.mutate({ enabled: v })}
/>
<StatusBadge
status={set.enabled ? 'enabled' : 'disabled'}
/>
</div>
<Button variant="outline" size="sm" render={<Link to="/rules" />}>
Назад
</Button>
<Button size="sm" onClick={() => setRuleOpen(true)}>
Правило
</Button>
</>
}
/>
<DetailPanel.Metrics
cards={[
{
id: 'rules',
icon: <ShieldIcon aria-hidden />,
label: 'Правила',
description: String(set.rules_count ?? rules.length),
},
{
id: 'agents',
icon: <UsersIcon aria-hidden />,
label: 'Агенты',
description: String(set.agents_count ?? assignedIds.length),
},
{
id: 'status',
icon: <ListIcon aria-hidden />,
label: 'Статус',
description: set.enabled ? 'Включён' : 'Выключен',
},
]}
/>
<DetailPanel.Section
title="Правила"
description="Список IP, CIDR или DNS-имя (резолвится в A/AAAA)."
>
<Frame dense spacing="sm">
<FramePanel className="p-0">
<DataGrid table={table} recordCount={rules.length}>
<DataGridTable />
</DataGrid>
</FramePanel>
</Frame>
<ResourcePage
title="Правила"
hideHeader
data={rules}
columns={ruleColumns}
getRowId={(r) => r.id}
filterFields={ruleFilterFields}
filters={ruleFilters}
onFiltersChange={setRuleFilters}
onClearFilters={() => setRuleFilters([])}
getFilterFieldValue={getRuleFilterValue}
isLoading={rulesQ.isLoading}
emptyState={{
title: 'Нет правил',
description: 'Добавьте CIDR, DNS или IP-список.',
action: (
<Button size="sm" onClick={() => setRuleOpen(true)}>
Правило
</Button>
),
}}
/>
</DetailPanel.Section>
<DetailPanel.Section
@@ -304,54 +467,47 @@ function PolicySetDetailPage() {
Отметьте, каким агентам применять этот набор
</FrameDescription>
</FrameHeader>
<FramePanel>
<div className="flex flex-col gap-2">
{(agentsQ.data?.items ?? [])
.filter((a) => a.status === 'approved')
.map((a) => {
const checked = assignedIds.includes(a.id)
return (
<label
key={a.id}
className="flex cursor-pointer items-center gap-2 text-sm"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
const base = assignedIds
setSelectedAgents(
v
? [...base, a.id]
: base.filter((id) => id !== a.id),
)
}}
/>
<span className="font-medium">{a.name}</span>
<span className="text-muted-foreground text-xs">
{a.hostname ?? a.platform}
</span>
</label>
)
})}
{(agentsQ.data?.items ?? []).filter((a) => a.status === 'approved')
.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет approved-агентов
</p>
) : null}
</div>
<FramePanel className="p-0">
{approvedAgents.length === 0 ? (
<p className="text-muted-foreground p-4 text-sm">
Нет approved-агентов
</p>
) : (
<DataGrid
table={agentsTable}
recordCount={approvedAgents.length}
tableLayout={{ dense: true }}
>
<DataGridTable />
</DataGrid>
)}
</FramePanel>
<div className="border-t px-4 py-3">
<Button
className="mt-3"
size="sm"
disabled={saveAgents.isPending || selectedAgents === null}
onClick={() => saveAgents.mutate()}
>
Сохранить назначение
</Button>
</FramePanel>
</div>
</Frame>
</DetailPanel.Section>
</DetailPanel>
<ConfirmDialog
open={deleteRuleId !== null}
onOpenChange={(open) => {
if (!open) setDeleteRuleId(null)
}}
title="Удалить правило?"
description="Правило будет удалено из набора. Агенты получат новый policy generation."
onConfirm={() => {
if (deleteRuleId) removeRule.mutate(deleteRuleId)
}}
disabled={removeRule.isPending}
/>
<Sheet open={ruleOpen} onOpenChange={setRuleOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
@@ -373,7 +529,9 @@ function PolicySetDetailPage() {
<FieldLabel>Action</FieldLabel>
<Select
value={action}
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
onValueChange={(v) => {
if (v) setAction(v as 'allow' | 'deny')
}}
>
<SelectTrigger>
<SelectValue />
@@ -388,7 +546,9 @@ function PolicySetDetailPage() {
<FieldLabel>Источник</FieldLabel>
<Select
value={source}
onValueChange={(v) => setSource(v as SourceKind)}
onValueChange={(v) => {
if (v) setSource(v as SourceKind)
}}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -1,11 +1,15 @@
import { createFileRoute, Link, 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 { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { policySetsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
@@ -22,14 +26,14 @@ import {
} from '@evofw/ui/components/sheet'
import type { PolicySet } from '@evofw/shared'
export const Route = createFileRoute('/_auth/rules')({
export const Route = createFileRoute('/_auth/rules/')({
component: PolicySetsPage,
})
/**
* Policy sets list ReUI ResourcePage.
* Policy sets list ResourcePage (Frame + tabs + Filters + DataGrid).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Empty: https://reui.io/preview/base/empty-state-5
* Empty: https://reui.io/preview/base/empty-state-12
* Create sheet: https://reui.io/preview/base/sheet-8
*/
function PolicySetsPage() {
@@ -40,6 +44,8 @@ function PolicySetsPage() {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [filters, setFilters] = useState<Filter[]>([])
const [activeTab, setActiveTab] = useState('all')
const [deleteId, setDeleteId] = useState<string | null>(null)
const create = useMutation({
mutationFn: () =>
@@ -67,6 +73,7 @@ function PolicySetsPage() {
apiFetch(`/api/v1/policy-sets/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Набор удалён')
setDeleteId(null)
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
},
onError: (e: Error) => toast.error(e.message),
@@ -96,68 +103,99 @@ function PolicySetsPage() {
return undefined
}, [])
const tabFilter = useCallback((item: PolicySet, tabId: string) => {
if (tabId === 'all') return true
if (tabId === 'enabled') return item.enabled
if (tabId === 'disabled') return !item.enabled
return true
}, [])
const columns: ColumnDef<PolicySet>[] = useMemo(
() => [
{
accessorKey: 'name',
header: 'Набор',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Набор" />
),
cell: ({ row }) => (
<Link
to="/rules/$setId"
params={{ setId: row.original.id }}
className="font-medium underline-offset-4 hover:underline"
className="min-w-0"
>
{row.original.name}
<DataGridPrimaryCell
accent="primary"
title={row.original.name}
subtitle={row.original.description ?? undefined}
/>
</Link>
),
},
{
accessorKey: 'enabled',
header: 'Статус',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => (
<Badge
variant={row.original.enabled ? 'success-light' : 'secondary'}
size="sm"
>
{row.original.enabled ? 'Включён' : 'Выключен'}
</Badge>
<StatusBadge
status={row.original.enabled ? 'enabled' : 'disabled'}
/>
),
},
{
accessorKey: 'rules_count',
header: 'Правила',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Правила" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.rules_count ?? 0}</span>
),
},
{
accessorKey: 'agents_count',
header: 'Агенты',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Агенты" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.agents_count ?? 0}</span>
<span className="tabular-nums">
{row.original.agents_count ?? 0}
</span>
),
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" render={<Link to="/rules/$setId" params={{ setId: row.original.id }} />}>
<Button
size="sm"
variant="outline"
render={
<Link
to="/rules/$setId"
params={{ setId: row.original.id }}
/>
}
>
Открыть
</Button>
{row.original.id !== 'set-shared-default' ? (
<Button
size="sm"
size="icon-sm"
variant="ghost"
onClick={() => remove.mutate(row.original.id)}
className="text-destructive"
aria-label="Удалить"
onClick={() => setDeleteId(row.original.id)}
>
Удалить
<Trash2 className="size-3.5" />
</Button>
) : null}
</div>
),
},
],
[remove],
[],
)
return (
@@ -166,7 +204,9 @@ function PolicySetsPage() {
title="Наборы правил"
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
actions={
<Button onClick={() => setSheetOpen(true)}>Новый набор</Button>
<Button size="sm" onClick={() => setSheetOpen(true)}>
Новый набор
</Button>
}
/>
@@ -181,6 +221,14 @@ function PolicySetsPage() {
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
tabs={[
{ id: 'all', label: 'Все' },
{ id: 'enabled', label: 'Включён' },
{ id: 'disabled', label: 'Выключен' },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={tabFilter}
isLoading={setsQ.isLoading}
isError={setsQ.isError}
error={setsQ.error}
@@ -189,11 +237,27 @@ function PolicySetsPage() {
title: 'Нет наборов',
description: 'Создайте первый набор правил политики.',
action: (
<Button onClick={() => setSheetOpen(true)}>Новый набор</Button>
<Button size="sm" onClick={() => setSheetOpen(true)}>
Новый набор
</Button>
),
}}
/>
<ConfirmDialog
open={deleteId !== null}
onOpenChange={(open) => {
if (!open) setDeleteId(null)
}}
title="Удалить набор?"
description="Правила набора будут удалены. Назначения агентам снимутся."
confirmLabel="Удалить"
onConfirm={() => {
if (deleteId) remove.mutate(deleteId)
}}
disabled={remove.isPending}
/>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">