feat(web): enhance quick action grid and resource page with search functionality
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m50s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Updated QuickActionItem interface to support optional `onSelect` handler and `badgeLabel`.
- Refactored QuickActionGrid to conditionally render links or buttons based on the presence of a `to` property.
- Introduced search functionality in ResourcePage, allowing users to filter items based on a search query.
- Added search input to the ResourcePage toolbar, improving user experience for data management.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 04:03:32 +07:00
co-authored by Cursor
parent 0653313a57
commit 33d301b191
11 changed files with 1082 additions and 431 deletions
@@ -0,0 +1,275 @@
import { useState } from 'react'
import { Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
agentOverridesQueryOptions,
agentsQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { Badge } from '@/components/reui/badge'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
import { ScrollArea } from '@evofw/ui/components/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
/**
* Agent settings sheets — override IP + clone sets.
* Preview: https://reui.io/preview/base/sheet-8 · sheet-1
* Docs: https://ui.shadcn.com/docs/components/base/sheet
*/
type OverrideSheetProps = {
agentId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function AgentOverrideSheet({
agentId,
open,
onOpenChange,
}: OverrideSheetProps) {
const qc = useQueryClient()
const overridesQ = useQuery({
...agentOverridesQueryOptions(agentId),
enabled: open,
})
const [cidr, setCidr] = useState('')
const [action, setAction] = useState<'allow' | 'deny'>('deny')
const add = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/overrides`, {
method: 'POST',
body: JSON.stringify({ cidr, action }),
}),
onSuccess: () => {
toast.success('Override добавлен — подхватится на следующей итерации sync')
setCidr('')
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
},
onError: (e: Error) => toast.error(e.message),
})
const remove = useMutation({
mutationFn: (overrideId: string) =>
apiFetch(`/api/v1/agents/${agentId}/overrides/${overrideId}`, {
method: 'DELETE',
}),
onSuccess: () => {
toast.success('Override удалён')
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
},
onError: (e: Error) => toast.error(e.message),
})
const items = overridesQ.data?.items ?? []
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>IP override</SheetTitle>
<SheetDescription>
Мгновенные allow/deny поверх политики. Обновятся на агенте на
следующей итерации sync (~1 мин).
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="flex flex-col gap-4 py-2 pb-4">
<div className="grid gap-3">
<Field>
<FieldLabel htmlFor="ov-cidr">CIDR / IP</FieldLabel>
<Input
id="ov-cidr"
placeholder="1.2.3.4/32"
value={cidr}
onChange={(e) => setCidr(e.target.value)}
/>
</Field>
<Field>
<FieldLabel>Действие</FieldLabel>
<Select
value={action}
onValueChange={(v) => {
if (v) setAction(v as 'allow' | 'deny')
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">deny</SelectItem>
<SelectItem value="allow">allow</SelectItem>
</SelectContent>
</Select>
</Field>
<Button
size="sm"
className="self-start"
disabled={!cidr.trim() || add.isPending}
onClick={() => add.mutate()}
>
Добавить
</Button>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">
Активные{' '}
<Badge variant="secondary" size="sm">
{items.length}
</Badge>
</p>
{items.length === 0 ? (
<p className="text-muted-foreground text-sm">Пока нет overrides</p>
) : (
<ul className="flex flex-col gap-1">
{items.map((o) => (
<li
key={o.id}
className="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-sm">
{o.cidr}
</span>
<Badge
variant={
o.action === 'deny'
? 'destructive-light'
: 'success-light'
}
size="xs"
>
{o.action}
</Badge>
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
disabled={remove.isPending}
onClick={() => remove.mutate(o.id)}
>
<Trash2 className="size-3.5" />
</Button>
</li>
))}
</ul>
)}
</div>
</div>
</ScrollArea>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Закрыть
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
type CloneSheetProps = {
agentId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function AgentCloneSetsSheet({
agentId,
open,
onOpenChange,
}: CloneSheetProps) {
const qc = useQueryClient()
const agentsQ = useQuery({
...agentsQueryOptions(),
enabled: open,
})
const [cloneFrom, setCloneFrom] = useState<string | null>(null)
const clone = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/clone-from/${cloneFrom}`, {
method: 'POST',
body: JSON.stringify({ include_overrides: true }),
}),
onSuccess: () => {
toast.success('Наборы скопированы')
setCloneFrom(null)
onOpenChange(false)
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
},
onError: (e: Error) => toast.error(e.message),
})
const sources = (agentsQ.data?.items ?? []).filter((x) => x.id !== agentId)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Копировать наборы</SheetTitle>
<SheetDescription>
Копирует назначения наборов и overrides с другого агента.
</SheetDescription>
</SheetHeader>
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4 py-2">
<Field>
<FieldLabel>Источник</FieldLabel>
<Select
value={cloneFrom}
onValueChange={(v) => setCloneFrom(v)}
items={sources.map((x) => ({ value: x.id, label: x.name }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Выберите агента" />
</SelectTrigger>
<SelectContent>
{sources.map((x) => (
<SelectItem key={x.id} value={x.id}>
{x.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={!cloneFrom || clone.isPending}
onClick={() => clone.mutate()}
>
Клонировать
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}