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,312 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
GripVerticalIcon,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { PolicySet } 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 { StatusBadge } from '@/components/status-badge'
|
||||
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import {
|
||||
agentPolicySetsQueryOptions,
|
||||
policySetsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
|
||||
/**
|
||||
* Agent-assigned policy sets — ReUI Sortable (c-sortable-5 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
|
||||
*/
|
||||
|
||||
export type AgentPolicySetRow = {
|
||||
set_id: string
|
||||
sort: number
|
||||
name: string
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
policy_mode: 'blacklist' | 'whitelist'
|
||||
}
|
||||
|
||||
type AgentPolicySetsSortableProps = {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
export function AgentPolicySetsSortable({
|
||||
agentId,
|
||||
}: AgentPolicySetsSortableProps) {
|
||||
const qc = useQueryClient()
|
||||
const assignedQ = useQuery(agentPolicySetsQueryOptions(agentId))
|
||||
const catalogQ = useQuery(policySetsQueryOptions())
|
||||
const [items, setItems] = useState<AgentPolicySetRow[]>([])
|
||||
const [addId, setAddId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setItems(assignedQ.data?.items ?? [])
|
||||
}, [assignedQ.data])
|
||||
|
||||
const assignedMode = items[0]?.policy_mode
|
||||
|
||||
const availableSets = useMemo(() => {
|
||||
const assigned = new Set(items.map((i) => i.set_id))
|
||||
return (catalogQ.data?.items ?? []).filter((s) => {
|
||||
if (assigned.has(s.id)) return false
|
||||
if (assignedMode && s.policy_mode !== assignedMode) return false
|
||||
return true
|
||||
})
|
||||
}, [catalogQ.data?.items, items, assignedMode])
|
||||
|
||||
const conflictSets = useMemo(() => {
|
||||
if (!assignedMode) return [] as PolicySet[]
|
||||
const assigned = new Set(items.map((i) => i.set_id))
|
||||
return (catalogQ.data?.items ?? []).filter(
|
||||
(s) => !assigned.has(s.id) && s.policy_mode !== assignedMode,
|
||||
)
|
||||
}, [catalogQ.data?.items, items, assignedMode])
|
||||
|
||||
const persist = useMutation({
|
||||
mutationFn: (set_ids: string[]) =>
|
||||
apiFetch<{ items: AgentPolicySetRow[] }>(
|
||||
`/api/v1/agents/${agentId}/policy-sets`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ set_ids }),
|
||||
},
|
||||
),
|
||||
onSuccess: (res) => {
|
||||
setItems(res.items)
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
toast.error(e.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleRemove = (setId: string) => {
|
||||
const next = items.filter((i) => i.set_id !== setId)
|
||||
const prev = items
|
||||
setItems(next)
|
||||
persist.mutate(
|
||||
next.map((i) => i.set_id),
|
||||
{
|
||||
onSuccess: () => toast.success('Набор снят'),
|
||||
onError: () => setItems(prev),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!addId) return
|
||||
const set = (catalogQ.data?.items ?? []).find((s) => s.id === addId)
|
||||
if (!set) return
|
||||
if (assignedMode && set.policy_mode !== assignedMode) {
|
||||
toast.error(
|
||||
`Режим набора (${set.policy_mode}) не совпадает с текущим (${assignedMode})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const next: AgentPolicySetRow[] = [
|
||||
...items,
|
||||
{
|
||||
set_id: set.id,
|
||||
sort: items.length * 10,
|
||||
name: set.name,
|
||||
description: set.description,
|
||||
enabled: set.enabled,
|
||||
policy_mode: set.policy_mode,
|
||||
},
|
||||
]
|
||||
const prev = items
|
||||
setItems(next)
|
||||
setAddId(null)
|
||||
persist.mutate(
|
||||
next.map((i) => i.set_id),
|
||||
{
|
||||
onSuccess: () => toast.success('Набор добавлен'),
|
||||
onError: () => setItems(prev),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Frame dense spacing="sm" stacked>
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FrameTitle>Наборы правил</FrameTitle>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<FrameDescription>
|
||||
Перетащите для приоритета · один режим на агента
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={addId}
|
||||
onValueChange={(v) => setAddId(v)}
|
||||
items={availableSets.map((s) => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger className="w-[11rem]">
|
||||
<SelectValue placeholder="Добавить…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSets.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{availableSets.length === 0 ? (
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
||||
{conflictSets.length > 0
|
||||
? 'Нет совместимых наборов'
|
||||
: 'Все наборы уже назначены'}
|
||||
</div>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!addId || persist.isPending}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<Plus data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<FramePanel className="p-0">
|
||||
<EmptyState
|
||||
title="Нет наборов"
|
||||
description="Добавьте набор правил — порядок задаёт приоритет при merge."
|
||||
centered={false}
|
||||
className="py-10"
|
||||
/>
|
||||
</FramePanel>
|
||||
) : (
|
||||
<FramePanel className="p-0">
|
||||
<Sortable
|
||||
value={items}
|
||||
onValueChange={setItems}
|
||||
getItemValue={(r) => r.set_id}
|
||||
onValueCommit={(next, meta) => {
|
||||
persist.mutate(
|
||||
next.map((r) => r.set_id),
|
||||
{ onError: () => setItems(meta.previousValue) },
|
||||
)
|
||||
}}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{items.map((row) => (
|
||||
<SortableItem
|
||||
key={row.set_id}
|
||||
value={row.set_id}
|
||||
className="border-border flex items-center gap-3 border-b px-3 py-2.5 last:border-b-0"
|
||||
>
|
||||
<SortableItemHandle className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||
<GripVerticalIcon className="size-4" />
|
||||
</SortableItemHandle>
|
||||
|
||||
<PolicySetIcon mode={row.policy_mode} className="size-9" />
|
||||
|
||||
<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 text-sm font-medium">
|
||||
{row.name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
row.policy_mode === 'whitelist'
|
||||
? 'warning-light'
|
||||
: 'secondary'
|
||||
}
|
||||
size="xs"
|
||||
>
|
||||
{row.policy_mode}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
status={row.enabled ? 'enabled' : 'disabled'}
|
||||
/>
|
||||
</div>
|
||||
{row.description ? (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{row.description}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Открыть набор"
|
||||
render={
|
||||
<Link
|
||||
to="/rules/$setId"
|
||||
params={{ setId: row.set_id }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
aria-label="Снять набор"
|
||||
disabled={persist.isPending}
|
||||
onClick={() => handleRemove(row.set_id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</SortableItem>
|
||||
))}
|
||||
</Sortable>
|
||||
</FramePanel>
|
||||
)}
|
||||
</Frame>
|
||||
|
||||
{conflictSets.length > 0 && items.length > 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{conflictSets.length} набор(ов) скрыты из‑за другого режима (
|
||||
{assignedMode}).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -16,10 +16,14 @@ export interface QuickActionItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
to: string
|
||||
/** Route link — mutually exclusive with onSelect for navigation */
|
||||
to?: string
|
||||
search?: Record<string, unknown>
|
||||
/** Click handler (sheets / tab switch) when no `to` */
|
||||
onSelect?: () => void
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
badgeLabel?: string
|
||||
}
|
||||
|
||||
interface QuickActionGridProps {
|
||||
@@ -51,7 +55,7 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||
<Badge variant="outline" size="sm" className="shrink-0">
|
||||
Перейти
|
||||
{action.badgeLabel ?? 'Перейти'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
@@ -64,7 +68,7 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
|
||||
/**
|
||||
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12
|
||||
*/
|
||||
export function QuickActionGrid({
|
||||
actions,
|
||||
@@ -88,14 +92,25 @@ export function QuickActionGrid({
|
||||
key={action.id}
|
||||
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
||||
>
|
||||
<Link
|
||||
to={action.to}
|
||||
search={action.search}
|
||||
className="focus-visible:outline-none"
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
>
|
||||
<QuickActionBody action={action} />
|
||||
</Link>
|
||||
{action.to ? (
|
||||
<Link
|
||||
to={action.to}
|
||||
search={action.search}
|
||||
className="focus-visible:outline-none"
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
>
|
||||
<QuickActionBody action={action} />
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left focus-visible:outline-none"
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
onClick={() => action.onSelect?.()}
|
||||
>
|
||||
<QuickActionBody action={action} />
|
||||
</button>
|
||||
)}
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
|
||||
import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@evofw/ui/components/input-group'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import {
|
||||
@@ -78,6 +83,11 @@ export interface ResourcePageProps<T extends object> {
|
||||
toolbarExtra?: ReactNode
|
||||
hideHeader?: boolean
|
||||
onRowClick?: (row: T) => void
|
||||
/** Toolbar search — DNA data-grid-filtering-2 */
|
||||
searchQuery?: string
|
||||
onSearchChange?: (query: string) => void
|
||||
searchPlaceholder?: string
|
||||
getSearchText?: (item: T) => string
|
||||
}
|
||||
|
||||
function ResourcePageSkeleton() {
|
||||
@@ -126,6 +136,10 @@ export function ResourcePage<T extends object>({
|
||||
toolbarExtra,
|
||||
hideHeader = false,
|
||||
onRowClick,
|
||||
searchQuery = '',
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
getSearchText,
|
||||
}: ResourcePageProps<T>) {
|
||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||
const activeTab = controlledTab ?? internalTab
|
||||
@@ -143,17 +157,41 @@ export function ResourcePage<T extends object>({
|
||||
)
|
||||
}, [])
|
||||
|
||||
const applySearch = useCallback(
|
||||
(items: T[]) => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
if (!q || !getSearchText) return items
|
||||
return items.filter((item) =>
|
||||
getSearchText(item).toLowerCase().includes(q),
|
||||
)
|
||||
},
|
||||
[searchQuery, getSearchText],
|
||||
)
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||
let result = applySearch(data)
|
||||
result = applyFiltersToData(result, filters, getFilterFieldValue)
|
||||
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||||
result = result.filter((item) => tabFilter(item, activeTab))
|
||||
}
|
||||
return result
|
||||
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
|
||||
}, [
|
||||
data,
|
||||
applySearch,
|
||||
filters,
|
||||
getFilterFieldValue,
|
||||
tabs,
|
||||
tabFilter,
|
||||
activeTab,
|
||||
])
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
if (!tabs?.length || !tabFilter) return {}
|
||||
const base = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||
const base = applyFiltersToData(
|
||||
applySearch(data),
|
||||
filters,
|
||||
getFilterFieldValue,
|
||||
)
|
||||
const counts: Record<string, number> = {}
|
||||
for (const tab of tabs) {
|
||||
counts[tab.id] =
|
||||
@@ -162,7 +200,7 @@ export function ResourcePage<T extends object>({
|
||||
: base.filter((item) => tabFilter(item, tab.id)).length
|
||||
}
|
||||
return counts
|
||||
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
|
||||
}, [tabs, tabFilter, data, applySearch, filters, getFilterFieldValue])
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||
@@ -208,8 +246,20 @@ export function ResourcePage<T extends object>({
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onClearFilters?.()
|
||||
onSearchChange?.('')
|
||||
resetPagination()
|
||||
}, [onClearFilters, resetPagination])
|
||||
}, [onClearFilters, onSearchChange, resetPagination])
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
onSearchChange?.(value)
|
||||
resetPagination()
|
||||
},
|
||||
[onSearchChange, resetPagination],
|
||||
)
|
||||
|
||||
const showSearch = Boolean(onSearchChange && getSearchText)
|
||||
const showClear = Boolean(onClearFilters || (showSearch && searchQuery))
|
||||
|
||||
const countedTabs = useMemo(
|
||||
() =>
|
||||
@@ -341,18 +391,33 @@ export function ResourcePage<T extends object>({
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{showSearch ? (
|
||||
<InputGroup className="h-8 max-w-xs min-w-[12rem] flex-1">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
@@ -360,7 +425,7 @@ export function ResourcePage<T extends object>({
|
||||
{selectedCount} выбрано
|
||||
</Badge>
|
||||
) : null}
|
||||
{onClearFilters ? (
|
||||
{showClear ? (
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||
@@ -31,6 +32,7 @@ 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
|
||||
* · https://reui.io/preview/base/empty-state-12
|
||||
* Docs: https://reui.io/docs/components/base/sortable
|
||||
*/
|
||||
|
||||
@@ -41,11 +43,24 @@ function ruleTarget(r: PolicyRule): string {
|
||||
return '—'
|
||||
}
|
||||
|
||||
function ruleSubtitle(r: PolicyRule): string | null {
|
||||
const parts: string[] = []
|
||||
if (r.list_id && (r.cidr || r.hostname)) {
|
||||
parts.push(`list:${r.list_id.slice(0, 8)}…`)
|
||||
}
|
||||
if (r.comment) parts.push(r.comment)
|
||||
if (typeof r.resolved_count === 'number' && r.resolved_count > 0) {
|
||||
parts.push(`${r.resolved_count} prefixes`)
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : null
|
||||
}
|
||||
|
||||
type PolicyRulesSortableProps = {
|
||||
setId: string
|
||||
rules: PolicyRule[]
|
||||
policyMode: 'blacklist' | 'whitelist'
|
||||
onDelete: (id: string) => void
|
||||
onAdd?: () => void
|
||||
}
|
||||
|
||||
export function PolicyRulesSortable({
|
||||
@@ -53,6 +68,7 @@ export function PolicyRulesSortable({
|
||||
rules: rulesProp,
|
||||
policyMode,
|
||||
onDelete,
|
||||
onAdd,
|
||||
}: PolicyRulesSortableProps) {
|
||||
const qc = useQueryClient()
|
||||
const [items, setItems] = useState(rulesProp)
|
||||
@@ -71,17 +87,8 @@ export function PolicyRulesSortable({
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
},
|
||||
onError: (e: Error, _vars, context) => {
|
||||
onError: (e: Error) => {
|
||||
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 }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -118,20 +125,43 @@ export function PolicyRulesSortable({
|
||||
</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>
|
||||
<Frame dense spacing="sm" stacked>
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FrameTitle>Правила</FrameTitle>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<FrameDescription>
|
||||
Перетащите для порядка · Switch — вкл/выкл
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
</div>
|
||||
{onAdd ? (
|
||||
<Button size="sm" variant="outline" onClick={onAdd}>
|
||||
Правило
|
||||
</Button>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<FramePanel className="p-0">
|
||||
<EmptyState
|
||||
title="Нет правил"
|
||||
description="Добавьте CIDR, список или hostname"
|
||||
action={
|
||||
onAdd ? (
|
||||
<Button size="sm" onClick={onAdd}>
|
||||
Правило
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
centered={false}
|
||||
className="py-10"
|
||||
/>
|
||||
</FramePanel>
|
||||
) : (
|
||||
<FramePanel className="p-0">
|
||||
<Sortable
|
||||
value={items}
|
||||
@@ -148,6 +178,7 @@ export function PolicyRulesSortable({
|
||||
{items.map((r) => {
|
||||
const enabled = r.enabled !== false
|
||||
const isDeny = r.action === 'deny'
|
||||
const subtitle = ruleSubtitle(r)
|
||||
return (
|
||||
<SortableItem
|
||||
key={r.id}
|
||||
@@ -178,7 +209,7 @@ export function PolicyRulesSortable({
|
||||
|
||||
<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">
|
||||
<span className="truncate font-mono text-sm font-medium">
|
||||
{ruleTarget(r)}
|
||||
</span>
|
||||
<Badge
|
||||
@@ -195,9 +226,9 @@ export function PolicyRulesSortable({
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{r.comment ? (
|
||||
{subtitle ? (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{r.comment}
|
||||
{subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -224,8 +255,8 @@ export function PolicyRulesSortable({
|
||||
})}
|
||||
</Sortable>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)}
|
||||
)}
|
||||
</Frame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user