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
+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' },