fix(web): открытие detail-роутов и паритет ReUI DataGrid с EvoBGP/CFDM
Sibling routes index+$id, ColumnHeader/PrimaryCell/StatusBadge/ConfirmDialog, tabs и таблица entries списков. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+102
-58
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
+286
-126
@@ -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">
|
||||
Reference in New Issue
Block a user