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