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
@@ -0,0 +1,38 @@
import { Router, Terminal } from 'lucide-react'
import { cn } from '@evofw/ui/lib/utils'
import { Item, ItemMedia } from '@evofw/ui/components/item'
type AgentPlatformIconProps = {
platform: string
className?: string
}
/**
* KPI-style platform tile for agent rows.
* Preview DNA: https://reui.io/preview/base/stats-12 · settings-14
*/
export function AgentPlatformIcon({
platform,
className,
}: AgentPlatformIconProps) {
const isMt = platform === 'mikrotik'
const Icon = isMt ? Router : Terminal
return (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
isMt ? 'text-info' : 'text-muted-foreground',
className,
)}
aria-label={isMt ? 'MikroTik' : 'Linux'}
>
<ItemMedia variant="icon" className="size-auto">
<Icon aria-hidden />
</ItemMedia>
</Item>
)
}
export function platformLabel(platform: string): string {
return platform === 'mikrotik' ? 'MikroTik' : 'Linux'
}
+439
View File
@@ -0,0 +1,439 @@
import * as React from "react"
import {
Children,
cloneElement,
createContext,
CSSProperties,
isValidElement,
ReactElement,
ReactNode,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useState,
} from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import {
defaultDropAnimationSideEffects,
DndContext,
DragCancelEvent,
DragEndEvent,
DragOverlay,
DragStartEvent,
DropAnimation,
KeyboardSensor,
MeasuringStrategy,
Modifiers,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DraggableSyntheticListeners,
} from "@dnd-kit/core"
import {
arrayMove,
defaultAnimateLayoutChanges,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
type AnimateLayoutChanges,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { createPortal } from "react-dom"
import { cn } from "@evofw/ui/lib/utils"
// Sortable Item Context
const SortableItemContext = createContext<{
listeners: DraggableSyntheticListeners | undefined
isDragging?: boolean
disabled?: boolean
}>({
listeners: undefined,
isDragging: false,
disabled: false,
})
const IsOverlayContext = createContext(false)
const SortableInternalContext = createContext<{
activeId: UniqueIdentifier | null
modifiers?: Modifiers
}>({
activeId: null,
modifiers: undefined,
})
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
defaultAnimateLayoutChanges({ ...args, wasDragging: true })
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
}
const MOUSE_SENSOR_OPTIONS = { activationConstraint: { distance: 10 } }
const TOUCH_SENSOR_OPTIONS = {
activationConstraint: { delay: 250, tolerance: 5 },
}
const KEYBOARD_SENSOR_OPTIONS = {
coordinateGetter: sortableKeyboardCoordinates,
}
const MEASURING_CONFIG = {
droppable: { strategy: MeasuringStrategy.Always },
}
const STRATEGY_MAP = {
horizontal: rectSortingStrategy,
grid: rectSortingStrategy,
vertical: verticalListSortingStrategy,
} as const
// Multipurpose Sortable Component
export interface SortableCommitMeta<T> {
event: DragEndEvent
activeIndex: number
overIndex: number
previousValue: T[]
}
export interface SortableRootProps<T> extends Omit<
useRender.ComponentProps<"div">,
"onDragStart" | "onDragEnd" | "children"
> {
value: T[]
onValueChange: (value: T[]) => void
getItemValue: (item: T) => string
children: ReactNode
onMove?: (event: {
event: DragEndEvent
activeIndex: number
overIndex: number
}) => void
onValueCommit?: (value: T[], meta: SortableCommitMeta<T>) => void
strategy?: "horizontal" | "vertical" | "grid"
onDragStart?: (event: DragStartEvent) => void
onDragEnd?: (event: DragEndEvent) => void
onDragCancel?: (event: DragCancelEvent) => void
accessibility?: React.ComponentProps<typeof DndContext>["accessibility"]
modifiers?: Modifiers
}
function Sortable<T>({
value,
onValueChange,
getItemValue,
className,
render,
onMove,
onValueCommit,
strategy = "vertical",
onDragStart,
onDragEnd,
onDragCancel,
accessibility,
modifiers,
children,
...props
}: SortableRootProps<T>) {
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
const [mounted, setMounted] = useState(false)
useLayoutEffect(() => setMounted(true), [])
const sensors = useSensors(
useSensor(MouseSensor, MOUSE_SENSOR_OPTIONS),
useSensor(TouchSensor, TOUCH_SENSOR_OPTIONS),
useSensor(KeyboardSensor, KEYBOARD_SENSOR_OPTIONS)
)
const handleDragStart = useCallback(
(event: DragStartEvent) => {
setActiveId(event.active.id)
onDragStart?.(event)
},
[onDragStart]
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
onDragEnd?.(event)
if (!over) return
// Handle item reordering
const activeIndex = value.findIndex(
(item: T) => getItemValue(item) === active.id
)
const overIndex = value.findIndex(
(item: T) => getItemValue(item) === over.id
)
if (activeIndex === -1 || overIndex === -1) return
if (activeIndex !== overIndex) {
if (onMove) {
onMove({ event, activeIndex, overIndex })
} else {
const newValue = arrayMove(value, activeIndex, overIndex)
onValueChange(newValue)
onValueCommit?.(newValue, {
event,
activeIndex,
overIndex,
previousValue: value,
})
}
}
},
[value, getItemValue, onValueChange, onMove, onDragEnd, onValueCommit]
)
const handleDragCancel = useCallback(
(event: DragCancelEvent) => {
setActiveId(null)
onDragCancel?.(event)
},
[onDragCancel]
)
const itemIds = useMemo(() => {
const ids = value.map(getItemValue)
if (process.env.NODE_ENV !== "production") {
const seen = new Set<string>()
for (const id of ids) {
if (seen.has(id)) {
console.warn(
`[Sortable] Duplicate item id "${id}". Item ids must be unique, or drag and drop will misbehave.`
)
break
}
seen.add(id)
}
}
return ids
}, [value, getItemValue])
const contextValue = useMemo(
() => ({ activeId, modifiers }),
[activeId, modifiers]
)
const defaultProps = {
"data-slot": "sortable",
"data-dragging": activeId !== null,
className: cn(activeId !== null && "cursor-grabbing!", className),
children,
}
// Find the active child for the overlay
const overlayContent = useMemo(() => {
if (!activeId) return null
let result: ReactNode = null
Children.forEach(children, (child) => {
if (isValidElement(child) && (child.props as any).value === activeId) {
result = cloneElement(child as ReactElement<any>, {
...(child.props as any),
className: cn((child.props as any).className, "z-50"),
})
}
})
return result
}, [activeId, children])
return (
<SortableInternalContext.Provider value={contextValue}>
<DndContext
sensors={sensors}
modifiers={modifiers}
accessibility={accessibility}
measuring={MEASURING_CONFIG}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext
items={itemIds}
strategy={STRATEGY_MAP[strategy] ?? verticalListSortingStrategy}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</SortableContext>
{mounted &&
createPortal(
<DragOverlay
dropAnimation={dropAnimationConfig}
modifiers={modifiers}
className={cn("z-50", activeId && "cursor-grabbing")}
>
<IsOverlayContext.Provider value={true}>
{overlayContent}
</IsOverlayContext.Provider>
</DragOverlay>,
document.body
)}
</DndContext>
</SortableInternalContext.Provider>
)
}
export interface SortableItemProps extends useRender.ComponentProps<"div"> {
value: string
disabled?: boolean
}
function SortableItem({
value,
className,
render,
disabled,
...props
}: SortableItemProps) {
const isOverlay = useContext(IsOverlayContext)
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging: isSortableDragging,
} = useSortable({
id: value,
disabled: disabled || isOverlay,
animateLayoutChanges,
})
const style = {
transition,
transform: CSS.Transform.toString(transform),
} as CSSProperties
const defaultProps = isOverlay
? {
"data-slot": "sortable-item",
"data-value": value,
"data-dragging": true,
className: cn(className),
children: props.children,
}
: {
"data-slot": "sortable-item",
"data-value": value,
"data-dragging": isSortableDragging,
"data-disabled": disabled,
ref: setNodeRef,
style,
...attributes,
className: cn(
isSortableDragging && "opacity-50 z-50",
disabled && "opacity-50",
className
),
children: props.children,
}
return (
<SortableItemContext.Provider
value={
isOverlay
? { listeners: undefined, isDragging: true, disabled: false }
: { listeners, isDragging: isSortableDragging, disabled }
}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</SortableItemContext.Provider>
)
}
export interface SortableItemHandleProps extends useRender.ComponentProps<"div"> {
cursor?: boolean
}
function SortableItemHandle({
className,
render,
cursor = true,
...props
}: SortableItemHandleProps) {
const { listeners, isDragging, disabled } = useContext(SortableItemContext)
const defaultProps = {
"data-slot": "sortable-item-handle",
"data-dragging": isDragging,
"data-disabled": disabled,
...listeners,
className: cn(
cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"),
className
),
children: props.children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
export interface SortableOverlayProps extends Omit<
React.ComponentProps<typeof DragOverlay>,
"children"
> {
children?: ReactNode | ((params: { value: UniqueIdentifier }) => ReactNode)
}
function SortableOverlay({
children,
className,
...props
}: SortableOverlayProps) {
const { activeId, modifiers } = useContext(SortableInternalContext)
const [mounted, setMounted] = useState(false)
useLayoutEffect(() => setMounted(true), [])
const content =
activeId && children
? typeof children === "function"
? children({ value: activeId })
: children
: null
if (!mounted) return null
return createPortal(
<DragOverlay
dropAnimation={dropAnimationConfig}
modifiers={modifiers}
className={cn("z-50", activeId && "cursor-grabbing", className)}
{...props}
>
<IsOverlayContext.Provider value={true}>
{content}
</IsOverlayContext.Provider>
</DragOverlay>,
document.body
)
}
export { Sortable, SortableItem, SortableItemHandle, SortableOverlay }
@@ -0,0 +1,231 @@
import { useEffect, useState } from 'react'
import {
BanIcon,
GripVerticalIcon,
ShieldCheckIcon,
Trash2,
} from 'lucide-react'
import { toast } from 'sonner'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { PolicyRule } from '@evofw/shared'
import {
Sortable,
SortableItem,
SortableItemHandle,
} from '@/components/reui/sortable'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@evofw/ui/components/button'
import { Switch } from '@evofw/ui/components/switch'
import { Item, ItemMedia } from '@evofw/ui/components/item'
import { cn } from '@evofw/ui/lib/utils'
import { apiFetch } from '@/lib/api'
/**
* Ordered firewall rules — ReUI Sortable + settings-8 DNA.
* Preview: https://reui.io/preview/base/components/c-sortable-5
* · https://reui.io/preview/base/settings-8
* Docs: https://reui.io/docs/components/base/sortable
*/
function ruleTarget(r: PolicyRule): string {
if (r.cidr) return r.cidr
if (r.hostname) return r.hostname
if (r.list_id) return `list:${r.list_id.slice(0, 8)}`
return '—'
}
type PolicyRulesSortableProps = {
setId: string
rules: PolicyRule[]
policyMode: 'blacklist' | 'whitelist'
onDelete: (id: string) => void
}
export function PolicyRulesSortable({
setId,
rules: rulesProp,
policyMode,
onDelete,
}: PolicyRulesSortableProps) {
const qc = useQueryClient()
const [items, setItems] = useState(rulesProp)
useEffect(() => {
setItems(rulesProp)
}, [rulesProp])
const reorder = useMutation({
mutationFn: (ordered_ids: string[]) =>
apiFetch(`/api/v1/policy-sets/${setId}/rules/reorder`, {
method: 'PUT',
body: JSON.stringify({ ordered_ids }),
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
},
onError: (e: Error, _vars, context) => {
toast.error(e.message)
if (context && typeof context === 'object' && 'prev' in context) {
setItems((context as { prev: PolicyRule[] }).prev)
}
},
onMutate: async (ordered_ids) => {
const prev = items
const byId = new Map(items.map((r) => [r.id, r]))
setItems(ordered_ids.map((id) => byId.get(id)!).filter(Boolean))
return { prev }
},
})
const toggle = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
apiFetch(`/api/v1/rules/${id}`, {
method: 'PATCH',
body: JSON.stringify({ enabled }),
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
},
onError: (e: Error) => toast.error(e.message),
})
const isWl = policyMode === 'whitelist'
return (
<div className="flex flex-col gap-3">
<Frame dense spacing="sm">
<FramePanel className="flex items-center gap-3 py-3">
<Badge
variant={isWl ? 'destructive-light' : 'success-light'}
size="sm"
>
{isWl ? 'DROP' : 'ACCEPT'}
</Badge>
<p className="text-muted-foreground text-sm">
{isWl
? 'По умолчанию DROP — ниже только allow-правила пропускают трафик'
: 'По умолчанию ACCEPT — ниже deny-правила блокируют адреса'}
</p>
</FramePanel>
</Frame>
{items.length === 0 ? (
<Frame dense spacing="sm">
<FramePanel className="text-muted-foreground py-8 text-center text-sm">
Нет правил добавьте CIDR, список или hostname
</FramePanel>
</Frame>
) : (
<Frame dense spacing="sm" stacked>
<FrameHeader className="px-4 py-3">
<FrameTitle>Правила</FrameTitle>
<FrameDescription>
Перетащите для порядка · Switch вкл/выкл
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<Sortable
value={items}
onValueChange={setItems}
getItemValue={(r) => r.id}
onValueCommit={(next, meta) => {
reorder.mutate(
next.map((r) => r.id),
{ onError: () => setItems(meta.previousValue) },
)
}}
className="flex flex-col"
>
{items.map((r) => {
const enabled = r.enabled !== false
const isDeny = r.action === 'deny'
return (
<SortableItem
key={r.id}
value={r.id}
className={cn(
'border-border flex items-center gap-3 border-b px-3 py-2.5 last:border-b-0',
!enabled && 'opacity-60',
)}
>
<SortableItemHandle className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
<GripVerticalIcon className="size-4" />
</SortableItemHandle>
<Item
className={cn(
'bg-muted flex size-9 shrink-0 items-center justify-center border-0 p-0 [&_svg]:size-4',
isDeny ? 'text-destructive' : 'text-success',
)}
>
<ItemMedia variant="icon" className="size-auto">
{isDeny ? (
<BanIcon aria-hidden />
) : (
<ShieldCheckIcon aria-hidden />
)}
</ItemMedia>
</Item>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium font-mono text-sm">
{ruleTarget(r)}
</span>
<Badge
variant={
isDeny ? 'destructive-light' : 'success-light'
}
size="xs"
>
{r.action}
</Badge>
{!enabled ? (
<Badge variant="secondary" size="xs">
Выкл
</Badge>
) : null}
</div>
{r.comment ? (
<span className="text-muted-foreground truncate text-xs">
{r.comment}
</span>
) : null}
</div>
<Switch
checked={enabled}
onCheckedChange={(v) =>
toggle.mutate({ id: r.id, enabled: v })
}
aria-label={enabled ? 'Выключить' : 'Включить'}
/>
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
onClick={() => onDelete(r.id)}
>
<Trash2 className="size-3.5" />
</Button>
</SortableItem>
)
})}
</Sortable>
</FramePanel>
</Frame>
)}
</div>
)
}