feat(policy): именованные наборы правил с DNS и M:N привязкой к агентам
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m44s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Правила живут в policy_sets; evaluate мержит назначенные наборы; источник list|CIDR|hostname с кэшем DNS.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 22:11:03 +07:00
co-authored by Cursor
parent cad05659b1
commit 0bed4367c7
18 changed files with 1524 additions and 312 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ const overviewNav = [
const opsNav = [
{ to: '/agents', label: 'Агенты', icon: ServerIcon, exact: false },
{ to: '/lists', label: 'Списки IP', icon: ListIcon, exact: false },
{ to: '/rules', label: 'Правила', icon: ShieldIcon, exact: false },
{ to: '/rules', label: 'Наборы правил', icon: ShieldIcon, exact: false },
{ to: '/stats', label: 'Статистика', icon: BarChart3Icon, exact: false },
] as const
@@ -47,8 +47,8 @@ const NAV_ITEMS = [
},
{
to: '/rules',
label: 'Правила',
keywords: ['rules', 'правила', 'policy'],
label: 'Наборы правил',
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
icon: ShieldIcon,
},
{
@@ -22,7 +22,7 @@ const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/agents': 'Агенты',
'/lists': 'Списки IP',
'/rules': 'Правила',
'/rules': 'Наборы правил',
'/stats': 'Статистика',
'/settings': 'Настройки',
}
@@ -42,6 +42,13 @@ function getBreadcrumbs(
]
}
if (pathname.match(/^\/rules\/[^/]+$/)) {
return [
{ label: 'Наборы правил', href: '/rules' },
{ label: dynamicLabels[pathname] ?? 'Набор', href: pathname },
]
}
const title = routeTitles[pathname]
if (title) {
return [{ label: title, href: pathname }]
@@ -11,7 +11,7 @@ import {
agentsQueryOptions,
dashboardQueryOptions,
listsQueryOptions,
rulesQueryOptions,
policySetsQueryOptions,
} from '@/queries'
type MonitorMetric = {
@@ -80,7 +80,10 @@ export function SystemMonitorPopover() {
const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 })
const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 })
const listsQ = useQuery({ ...listsQueryOptions(), refetchInterval: 60_000 })
const rulesQ = useQuery({ ...rulesQueryOptions(), refetchInterval: 60_000 })
const setsQ = useQuery({
...policySetsQueryOptions(),
refetchInterval: 60_000,
})
const d = dashQ.data
const agents = agentsQ.data?.items ?? []
@@ -90,7 +93,8 @@ export function SystemMonitorPopover() {
d?.agents_pending ?? agents.filter((a) => a.status === 'pending').length
const onlinePct = approved > 0 ? Math.round((online / approved) * 100) : 0
const listsCount = listsQ.data?.items?.length ?? d?.lists_total ?? 0
const rulesCount = rulesQ.data?.items?.length ?? 0
const rulesCount =
setsQ.data?.items.reduce((n, s) => n + (s.rules_count ?? 0), 0) ?? 0
const apiOk = !dashQ.isError
const metrics = useMemo<MonitorMetric[]>(
+40 -4
View File
@@ -1,6 +1,12 @@
import { queryOptions } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import type { Agent, DashboardStats, IpList, PolicyRule } from '@evofw/shared'
import type {
Agent,
DashboardStats,
IpList,
PolicyRule,
PolicySet,
} from '@evofw/shared'
export const dashboardQueryOptions = () =>
queryOptions({
@@ -26,15 +32,45 @@ export const listsQueryOptions = () =>
queryFn: () => apiFetch<{ items: IpList[] }>('/api/v1/lists'),
})
export const rulesQueryOptions = (agentId?: string) =>
export const policySetsQueryOptions = () =>
queryOptions({
queryKey: ['rules', agentId ?? 'all'],
queryKey: ['policy-sets'],
queryFn: () => apiFetch<{ items: PolicySet[] }>('/api/v1/policy-sets'),
})
export const policySetQueryOptions = (id: string) =>
queryOptions({
queryKey: ['policy-sets', id],
queryFn: () =>
apiFetch<PolicySet & { agent_ids: string[] }>(
`/api/v1/policy-sets/${id}`,
),
})
export const policySetRulesQueryOptions = (setId: string) =>
queryOptions({
queryKey: ['policy-sets', setId, 'rules'],
queryFn: () =>
apiFetch<{ items: PolicyRule[] }>(
`/api/v1/rules${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ''}`,
`/api/v1/policy-sets/${setId}/rules`,
),
})
export const agentPolicySetsQueryOptions = (agentId: string) =>
queryOptions({
queryKey: ['agents', agentId, 'policy-sets'],
queryFn: () =>
apiFetch<{
items: {
set_id: string
sort: number
name: string
description?: string | null
enabled: boolean
}[]
}>(`/api/v1/agents/${agentId}/policy-sets`),
})
export const installContextQueryOptions = () =>
queryOptions({
queryKey: ['install-context'],
+36 -5
View File
@@ -18,6 +18,7 @@ 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'
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
@@ -63,38 +64,46 @@ const AuthAgentsIdRoute = AuthAgentsIdRouteImport.update({
path: '/$id',
getParentRoute: () => AuthAgentsRoute,
} as any)
const AuthRulesSetIdRoute = AuthRulesSetIdRouteImport.update({
id: '/$setId',
path: '/$setId',
getParentRoute: () => AuthRulesRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof AuthIndexRoute
'/agents': typeof AuthAgentsRouteWithChildren
'/lists': typeof AuthListsRoute
'/rules': typeof AuthRulesRoute
'/rules': typeof AuthRulesRouteWithChildren
'/settings': typeof AuthSettingsRoute
'/stats': typeof AuthStatsRoute
'/auth/callback': typeof AuthCallbackRoute
'/agents/$id': typeof AuthAgentsIdRoute
'/rules/$setId': typeof AuthRulesSetIdRoute
}
export interface FileRoutesByTo {
'/agents': typeof AuthAgentsRouteWithChildren
'/lists': typeof AuthListsRoute
'/rules': typeof AuthRulesRoute
'/rules': typeof AuthRulesRouteWithChildren
'/settings': typeof AuthSettingsRoute
'/stats': typeof AuthStatsRoute
'/auth/callback': typeof AuthCallbackRoute
'/': typeof AuthIndexRoute
'/agents/$id': typeof AuthAgentsIdRoute
'/rules/$setId': typeof AuthRulesSetIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_auth': typeof AuthRouteWithChildren
'/_auth/agents': typeof AuthAgentsRouteWithChildren
'/_auth/lists': typeof AuthListsRoute
'/_auth/rules': typeof AuthRulesRoute
'/_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/rules/$setId': typeof AuthRulesSetIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -107,6 +116,7 @@ export interface FileRouteTypes {
| '/stats'
| '/auth/callback'
| '/agents/$id'
| '/rules/$setId'
fileRoutesByTo: FileRoutesByTo
to:
| '/agents'
@@ -117,6 +127,7 @@ export interface FileRouteTypes {
| '/auth/callback'
| '/'
| '/agents/$id'
| '/rules/$setId'
id:
| '__root__'
| '/_auth'
@@ -128,6 +139,7 @@ export interface FileRouteTypes {
| '/auth/callback'
| '/_auth/'
| '/_auth/agents/$id'
| '/_auth/rules/$setId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -200,6 +212,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthAgentsIdRouteImport
parentRoute: typeof AuthAgentsRoute
}
'/_auth/rules/$setId': {
id: '/_auth/rules/$setId'
path: '/$setId'
fullPath: '/rules/$setId'
preLoaderRoute: typeof AuthRulesSetIdRouteImport
parentRoute: typeof AuthRulesRoute
}
}
}
@@ -215,10 +234,22 @@ 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 AuthRulesRoute
AuthRulesRoute: typeof AuthRulesRouteWithChildren
AuthSettingsRoute: typeof AuthSettingsRoute
AuthStatsRoute: typeof AuthStatsRoute
AuthIndexRoute: typeof AuthIndexRoute
@@ -227,7 +258,7 @@ interface AuthRouteChildren {
const AuthRouteChildren: AuthRouteChildren = {
AuthAgentsRoute: AuthAgentsRouteWithChildren,
AuthListsRoute: AuthListsRoute,
AuthRulesRoute: AuthRulesRoute,
AuthRulesRoute: AuthRulesRouteWithChildren,
AuthSettingsRoute: AuthSettingsRoute,
AuthStatsRoute: AuthStatsRoute,
AuthIndexRoute: AuthIndexRoute,
+89 -65
View File
@@ -1,8 +1,7 @@
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 { useEffect, useState } from 'react'
import {
BanIcon,
CheckCircle2Icon,
@@ -21,10 +20,12 @@ import { Badge } from '@/components/reui/badge'
import {
agentQueryOptions,
agentsQueryOptions,
rulesQueryOptions,
agentPolicySetsQueryOptions,
policySetsQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Checkbox } from '@evofw/ui/components/checkbox'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import {
@@ -34,10 +35,6 @@ import {
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
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 { Skeleton } from '@evofw/ui/components/skeleton'
export const Route = createFileRoute('/_auth/agents/$id')({
@@ -48,11 +45,20 @@ function AgentDetailPage() {
const { id } = Route.useParams()
const qc = useQueryClient()
const agentQ = useQuery(agentQueryOptions(id))
const rulesQ = useQuery(rulesQueryOptions(id))
const setsQ = useQuery(policySetsQueryOptions())
const assignedQ = useQuery(agentPolicySetsQueryOptions(id))
const agentsQ = useQuery(agentsQueryOptions())
const [cidr, setCidr] = useState('')
const [action, setAction] = useState<'allow' | 'deny'>('deny')
const [cloneFrom, setCloneFrom] = useState('')
const [selectedSets, setSelectedSets] = useState<string[] | null>(null)
useEffect(() => {
setSelectedSets(null)
}, [id, assignedQ.data])
const assignedIds =
selectedSets ?? assignedQ.data?.items.map((i) => i.set_id) ?? []
const patchMode = useMutation({
mutationFn: (policy_mode: 'blacklist' | 'whitelist') =>
@@ -80,6 +86,21 @@ function AgentDetailPage() {
onError: (e: Error) => toast.error(e.message),
})
const saveSets = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/policy-sets`, {
method: 'PUT',
body: JSON.stringify({ set_ids: assignedIds }),
}),
onSuccess: () => {
toast.success('Наборы сохранены')
setSelectedSets(null)
void qc.invalidateQueries({ queryKey: ['agents', id] })
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
},
onError: (e: Error) => toast.error(e.message),
})
const clone = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/clone-from/${cloneFrom}`, {
@@ -87,52 +108,13 @@ function AgentDetailPage() {
body: JSON.stringify({ include_overrides: true }),
}),
onSuccess: () => {
toast.success('Правила скопированы')
void qc.invalidateQueries({ queryKey: ['rules'] })
void qc.invalidateQueries({ queryKey: ['agents'] })
toast.success('Наборы скопированы')
void qc.invalidateQueries({ queryKey: ['agents', id] })
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
},
onError: (e: Error) => toast.error(e.message),
})
const ruleColumns: ColumnDef<PolicyRule>[] = useMemo(
() => [
{ accessorKey: 'priority', header: 'Prio' },
{
accessorKey: 'action',
header: 'Action',
cell: ({ row }) => (
<Badge
variant={
row.original.action === 'deny'
? 'destructive-light'
: 'success-light'
}
size="sm"
>
{row.original.action}
</Badge>
),
},
{
id: 'source',
header: 'Source',
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.cidr ?? row.original.list_id ?? '—'}
</span>
),
},
],
[],
)
const rulesTable = useReactTable({
data: rulesQ.data?.items ?? [],
columns: ruleColumns,
getCoreRowModel: getCoreRowModel(),
getRowId: (r) => r.id,
})
const a = agentQ.data
if (agentQ.isLoading || !a) {
return (
@@ -231,6 +213,59 @@ function AgentDetailPage() {
</FramePanel>
</Frame>
<Frame dense spacing="sm">
<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>
<Button
className="mt-3"
disabled={saveSets.isPending || selectedSets === null}
onClick={() => saveSets.mutate()}
>
Сохранить наборы
</Button>
</FramePanel>
</Frame>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Мгновенный IP override</FrameTitle>
@@ -277,7 +312,10 @@ function AgentDetailPage() {
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Копировать правила</FrameTitle>
<FrameTitle>Копировать наборы</FrameTitle>
<FrameDescription>
Копирует назначения наборов (+ overrides) с другого агента
</FrameDescription>
</FrameHeader>
<FramePanel>
<div className="flex flex-col gap-3">
@@ -308,20 +346,6 @@ function AgentDetailPage() {
</div>
</FramePanel>
</Frame>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Правила агента</FrameTitle>
</FrameHeader>
<FramePanel className="p-0">
<DataGrid
table={rulesTable}
recordCount={rulesQ.data?.items?.length ?? 0}
>
<DataGridTable />
</DataGrid>
</FramePanel>
</Frame>
</div>
</DetailPanel.Section>
</DetailPanel>
+461
View File
@@ -0,0 +1,461 @@
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'
import {
agentsQueryOptions,
policySetQueryOptions,
policySetRulesQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Checkbox } from '@evofw/ui/components/checkbox'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
import { Switch } from '@evofw/ui/components/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} 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 { Skeleton } from '@evofw/ui/components/skeleton'
export const Route = createFileRoute('/_auth/rules/$setId')({
component: PolicySetDetailPage,
})
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
*/
function PolicySetDetailPage() {
const { setId } = Route.useParams()
const qc = useQueryClient()
const setQ = useQuery(policySetQueryOptions(setId))
const rulesQ = useQuery(policySetRulesQueryOptions(setId))
const agentsQ = useQuery(agentsQueryOptions())
const listsQ = useQuery(listsQueryOptions())
const [ruleOpen, setRuleOpen] = useState(false)
const [priority, setPriority] = useState('100')
const [action, setAction] = useState<'allow' | 'deny'>('deny')
const [source, setSource] = useState<SourceKind>('cidr')
const [listId, setListId] = useState('')
const [cidr, setCidr] = useState('')
const [hostname, setHostname] = useState('')
const [selectedAgents, setSelectedAgents] = useState<string[] | null>(null)
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
const patchSet = useMutation({
mutationFn: (body: { enabled?: boolean; name?: string }) =>
apiFetch(`/api/v1/policy-sets/${setId}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
onSuccess: () => {
toast.success('Набор обновлён')
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
},
onError: (e: Error) => toast.error(e.message),
})
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) => {
const current = await apiFetch<{
items: { set_id: string }[]
}>(`/api/v1/agents/${a.id}/policy-sets`)
const others = current.items
.map((i) => i.set_id)
.filter((id) => id !== setId)
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 }),
})
}),
)
},
onSuccess: () => {
toast.success('Назначение агентов сохранено')
setSelectedAgents(null)
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const createRule = useMutation({
mutationFn: () => {
const body: Record<string, unknown> = {
set_id: setId,
priority: Number(priority),
action,
}
if (source === 'list') body.list_id = listId
if (source === 'cidr') body.cidr = cidr.trim()
if (source === 'hostname') body.hostname = hostname.trim()
return apiFetch('/api/v1/rules', {
method: 'POST',
body: JSON.stringify(body),
})
},
onSuccess: () => {
toast.success('Правило создано')
setRuleOpen(false)
setCidr('')
setHostname('')
setListId('')
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
},
onError: (e: Error) => toast.error(e.message),
})
const removeRule = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/rules/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Удалено')
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
},
})
const rules = rulesQ.data?.items ?? []
const columns: ColumnDef<PolicyRule>[] = useMemo(
() => [
{ accessorKey: 'priority', header: 'Prio' },
{
accessorKey: 'action',
header: 'Action',
cell: ({ row }) => (
<Badge
variant={
row.original.action === 'deny'
? 'destructive-light'
: 'success-light'
}
size="sm"
>
{row.original.action}
</Badge>
),
},
{
id: 'source',
header: 'Источник',
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>
)
}
if (r.cidr) {
return <span className="font-mono text-xs">{r.cidr}</span>
}
return (
<span className="font-mono text-xs text-muted-foreground">
list:{r.list_id?.slice(0, 8)}
</span>
)
},
},
{
id: 'actions',
cell: ({ row }) => (
<div className="flex justify-end">
<Button
size="sm"
variant="ghost"
onClick={() => removeRule.mutate(row.original.id)}
>
Удалить
</Button>
</div>
),
},
],
[removeRule],
)
const table = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (r) => r.id,
})
const canCreate =
source === 'list'
? Boolean(listId)
: source === 'cidr'
? Boolean(cidr.trim())
: Boolean(hostname.trim())
if (setQ.isLoading) {
return (
<PageShell>
<Skeleton className="h-10 w-64" />
<Skeleton className="h-48 w-full" />
</PageShell>
)
}
if (!setQ.data) {
return (
<PageShell>
<PageHeader title="Набор не найден" />
<Button render={<Link to="/rules" />}>К списку</Button>
</PageShell>
)
}
const set = setQ.data
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.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>
</DetailPanel.Section>
<DetailPanel.Section
title="Назначено агентам"
description="Агент может иметь несколько наборов — они мержатся при sync."
>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Агенты</FrameTitle>
<FrameDescription>
Отметьте, каким агентам применять этот набор
</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>
<Button
className="mt-3"
disabled={saveAgents.isPending || selectedAgents === null}
onClick={() => saveAgents.mutate()}
>
Сохранить назначение
</Button>
</FramePanel>
</Frame>
</DetailPanel.Section>
</DetailPanel>
<Sheet open={ruleOpen} onOpenChange={setRuleOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Новое правило</SheetTitle>
<SheetDescription>
Один источник: список, CIDR или DNS-имя
</SheetDescription>
</SheetHeader>
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="prio">Priority</FieldLabel>
<Input
id="prio"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</Field>
<Field>
<FieldLabel>Action</FieldLabel>
<Select
value={action}
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">deny</SelectItem>
<SelectItem value="allow">allow</SelectItem>
</SelectContent>
</Select>
</Field>
<Field className="sm:col-span-2">
<FieldLabel>Источник</FieldLabel>
<Select
value={source}
onValueChange={(v) => setSource(v as SourceKind)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cidr">CIDR / IP</SelectItem>
<SelectItem value="hostname">DNS-имя</SelectItem>
<SelectItem value="list">IP-список</SelectItem>
</SelectContent>
</Select>
</Field>
{source === 'list' ? (
<Field className="sm:col-span-2">
<FieldLabel>Список</FieldLabel>
<Select
value={listId || null}
onValueChange={(v) => setListId(v ?? '')}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Выберите список" />
</SelectTrigger>
<SelectContent>
{(listsQ.data?.items ?? []).map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
) : null}
{source === 'cidr' ? (
<Field className="sm:col-span-2">
<FieldLabel htmlFor="cidr">CIDR</FieldLabel>
<Input
id="cidr"
value={cidr}
onChange={(e) => setCidr(e.target.value)}
placeholder="203.0.113.0/24"
/>
</Field>
) : null}
{source === 'hostname' ? (
<Field className="sm:col-span-2">
<FieldLabel htmlFor="host">DNS-имя</FieldLabel>
<Input
id="host"
value={hostname}
onChange={(e) => setHostname(e.target.value)}
placeholder="bad.example.com"
/>
</Field>
) : null}
</div>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setRuleOpen(false)}>
Отмена
</Button>
<Button
disabled={!canCreate || createRule.isPending}
onClick={() => createRule.mutate()}
>
Создать
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</PageShell>
)
}
+106 -136
View File
@@ -1,4 +1,4 @@
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 { useCallback, useMemo, useState } from 'react'
@@ -6,22 +6,12 @@ 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 {
rulesQueryOptions,
listsQueryOptions,
agentsQueryOptions,
} from '@/queries'
import { policySetsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import { Textarea } from '@evofw/ui/components/textarea'
import {
Sheet,
SheetContent,
@@ -30,117 +20,139 @@ import {
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import type { PolicyRule } from '@evofw/shared'
import type { PolicySet } from '@evofw/shared'
export const Route = createFileRoute('/_auth/rules')({
component: RulesPage,
component: PolicySetsPage,
})
function RulesPage() {
/**
* Policy sets list — ReUI ResourcePage.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Empty: https://reui.io/preview/base/empty-state-5
* Create sheet: https://reui.io/preview/base/sheet-8
*/
function PolicySetsPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const rulesQ = useQuery(rulesQueryOptions())
const listsQ = useQuery(listsQueryOptions())
const agentsQ = useQuery(agentsQueryOptions())
const setsQ = useQuery(policySetsQueryOptions())
const [sheetOpen, setSheetOpen] = useState(false)
const [priority, setPriority] = useState('100')
const [action, setAction] = useState<'allow' | 'deny'>('deny')
const [listId, setListId] = useState('')
const [agentId, setAgentId] = useState('tenant')
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [filters, setFilters] = useState<Filter[]>([])
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/rules', {
apiFetch<PolicySet>('/api/v1/policy-sets', {
method: 'POST',
body: JSON.stringify({
priority: Number(priority),
action,
list_id: listId || null,
agent_id: agentId === 'tenant' ? null : agentId,
name,
description: description.trim() || null,
enabled: true,
}),
}),
onSuccess: () => {
toast.success('Правило создано')
onSuccess: (row) => {
toast.success('Набор создан')
setName('')
setDescription('')
setSheetOpen(false)
void qc.invalidateQueries({ queryKey: ['rules'] })
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
},
onError: (e: Error) => toast.error(e.message),
})
const remove = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/rules/${id}`, { method: 'DELETE' }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['rules'] }),
apiFetch(`/api/v1/policy-sets/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Набор удалён')
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
},
onError: (e: Error) => toast.error(e.message),
})
const items = rulesQ.data?.items ?? []
const items = setsQ.data?.items ?? []
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{ key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' },
{
key: 'action',
label: 'Action',
key: 'enabled',
label: 'Статус',
type: 'select',
options: [
{ value: 'deny', label: 'deny' },
{ value: 'allow', label: 'allow' },
{ value: 'true', label: 'Включён' },
{ value: 'false', label: 'Выключен' },
],
},
],
[],
)
const getFilterFieldValue = useCallback((item: PolicyRule, field: string) => {
if (field === 'action') return item.action
const getFilterFieldValue = useCallback((item: PolicySet, field: string) => {
if (field === 'name') return item.name
if (field === 'enabled') return String(item.enabled)
return undefined
}, [])
const columns: ColumnDef<PolicyRule>[] = useMemo(
const columns: ColumnDef<PolicySet>[] = useMemo(
() => [
{ accessorKey: 'priority', header: 'Prio' },
{
accessorKey: 'action',
header: 'Action',
accessorKey: 'name',
header: 'Набор',
cell: ({ row }) => (
<Link
to="/rules/$setId"
params={{ setId: row.original.id }}
className="font-medium underline-offset-4 hover:underline"
>
{row.original.name}
</Link>
),
},
{
accessorKey: 'enabled',
header: 'Статус',
cell: ({ row }) => (
<Badge
variant={
row.original.action === 'deny'
? 'destructive-light'
: 'success-light'
}
variant={row.original.enabled ? 'success-light' : 'secondary'}
size="sm"
>
{row.original.action}
{row.original.enabled ? 'Включён' : 'Выключен'}
</Badge>
),
},
{
accessorKey: 'agent_id',
header: 'Agent',
accessorKey: 'rules_count',
header: 'Правила',
cell: ({ row }) => (
<span className="text-xs">{row.original.agent_id ?? 'tenant'}</span>
<span className="tabular-nums">{row.original.rules_count ?? 0}</span>
),
},
{
id: 'source',
header: 'List / CIDR',
accessorKey: 'agents_count',
header: 'Агенты',
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.cidr ?? row.original.list_id ?? '—'}
</span>
<span className="tabular-nums">{row.original.agents_count ?? 0}</span>
),
},
{
id: 'actions',
cell: ({ row }) => (
<div className="flex justify-end">
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(row.original.id)}
>
Удалить
<div className="flex justify-end gap-1">
<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"
variant="ghost"
onClick={() => remove.mutate(row.original.id)}
>
Удалить
</Button>
) : null}
</div>
),
},
@@ -151,15 +163,15 @@ function RulesPage() {
return (
<PageShell>
<PageHeader
title="Правила"
description="Упорядоченные allow/deny по списку или CIDR"
title="Наборы правил"
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
actions={
<Button onClick={() => setSheetOpen(true)}>Новое правило</Button>
<Button onClick={() => setSheetOpen(true)}>Новый набор</Button>
}
/>
<ResourcePage
title="Правила"
title="Наборы"
hideHeader
data={items}
columns={columns}
@@ -169,15 +181,15 @@ function RulesPage() {
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
isLoading={rulesQ.isLoading}
isError={rulesQ.isError}
error={rulesQ.error}
onRetry={() => void rulesQ.refetch()}
isLoading={setsQ.isLoading}
isError={setsQ.isError}
error={setsQ.error}
onRetry={() => void setsQ.refetch()}
emptyState={{
title: 'Нет правил',
description: 'Создайте первое правило политики.',
title: 'Нет наборов',
description: 'Создайте первый набор правил политики.',
action: (
<Button onClick={() => setSheetOpen(true)}>Новое правило</Button>
<Button onClick={() => setSheetOpen(true)}>Новый набор</Button>
),
}}
/>
@@ -185,71 +197,29 @@ function RulesPage() {
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Новое правило</SheetTitle>
<SheetDescription>Priority + action + list scope</SheetDescription>
<SheetTitle>Новый набор</SheetTitle>
<SheetDescription>
После создания добавьте правила и назначьте набор агентам.
</SheetDescription>
</SheetHeader>
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4 sm:grid-cols-2">
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
<Field>
<FieldLabel htmlFor="rule-priority">Priority</FieldLabel>
<FieldLabel htmlFor="set-name">Имя</FieldLabel>
<Input
id="rule-priority"
value={priority}
onChange={(e) => setPriority(e.target.value)}
id="set-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Web deny"
/>
</Field>
<Field>
<FieldLabel>Action</FieldLabel>
<Select
value={action}
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">deny</SelectItem>
<SelectItem value="allow">allow</SelectItem>
</SelectContent>
</Select>
</Field>
<Field className="sm:col-span-2">
<FieldLabel>Список</FieldLabel>
<Select
value={listId || null}
onValueChange={(v) => setListId(v ?? '')}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="IP list" />
</SelectTrigger>
<SelectContent>
{(listsQ.data?.items ?? []).map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field className="sm:col-span-2">
<FieldLabel>Scope</FieldLabel>
<Select
value={agentId}
onValueChange={(v) => {
if (v) setAgentId(v)
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tenant">Tenant default</SelectItem>
{(agentsQ.data?.items ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldLabel htmlFor="set-desc">Описание</FieldLabel>
<Textarea
id="set-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</Field>
</div>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
@@ -257,7 +227,7 @@ function RulesPage() {
Отмена
</Button>
<Button
disabled={!listId || create.isPending}
disabled={!name.trim() || create.isPending}
onClick={() => create.mutate()}
>
Создать