feat(web): enhance quick action grid and resource page with search functionality
- 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user