feat(api, web): implement policy mode management for agents and rules
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m46s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added support for policy modes ('blacklist' and 'whitelist') in agent and policy set management.
- Updated API endpoints to handle policy mode during agent assignment and rule operations.
- Enhanced the web UI to display and manage policy modes for agents and rules, ensuring all assigned sets share a consistent mode.
- Introduced new validation to enforce single policy mode across assigned sets for agents.
- Improved error handling for policy mode conflicts and updated documentation accordingly.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 02:16:51 +07:00
co-authored by Cursor
parent d5784b9f35
commit 90d50c2556
15 changed files with 1261 additions and 266 deletions
+46 -30
View File
@@ -18,6 +18,11 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import {
AgentPlatformIcon,
platformLabel,
} from '@/components/agents/agent-platform-icon'
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'
@@ -67,16 +72,14 @@ function AgentDetailPage() {
const assignedIds =
selectedSets ?? assignedQ.data?.items.map((i) => i.set_id) ?? []
const patchMode = useMutation({
mutationFn: (policy_mode: 'blacklist' | 'whitelist') =>
apiFetch(`/api/v1/agents/${id}`, {
method: 'PATCH',
body: JSON.stringify({ policy_mode }),
}),
const revoke = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Режим обновлён')
toast.success('Агент отозван')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const addOverride = useMutation({
@@ -207,10 +210,21 @@ function AgentDetailPage() {
<DetailPanel>
<DetailPanel.Header
title={a.name}
description={`${a.platform} · gen ${a.policy_generation}`}
description={`${platformLabel(a.platform)} · gen ${a.policy_generation}`}
actions={
<div className="flex items-center gap-2">
<AgentPlatformIcon platform={a.platform} />
<StatusBadge status={a.status} />
{a.status === 'approved' ? (
<Button
variant="outline"
size="sm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
Revoke
</Button>
) : null}
<Button
variant="outline"
size="sm"
@@ -261,30 +275,32 @@ function AgentDetailPage() {
<div className="grid gap-4 lg:grid-cols-2">
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Политика</FrameTitle>
<FrameTitle>Режим фильтра</FrameTitle>
<FrameDescription>
blacklist = deny set; whitelist = allow set + default drop
Задаётся наборами правил (не на агенте). Все назначенные
наборы должны иметь один режим.
</FrameDescription>
</FrameHeader>
<FramePanel>
<div className="flex flex-wrap gap-2">
<Button
variant={
a.policy_mode === 'blacklist' ? 'default' : 'outline'
}
onClick={() => patchMode.mutate('blacklist')}
>
Blacklist
</Button>
<Button
variant={
a.policy_mode === 'whitelist' ? 'default' : 'outline'
}
onClick={() => patchMode.mutate('whitelist')}
>
Whitelist
</Button>
</div>
<FramePanel className="flex flex-wrap items-center gap-2">
<Badge
variant={
a.policy_mode === 'whitelist'
? 'warning-light'
: 'secondary'
}
size="sm"
>
{a.policy_mode === 'whitelist'
? 'Белый список'
: 'Чёрный список'}
</Badge>
<Button
size="sm"
variant="outline"
render={<Link to="/rules" />}
>
Открыть правила
</Button>
</FramePanel>
</Frame>
@@ -292,7 +308,7 @@ function AgentDetailPage() {
<FrameHeader>
<FrameTitle>Наборы правил</FrameTitle>
<FrameDescription>
Можно назначить несколько мержатся при sync
Можно назначить несколько мержатся при sync (один режим)
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
+55 -64
View File
@@ -1,8 +1,8 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Check, Copy, Plus, Trash2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { Check, Copy, Pencil, Plus, Trash2 } from 'lucide-react'
import { useCallback, useMemo, useState, type MouseEvent } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
@@ -18,6 +18,10 @@ import {
import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
import {
AgentPlatformIcon,
platformLabel,
} from '@/components/agents/agent-platform-icon'
import { agentsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
@@ -32,15 +36,15 @@ import type { Agent } from '@evofw/shared'
/**
* Agents list — ResourcePage (Frame + tabs + Filters + DataGrid).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Copyable install: https://reui.io/preview/base/settings-14
* Row icon + copyable install: https://reui.io/preview/base/settings-14 · stats-12
* Empty: https://reui.io/preview/base/empty-state-7
* Create Sheet: https://reui.io/preview/base/sheet-1 · sheet-8
*/
export const Route = createFileRoute('/_auth/agents/')({
component: AgentsPage,
})
function AgentsPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions())
const { copyToClipboard } = useCopyToClipboard()
@@ -59,16 +63,6 @@ function AgentsPage() {
onError: (e: Error) => toast.error(e.message),
})
const revoke = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент отозван')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const remove = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
@@ -127,7 +121,8 @@ function AgentsPage() {
}, [])
const handleCopyCurl = useCallback(
(curl: string) => {
(curl: string, e?: MouseEvent) => {
e?.stopPropagation()
if (!curl) return
copyToClipboard(curl)
toast.success('Скопировано')
@@ -142,25 +137,19 @@ function AgentsPage() {
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Имя" />
),
cell: ({ row }) => (
<Link
to="/agents/$id"
params={{ id: row.original.id }}
className="min-w-0"
>
<DataGridPrimaryCell
accent="primary"
title={row.original.name}
subtitle={row.original.hostname ?? undefined}
/>
</Link>
),
},
{
accessorKey: 'platform',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Платформа" />
),
cell: ({ row }) => {
const a = row.original
return (
<div className="flex min-w-0 items-center gap-3">
<AgentPlatformIcon platform={a.platform} />
<DataGridPrimaryCell
accent="primary"
title={a.name}
subtitle={a.hostname ?? platformLabel(a.platform)}
/>
</div>
)
},
},
{
accessorKey: 'status',
@@ -188,7 +177,7 @@ function AgentsPage() {
size="sm"
variant="outline"
className="max-w-[14rem] font-mono text-xs"
onClick={() => handleCopyCurl(curl)}
onClick={(e) => handleCopyCurl(curl, e)}
/>
}
>
@@ -202,12 +191,6 @@ function AgentsPage() {
)
},
},
{
accessorKey: 'policy_mode',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Режим" />
),
},
{
accessorKey: 'last_seen_at',
header: ({ column }) => (
@@ -229,36 +212,41 @@ function AgentsPage() {
<div className="flex justify-end gap-1">
{a.status === 'pending' ? (
<Button
size="sm"
onClick={() => approve.mutate(a.id)}
size="icon-sm"
variant="ghost"
aria-label="Approve"
disabled={approve.isPending}
onClick={(e) => {
e.stopPropagation()
approve.mutate(a.id)
}}
>
<Check data-icon="inline-start" />
Approve
</Button>
) : null}
<Button
size="sm"
variant="outline"
render={<Link to="/agents/$id" params={{ id: a.id }} />}
>
Открыть
</Button>
{a.status === 'approved' ? (
<Button
size="sm"
variant="outline"
onClick={() => revoke.mutate(a.id)}
>
Revoke
<Check className="size-3.5" />
</Button>
) : null}
<Button
size="icon-sm"
variant="ghost"
aria-label="Открыть"
onClick={(e) => {
e.stopPropagation()
void navigate({
to: '/agents/$id',
params: { id: a.id },
})
}}
>
<Pencil className="size-3.5" />
</Button>
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
onClick={() => setDeleteId(a.id)}
onClick={(e) => {
e.stopPropagation()
setDeleteId(a.id)
}}
>
<Trash2 className="size-3.5" />
</Button>
@@ -267,7 +255,7 @@ function AgentsPage() {
},
},
],
[approve, revoke, handleCopyCurl],
[approve, handleCopyCurl, navigate],
)
const addButton = (
@@ -296,6 +284,9 @@ function AgentsPage() {
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
onRowClick={(row) =>
void navigate({ to: '/agents/$id', params: { id: row.id } })
}
tabs={[
{ id: 'all', label: 'Все' },
{ id: 'invited', label: 'Invited' },
+82 -160
View File
@@ -5,16 +5,16 @@ import {
ListIcon,
ShieldIcon,
UsersIcon,
Trash2,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { PageShell, DetailPanel, ResourcePage } from '@/components/reui-kit'
import { PageShell, DetailPanel } from '@/components/reui-kit'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
import { Badge } from '@/components/reui/badge'
import {
agentsQueryOptions,
listsQueryOptions,
@@ -44,11 +44,8 @@ import {
} from '@evofw/ui/components/sheet'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
import type { Agent, PolicyRule } from '@evofw/shared'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { Agent } from '@evofw/shared'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
Frame,
@@ -65,8 +62,9 @@ export const Route = createFileRoute('/_auth/rules/$setId')({
type SourceKind = 'list' | 'cidr' | 'hostname'
/**
* Policy set detail — DetailPanel + ResourcePage rules + agents DataGrid.
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · stats-12 · sheet-8
* Policy set detail — Sortable rules (ReUI PRO) + agents.
* Preview: https://reui.io/preview/base/components/c-sortable-5 · settings-8 · settings-3
* Docs: https://reui.io/docs/components/base/sortable
*/
function PolicySetDetailPage() {
const { setId } = Route.useParams()
@@ -77,14 +75,12 @@ function PolicySetDetailPage() {
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 [ruleFilters, setRuleFilters] = useState<Filter[]>([])
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null)
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
@@ -101,7 +97,11 @@ function PolicySetDetailPage() {
}, [assignedIds])
const patchSet = useMutation({
mutationFn: (body: { enabled?: boolean; name?: string }) =>
mutationFn: (body: {
enabled?: boolean
name?: string
policy_mode?: 'blacklist' | 'whitelist'
}) =>
apiFetch(`/api/v1/policy-sets/${setId}`, {
method: 'PATCH',
body: JSON.stringify(body),
@@ -109,6 +109,7 @@ function PolicySetDetailPage() {
onSuccess: () => {
toast.success('Набор обновлён')
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
@@ -145,7 +146,6 @@ function PolicySetDetailPage() {
mutationFn: () => {
const body: Record<string, unknown> = {
set_id: setId,
priority: Number(priority),
action,
}
if (source === 'list') body.list_id = listId
@@ -179,112 +179,6 @@ function PolicySetDetailPage() {
const rules = rulesQ.data?.items ?? []
const ruleFilterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'action',
label: 'Action',
type: 'select',
options: [
{ value: 'deny', label: 'deny' },
{ value: 'allow', label: 'allow' },
],
},
{
key: 'source',
label: 'Источник',
type: 'select',
options: [
{ value: 'list', label: 'list' },
{ value: 'cidr', label: 'CIDR' },
{ value: 'hostname', label: 'DNS' },
],
},
],
[],
)
const getRuleFilterValue = useCallback((item: PolicyRule, field: string) => {
if (field === 'action') return item.action
if (field === 'source') {
if (item.hostname) return 'hostname'
if (item.cidr) return 'cidr'
return 'list'
}
return undefined
}, [])
const ruleColumns: ColumnDef<PolicyRule>[] = useMemo(
() => [
{
accessorKey: 'priority',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Prio" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.priority}</span>
),
},
{
accessorKey: 'action',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Action" />
),
cell: ({ row }) => <StatusBadge status={row.original.action} />,
},
{
id: 'source',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Источник" />
),
cell: ({ row }) => {
const r = row.original
if (r.hostname) {
return (
<DataGridPrimaryCell
accent="mono"
title={r.hostname}
subtitle={
typeof r.resolved_count === 'number'
? `${r.resolved_count} IP`
: 'DNS'
}
/>
)
}
if (r.cidr) {
return <DataGridPrimaryCell accent="mono" title={r.cidr} />
}
return (
<DataGridPrimaryCell
accent="mono"
title={`list:${r.list_id?.slice(0, 8) ?? '—'}`}
/>
)
},
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<div className="flex justify-end">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
onClick={() => setDeleteRuleId(row.original.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
),
},
],
[],
)
const agentColumns: ColumnDef<Agent>[] = useMemo(
() => [
{
@@ -377,6 +271,8 @@ function PolicySetDetailPage() {
}
const set = setQ.data
const policyMode =
set.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist'
return (
<PageShell>
@@ -425,7 +321,9 @@ function PolicySetDetailPage() {
{
id: 'status',
icon: <ListIcon aria-hidden />,
iconClassName: set.enabled ? 'text-success' : 'text-muted-foreground',
iconClassName: set.enabled
? 'text-success'
: 'text-muted-foreground',
label: 'Статус',
description: set.enabled ? 'Включён' : 'Выключен',
hint: set.enabled ? 'active' : 'disabled',
@@ -433,37 +331,69 @@ function PolicySetDetailPage() {
]}
/>
<DetailPanel.Section>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Режим фильтра</FrameTitle>
<FrameDescription>
Blacklist: блокировать deny. Whitelist: пропускать только
allow, остальное (forward) DROP.
</FrameDescription>
</FrameHeader>
<FramePanel>
<div className="flex flex-wrap gap-2">
<Button
variant={
policyMode === 'blacklist' ? 'default' : 'outline'
}
size="sm"
onClick={() =>
patchSet.mutate({ policy_mode: 'blacklist' })
}
>
Чёрный список
</Button>
<Button
variant={
policyMode === 'whitelist' ? 'default' : 'outline'
}
size="sm"
onClick={() =>
patchSet.mutate({ policy_mode: 'whitelist' })
}
>
Белый список
</Button>
<Badge
variant={
policyMode === 'whitelist'
? 'warning-light'
: 'secondary'
}
size="sm"
>
{policyMode}
</Badge>
</div>
</FramePanel>
</Frame>
</DetailPanel.Section>
<DetailPanel.Section
title="Правила"
description="Список IP, CIDR или DNS-имя (резолвится в A/AAAA)."
description="DnD порядок · Switch вкл/выкл. Preview: c-sortable-5 · settings-8"
>
<ResourcePage
title="Правила"
hideHeader
data={rules}
columns={ruleColumns}
getRowId={(r) => r.id}
filterFields={ruleFilterFields}
filters={ruleFilters}
onFiltersChange={setRuleFilters}
onClearFilters={() => setRuleFilters([])}
getFilterFieldValue={getRuleFilterValue}
isLoading={rulesQ.isLoading}
emptyState={{
title: 'Нет правил',
description: 'Добавьте CIDR, DNS или IP-список.',
action: (
<Button size="sm" onClick={() => setRuleOpen(true)}>
Правило
</Button>
),
}}
<PolicyRulesSortable
setId={setId}
rules={rules}
policyMode={policyMode}
onDelete={(id) => setDeleteRuleId(id)}
/>
</DetailPanel.Section>
<DetailPanel.Section
title="Назначено агентам"
description="Агент может иметь несколько наборов — они мержатся при sync."
description="Все наборы агента должны иметь один режим фильтра."
>
<Frame dense spacing="sm">
<FrameHeader>
@@ -518,18 +448,10 @@ function PolicySetDetailPage() {
<SheetHeader className="shrink-0">
<SheetTitle>Новое правило</SheetTitle>
<SheetDescription>
Один источник: список, CIDR или DNS-имя
Один источник: список, CIDR или DNS-имя (priority в конец)
</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>
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
<Field>
<FieldLabel>Action</FieldLabel>
<Select
@@ -538,7 +460,7 @@ function PolicySetDetailPage() {
if (v) setAction(v as 'allow' | 'deny')
}}
>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -547,7 +469,7 @@ function PolicySetDetailPage() {
</SelectContent>
</Select>
</Field>
<Field className="sm:col-span-2">
<Field>
<FieldLabel>Источник</FieldLabel>
<Select
value={source}
@@ -566,7 +488,7 @@ function PolicySetDetailPage() {
</Select>
</Field>
{source === 'list' ? (
<Field className="sm:col-span-2">
<Field>
<FieldLabel>Список</FieldLabel>
<Select
value={listId || null}
@@ -586,7 +508,7 @@ function PolicySetDetailPage() {
</Field>
) : null}
{source === 'cidr' ? (
<Field className="sm:col-span-2">
<Field>
<FieldLabel htmlFor="cidr">CIDR</FieldLabel>
<Input
id="cidr"
@@ -597,7 +519,7 @@ function PolicySetDetailPage() {
</Field>
) : null}
{source === 'hostname' ? (
<Field className="sm:col-span-2">
<Field>
<FieldLabel htmlFor="host">DNS-имя</FieldLabel>
<Input
id="host"
+21
View File
@@ -9,6 +9,7 @@ import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { policySetsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
@@ -142,6 +143,26 @@ function PolicySetsPage() {
/>
),
},
{
accessorKey: 'policy_mode',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Режим" />
),
cell: ({ row }) => (
<Badge
variant={
row.original.policy_mode === 'whitelist'
? 'warning-light'
: 'secondary'
}
size="sm"
>
{row.original.policy_mode === 'whitelist'
? 'whitelist'
: 'blacklist'}
</Badge>
),
},
{
accessorKey: 'rules_count',
header: ({ column }) => (