feat(api, web): implement policy mode management for agents and rules
- 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user