feat(api): unify policy handling with default action updates
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m53s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Updated `evofw-firewall.sh` and related scripts to replace `policy_mode` with `default_action`, enhancing clarity and consistency in policy management.
- Adjusted agent routes and evaluation logic to accommodate the new default action structure, ensuring backward compatibility with legacy modes.
- Enhanced tests to validate the new default action behavior and its integration within the agent policy framework.
- Refactored related components in the web interface to align with the updated policy handling, improving user experience and reducing confusion around policy modes.
This commit is contained in:
Denozordec
2026-07-23 10:52:28 +07:00
parent a6eb21a10d
commit 1f7273f38d
30 changed files with 1469 additions and 621 deletions
+77 -155
View File
@@ -1,31 +1,20 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useMemo, useRef, useState } from 'react'
import { useRef, useState } from 'react'
import {
BanIcon,
CheckCircle2Icon,
CircleAlertIcon,
ClockIcon,
Copy,
CpuIcon,
CopyPlusIcon,
ShieldOffIcon,
CpuIcon,
MoreHorizontalIcon,
ShieldPlusIcon,
TerminalIcon,
} from 'lucide-react'
import {
DetailPanel,
PageShell,
QuickActionGrid,
} from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { DetailPanel, PageShell } from '@/components/reui-kit'
import {
Alert,
AlertDescription,
@@ -36,21 +25,34 @@ import {
AgentPlatformIcon,
platformLabel,
} from '@/components/agents/agent-platform-icon'
import { AgentLifecycleTimeline } from '@/components/agents/agent-lifecycle-timeline'
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
import {
AgentCloneSetsSheet,
AgentOverrideSheet,
} from '@/components/agents/agent-settings-sheets'
import { agentQueryOptions } from '@/queries'
import {
agentPreviewQueryOptions,
agentQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu'
/**
* Agent detail — Solutions Agents DNA.
* Preview: https://reui.io/preview/base/solution-agents-3 · stats-12 · sheet-8 · c-sortable-5
* Agent detail — SA3 layout DNA (Header + Trace 2/3 + Facts 1/3).
* Preview: https://reui.io/preview/base/solution-agents-3
* · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/blocks/solutions/agents
*/
export const Route = createFileRoute('/_auth/agents/$id')({
@@ -58,6 +60,7 @@ export const Route = createFileRoute('/_auth/agents/$id')({
const agent = await queryClient.ensureQueryData(
agentQueryOptions(params.id),
)
void queryClient.ensureQueryData(agentPreviewQueryOptions(params.id))
return { breadcrumb: agent.name }
},
component: AgentDetailPage,
@@ -68,6 +71,7 @@ function AgentDetailPage() {
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const agentQ = useQuery(agentQueryOptions(id))
const previewQ = useQuery(agentPreviewQueryOptions(id))
const installRef = useRef<HTMLDivElement>(null)
const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false)
@@ -94,68 +98,6 @@ function AgentDetailPage() {
const a = agentQ.data
const quickActions = useMemo(() => {
if (!a) return []
const actions = [
{
id: 'override',
title: 'IP override',
description: 'Allow/deny поверх политики',
icon: <ShieldPlusIcon aria-hidden />,
iconClassName: 'text-warning [&_svg]:text-current',
badgeLabel: 'Открыть',
onSelect: () => setOverrideOpen(true),
},
{
id: 'clone',
title: 'Копировать наборы',
description: 'С другого агента + overrides',
icon: <CopyPlusIcon aria-hidden />,
iconClassName: 'text-info [&_svg]:text-current',
badgeLabel: 'Открыть',
onSelect: () => setCloneOpen(true),
},
{
id: 'install',
title: 'Install curl',
description: a.install_curl ? 'Скопировать one-liner' : 'Недоступен',
icon: <TerminalIcon aria-hidden />,
iconClassName: 'text-primary [&_svg]:text-current',
badgeLabel: 'Копировать',
onSelect: () => {
if (a.install_curl) {
copyToClipboard(a.install_curl)
toast.success('Скопировано')
}
installRef.current?.scrollIntoView({ behavior: 'smooth' })
},
},
]
if (a.status === 'pending') {
actions.push({
id: 'approve',
title: 'Approve',
description: 'Выдать политику агенту',
icon: <CheckCircle2Icon aria-hidden />,
iconClassName: 'text-success [&_svg]:text-current',
badgeLabel: 'Выполнить',
onSelect: () => approve.mutate(),
})
}
if (a.status === 'approved') {
actions.push({
id: 'revoke',
title: 'Revoke',
description: 'Отозвать доступ агента',
icon: <ShieldOffIcon aria-hidden />,
iconClassName: 'text-destructive [&_svg]:text-current',
badgeLabel: 'Выполнить',
onSelect: () => revoke.mutate(),
})
}
return actions
}, [a, approve, copyToClipboard, revoke])
if (agentQ.isLoading || !a) {
return (
<PageShell>
@@ -171,6 +113,7 @@ function AgentDetailPage() {
a.hostname,
platformLabel(a.platform),
`gen ${a.policy_generation}`,
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
]
.filter(Boolean)
.join(' · ')
@@ -217,13 +160,44 @@ function AgentDetailPage() {
Install
</Button>
) : null}
<Button
variant="outline"
size="sm"
render={<Link to="/agents" />}
>
К списку
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Ещё" />
}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOverrideOpen(true)}>
<ShieldPlusIcon className="size-4" />
IP override
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setCloneOpen(true)}>
<CopyPlusIcon className="size-4" />
Копировать наборы
</DropdownMenuItem>
{a.install_curl ? (
<DropdownMenuItem
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Скопировано')
installRef.current?.scrollIntoView({
behavior: 'smooth',
})
}}
>
<TerminalIcon className="size-4" />
Install curl
</DropdownMenuItem>
) : null}
<DropdownMenuItem
render={<Link to="/agents" />}
>
К списку
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
@@ -272,78 +246,26 @@ function AgentDetailPage() {
]}
/>
<QuickActionGrid actions={quickActions} />
<DetailPanel.Section>
<div className="grid gap-4 lg:grid-cols-2">
<AgentLifecycleTimeline agent={a} />
<div className="@container flex flex-col gap-4">
<div className="grid gap-4 @4xl:grid-cols-3">
<div className="@4xl:col-span-2">
<AgentPolicyTrace
preview={previewQ.data}
isLoading={previewQ.isLoading}
/>
</div>
<AgentFactsPanel agent={a} />
</div>
<div ref={installRef}>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Install / identity</FrameTitle>
<FrameDescription>
Copy one-liner · hostname · token
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3">
{a.install_curl ? (
<div className="flex flex-col gap-2">
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
{a.install_curl}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
</div>
) : (
<p className="text-muted-foreground text-sm">
Install curl недоступен
</p>
)}
<div className="text-muted-foreground grid gap-1 text-sm">
<div>
Hostname:{' '}
<span className="text-foreground">
{a.hostname ?? '—'}
</span>
</div>
<div>
Last seen IP:{' '}
<span className="text-foreground">
{a.last_seen_ip ?? '—'}
</span>
</div>
<div>
Client:{' '}
<span className="text-foreground">
{a.client_version ?? '—'}
</span>
</div>
<div>
Token prefix:{' '}
<span className="text-foreground font-mono">
{a.token_prefix}
</span>
</div>
</div>
</FramePanel>
</Frame>
</div>
<div className="lg:col-span-2">
<AgentPolicySetsSortable agentId={id} />
</div>
<AgentEffectiveCidrs
preview={previewQ.data}
isLoading={previewQ.isLoading}
/>
</div>
</DetailPanel.Section>
</DetailPanel>
+52 -8
View File
@@ -13,8 +13,16 @@ import {
listTabFilter,
} from '@/components/lists/lists-columns'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { listsQueryOptions } from '@/queries'
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import {
Autocomplete,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
} from '@/components/reui/autocomplete'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
@@ -58,8 +66,6 @@ const CREATE_SOURCE_ITEMS = [
function ListsPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const listsQ = useQuery(listsQueryOptions())
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
const [source, setSource] = useState<CreateSource>('static')
@@ -69,6 +75,21 @@ function ListsPage() {
const [activeTab, setActiveTab] = useState('all')
const [deleteListId, setDeleteListId] = useState<string | null>(null)
const listsQ = useQuery(listsQueryOptions())
const communitiesQ = useQuery({
...evobgpCommunitiesQueryOptions(),
enabled: createOpen && source === 'evobgp_community',
})
const communityItems = useMemo(
() =>
(communitiesQ.data?.items ?? []).map((c) => ({
value: c.id,
label: c.title ? `${c.community} · ${c.title}` : c.community,
})),
[communitiesQ.data?.items],
)
const create = useMutation({
mutationFn: async () => {
const config: Record<string, unknown> = {}
@@ -273,12 +294,35 @@ function ListsPage() {
) : null}
{source === 'evobgp_community' ? (
<Field>
<FieldLabel htmlFor="list-comm">Community ID</FieldLabel>
<Input
id="list-comm"
<FieldLabel>BGP community</FieldLabel>
<Autocomplete
items={communityItems}
value={extra}
onChange={(e) => setExtra(e.target.value)}
/>
onValueChange={setExtra}
>
<AutocompleteInput
placeholder={
communitiesQ.isError
? 'ID вручную (EvoBGP недоступен)'
: 'Поиск community…'
}
showClear
/>
<AutocompleteContent>
<AutocompleteEmpty>
{communitiesQ.isLoading
? 'Загрузка…'
: 'Нет совпадений'}
</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>
{item.label}
</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
</Field>
) : null}
</div>
+2 -18
View File
@@ -14,7 +14,6 @@ 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 { PolicyModeToggle } from '@/components/rules/policy-mode-toggle'
import {
agentsQueryOptions,
listsQueryOptions,
@@ -103,11 +102,7 @@ function PolicySetDetailPage() {
}, [assignedIds])
const patchSet = useMutation({
mutationFn: (body: {
enabled?: boolean
name?: string
policy_mode?: 'blacklist' | 'whitelist'
}) =>
mutationFn: (body: { enabled?: boolean; name?: string }) =>
apiFetch(`/api/v1/policy-sets/${setId}`, {
method: 'PATCH',
body: JSON.stringify(body),
@@ -277,8 +272,6 @@ function PolicySetDetailPage() {
}
const set = setQ.data
const policyMode =
set.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist'
return (
<PageShell>
@@ -339,19 +332,10 @@ function PolicySetDetailPage() {
]}
/>
<DetailPanel.Section>
<PolicyModeToggle
value={policyMode}
disabled={patchSet.isPending}
onChange={(mode) => patchSet.mutate({ policy_mode: mode })}
/>
</DetailPanel.Section>
<DetailPanel.Section>
<PolicyRulesSortable
setId={setId}
rules={rules}
policyMode={policyMode}
onDelete={(id) => setDeleteRuleId(id)}
onAdd={() => setRuleOpen(true)}
/>
@@ -359,7 +343,7 @@ function PolicySetDetailPage() {
<DetailPanel.Section
title="Назначено агентам"
description="Все наборы агента должны иметь один режим фильтра."
description="Агенты, которым применён этот набор."
>
<Frame dense spacing="sm">
<FrameHeader>
+1 -22
View File
@@ -9,7 +9,6 @@ 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 { PolicySetIcon } from '@/components/rules/policy-set-icon'
import { policySetsQueryOptions } from '@/queries'
@@ -120,7 +119,7 @@ function PolicySetsPage() {
),
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-3">
<PolicySetIcon mode={row.original.policy_mode} />
<PolicySetIcon />
<DataGridPrimaryCell
accent="primary"
title={row.original.name}
@@ -140,26 +139,6 @@ 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 }) => (