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,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'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user