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
|
id: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
to: string
|
/** Route link — mutually exclusive with onSelect for navigation */
|
||||||
|
to?: string
|
||||||
search?: Record<string, unknown>
|
search?: Record<string, unknown>
|
||||||
|
/** Click handler (sheets / tab switch) when no `to` */
|
||||||
|
onSelect?: () => void
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
|
badgeLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuickActionGridProps {
|
interface QuickActionGridProps {
|
||||||
@@ -51,7 +55,7 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||||
<Badge variant="outline" size="sm" className="shrink-0">
|
<Badge variant="outline" size="sm" className="shrink-0">
|
||||||
Перейти
|
{action.badgeLabel ?? 'Перейти'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
<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).
|
* 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({
|
export function QuickActionGrid({
|
||||||
actions,
|
actions,
|
||||||
@@ -88,14 +92,25 @@ export function QuickActionGrid({
|
|||||||
key={action.id}
|
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"
|
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
|
{action.to ? (
|
||||||
to={action.to}
|
<Link
|
||||||
search={action.search}
|
to={action.to}
|
||||||
className="focus-visible:outline-none"
|
search={action.search}
|
||||||
aria-label={`${action.title}: ${action.description}`}
|
className="focus-visible:outline-none"
|
||||||
>
|
aria-label={`${action.title}: ${action.description}`}
|
||||||
<QuickActionBody action={action} />
|
>
|
||||||
</Link>
|
<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>
|
</FramePanel>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
} from '@tanstack/react-table'
|
} 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 { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
@@ -31,6 +31,11 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
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 { Separator } from '@evofw/ui/components/separator'
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
import {
|
import {
|
||||||
@@ -78,6 +83,11 @@ export interface ResourcePageProps<T extends object> {
|
|||||||
toolbarExtra?: ReactNode
|
toolbarExtra?: ReactNode
|
||||||
hideHeader?: boolean
|
hideHeader?: boolean
|
||||||
onRowClick?: (row: T) => void
|
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() {
|
function ResourcePageSkeleton() {
|
||||||
@@ -126,6 +136,10 @@ export function ResourcePage<T extends object>({
|
|||||||
toolbarExtra,
|
toolbarExtra,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
|
searchQuery = '',
|
||||||
|
onSearchChange,
|
||||||
|
searchPlaceholder = 'Поиск…',
|
||||||
|
getSearchText,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||||
const activeTab = controlledTab ?? internalTab
|
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(() => {
|
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') {
|
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||||||
result = result.filter((item) => tabFilter(item, activeTab))
|
result = result.filter((item) => tabFilter(item, activeTab))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
|
}, [
|
||||||
|
data,
|
||||||
|
applySearch,
|
||||||
|
filters,
|
||||||
|
getFilterFieldValue,
|
||||||
|
tabs,
|
||||||
|
tabFilter,
|
||||||
|
activeTab,
|
||||||
|
])
|
||||||
|
|
||||||
const tabCounts = useMemo(() => {
|
const tabCounts = useMemo(() => {
|
||||||
if (!tabs?.length || !tabFilter) return {}
|
if (!tabs?.length || !tabFilter) return {}
|
||||||
const base = applyFiltersToData(data, filters, getFilterFieldValue)
|
const base = applyFiltersToData(
|
||||||
|
applySearch(data),
|
||||||
|
filters,
|
||||||
|
getFilterFieldValue,
|
||||||
|
)
|
||||||
const counts: Record<string, number> = {}
|
const counts: Record<string, number> = {}
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
counts[tab.id] =
|
counts[tab.id] =
|
||||||
@@ -162,7 +200,7 @@ export function ResourcePage<T extends object>({
|
|||||||
: base.filter((item) => tabFilter(item, tab.id)).length
|
: base.filter((item) => tabFilter(item, tab.id)).length
|
||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
|
}, [tabs, tabFilter, data, applySearch, filters, getFilterFieldValue])
|
||||||
|
|
||||||
const selectedIds = useMemo(
|
const selectedIds = useMemo(
|
||||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||||
@@ -208,8 +246,20 @@ export function ResourcePage<T extends object>({
|
|||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
onClearFilters?.()
|
onClearFilters?.()
|
||||||
|
onSearchChange?.('')
|
||||||
resetPagination()
|
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(
|
const countedTabs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -341,18 +391,33 @@ export function ResourcePage<T extends object>({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||||
<Filters
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
filters={filters}
|
<Filters
|
||||||
fields={filterFields}
|
filters={filters}
|
||||||
onChange={handleFiltersChange}
|
fields={filterFields}
|
||||||
size="default"
|
onChange={handleFiltersChange}
|
||||||
trigger={
|
size="default"
|
||||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
trigger={
|
||||||
<FilterIcon className="size-4" aria-hidden="true" />
|
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||||
Фильтры
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
</Button>
|
Фильтры
|
||||||
}
|
</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">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
{toolbarExtra}
|
{toolbarExtra}
|
||||||
{selectedCount > 0 ? (
|
{selectedCount > 0 ? (
|
||||||
@@ -360,7 +425,7 @@ export function ResourcePage<T extends object>({
|
|||||||
{selectedCount} выбрано
|
{selectedCount} выбрано
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{onClearFilters ? (
|
{showClear ? (
|
||||||
<Button type="button" variant="outline" onClick={handleClear}>
|
<Button type="button" variant="outline" onClick={handleClear}>
|
||||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||||
Сбросить
|
Сбросить
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Switch } from '@evofw/ui/components/switch'
|
import { Switch } from '@evofw/ui/components/switch'
|
||||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
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.
|
* Ordered firewall rules — ReUI Sortable + settings-8 DNA.
|
||||||
* Preview: https://reui.io/preview/base/components/c-sortable-5
|
* Preview: https://reui.io/preview/base/components/c-sortable-5
|
||||||
* · https://reui.io/preview/base/settings-8
|
* · https://reui.io/preview/base/settings-8
|
||||||
|
* · https://reui.io/preview/base/empty-state-12
|
||||||
* Docs: https://reui.io/docs/components/base/sortable
|
* Docs: https://reui.io/docs/components/base/sortable
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -41,11 +43,24 @@ function ruleTarget(r: PolicyRule): string {
|
|||||||
return '—'
|
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 = {
|
type PolicyRulesSortableProps = {
|
||||||
setId: string
|
setId: string
|
||||||
rules: PolicyRule[]
|
rules: PolicyRule[]
|
||||||
policyMode: 'blacklist' | 'whitelist'
|
policyMode: 'blacklist' | 'whitelist'
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void
|
||||||
|
onAdd?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PolicyRulesSortable({
|
export function PolicyRulesSortable({
|
||||||
@@ -53,6 +68,7 @@ export function PolicyRulesSortable({
|
|||||||
rules: rulesProp,
|
rules: rulesProp,
|
||||||
policyMode,
|
policyMode,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onAdd,
|
||||||
}: PolicyRulesSortableProps) {
|
}: PolicyRulesSortableProps) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [items, setItems] = useState(rulesProp)
|
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', setId] })
|
||||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||||
},
|
},
|
||||||
onError: (e: Error, _vars, context) => {
|
onError: (e: Error) => {
|
||||||
toast.error(e.message)
|
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>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
<Frame dense spacing="sm" stacked>
|
||||||
<Frame dense spacing="sm">
|
<FrameHeader className="flex-row items-start justify-between gap-3 px-4 py-3">
|
||||||
<FramePanel className="text-muted-foreground py-8 text-center text-sm">
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
Нет правил — добавьте CIDR, список или hostname
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</FramePanel>
|
<FrameTitle>Правила</FrameTitle>
|
||||||
</Frame>
|
<Badge variant="secondary" size="sm">
|
||||||
) : (
|
{items.length}
|
||||||
<Frame dense spacing="sm" stacked>
|
</Badge>
|
||||||
<FrameHeader className="px-4 py-3">
|
</div>
|
||||||
<FrameTitle>Правила</FrameTitle>
|
|
||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
Перетащите для порядка · Switch — вкл/выкл
|
Перетащите для порядка · Switch — вкл/выкл
|
||||||
</FrameDescription>
|
</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">
|
<FramePanel className="p-0">
|
||||||
<Sortable
|
<Sortable
|
||||||
value={items}
|
value={items}
|
||||||
@@ -148,6 +178,7 @@ export function PolicyRulesSortable({
|
|||||||
{items.map((r) => {
|
{items.map((r) => {
|
||||||
const enabled = r.enabled !== false
|
const enabled = r.enabled !== false
|
||||||
const isDeny = r.action === 'deny'
|
const isDeny = r.action === 'deny'
|
||||||
|
const subtitle = ruleSubtitle(r)
|
||||||
return (
|
return (
|
||||||
<SortableItem
|
<SortableItem
|
||||||
key={r.id}
|
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 min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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)}
|
{ruleTarget(r)}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -195,9 +226,9 @@ export function PolicyRulesSortable({
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{r.comment ? (
|
{subtitle ? (
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
{r.comment}
|
{subtitle}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -224,8 +255,8 @@ export function PolicyRulesSortable({
|
|||||||
})}
|
})}
|
||||||
</Sortable>
|
</Sortable>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
)}
|
||||||
)}
|
</Frame>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,10 +93,27 @@ export const agentPolicySetsQueryOptions = (agentId: string) =>
|
|||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
policy_mode: 'blacklist' | 'whitelist'
|
||||||
}[]
|
}[]
|
||||||
}>(`/api/v1/agents/${agentId}/policy-sets`),
|
}>(`/api/v1/agents/${agentId}/policy-sets`),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const agentOverridesQueryOptions = (agentId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['agents', agentId, 'overrides'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{
|
||||||
|
items: {
|
||||||
|
id: string
|
||||||
|
agent_id: string
|
||||||
|
cidr: string
|
||||||
|
action: 'allow' | 'deny'
|
||||||
|
comment?: string | null
|
||||||
|
created_at: string
|
||||||
|
}[]
|
||||||
|
}>(`/api/v1/agents/${agentId}/overrides`),
|
||||||
|
})
|
||||||
|
|
||||||
export const installContextQueryOptions = () =>
|
export const installContextQueryOptions = () =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['install-context'],
|
queryKey: ['install-context'],
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import {
|
import {
|
||||||
BanIcon,
|
BanIcon,
|
||||||
CheckCircle2Icon,
|
CheckCircle2Icon,
|
||||||
|
CircleAlertIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
Copy,
|
Copy,
|
||||||
CpuIcon,
|
CpuIcon,
|
||||||
|
CopyPlusIcon,
|
||||||
|
ShieldOffIcon,
|
||||||
|
ShieldPlusIcon,
|
||||||
|
TerminalIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { PageShell, DetailPanel } from '@/components/reui-kit'
|
import {
|
||||||
|
DetailPanel,
|
||||||
|
PageShell,
|
||||||
|
QuickActionGrid,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
@@ -24,43 +32,25 @@ import {
|
|||||||
AlertTitle,
|
AlertTitle,
|
||||||
} from '@/components/reui/alert'
|
} from '@/components/reui/alert'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import {
|
import {
|
||||||
AgentPlatformIcon,
|
AgentPlatformIcon,
|
||||||
platformLabel,
|
platformLabel,
|
||||||
} from '@/components/agents/agent-platform-icon'
|
} from '@/components/agents/agent-platform-icon'
|
||||||
import { AgentLifecycleTimeline } from '@/components/agents/agent-lifecycle-timeline'
|
import { AgentLifecycleTimeline } from '@/components/agents/agent-lifecycle-timeline'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|
||||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
|
||||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
|
||||||
import {
|
import {
|
||||||
agentQueryOptions,
|
AgentCloneSetsSheet,
|
||||||
agentsQueryOptions,
|
AgentOverrideSheet,
|
||||||
agentPolicySetsQueryOptions,
|
} from '@/components/agents/agent-settings-sheets'
|
||||||
policySetsQueryOptions,
|
import { agentQueryOptions } from '@/queries'
|
||||||
} from '@/queries'
|
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Checkbox } from '@evofw/ui/components/checkbox'
|
|
||||||
import { Input } from '@evofw/ui/components/input'
|
|
||||||
import { Label } from '@evofw/ui/components/label'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@evofw/ui/components/select'
|
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
import type { PolicySet } from '@evofw/shared'
|
|
||||||
import { CircleAlertIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent detail — Solutions Agents DNA.
|
* Agent detail — Solutions Agents DNA.
|
||||||
* Preview: https://reui.io/preview/base/solution-agents-3 · stats-12 · settings-14
|
* Preview: https://reui.io/preview/base/solution-agents-3 · stats-12 · sheet-8 · c-sortable-5
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||||
@@ -78,20 +68,9 @@ function AgentDetailPage() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { copyToClipboard } = useCopyToClipboard()
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
const agentQ = useQuery(agentQueryOptions(id))
|
const agentQ = useQuery(agentQueryOptions(id))
|
||||||
const setsQ = useQuery(policySetsQueryOptions())
|
const installRef = useRef<HTMLDivElement>(null)
|
||||||
const assignedQ = useQuery(agentPolicySetsQueryOptions(id))
|
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||||
const agentsQ = useQuery(agentsQueryOptions())
|
const [cloneOpen, setCloneOpen] = useState(false)
|
||||||
const [cidr, setCidr] = useState('')
|
|
||||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
|
||||||
const [cloneFrom, setCloneFrom] = useState('')
|
|
||||||
const [selectedSets, setSelectedSets] = useState<string[] | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedSets(null)
|
|
||||||
}, [id, assignedQ.data])
|
|
||||||
|
|
||||||
const assignedIds =
|
|
||||||
selectedSets ?? assignedQ.data?.items.map((i) => i.set_id) ?? []
|
|
||||||
|
|
||||||
const revoke = useMutation({
|
const revoke = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -113,121 +92,70 @@ function AgentDetailPage() {
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const addOverride = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
apiFetch(`/api/v1/agents/${id}/overrides`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ cidr, action }),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Override добавлен — подхватится на следующей итерации sync')
|
|
||||||
setCidr('')
|
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', id] })
|
|
||||||
},
|
|
||||||
onError: (e: Error) => toast.error(e.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveSets = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
apiFetch(`/api/v1/agents/${id}/policy-sets`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ set_ids: assignedIds }),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Наборы сохранены')
|
|
||||||
setSelectedSets(null)
|
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', id] })
|
|
||||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
|
||||||
},
|
|
||||||
onError: (e: Error) => toast.error(e.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const clone = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
apiFetch(`/api/v1/agents/${id}/clone-from/${cloneFrom}`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ include_overrides: true }),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Наборы скопированы')
|
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', id] })
|
|
||||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
|
||||||
},
|
|
||||||
onError: (e: Error) => toast.error(e.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const setColumns: ColumnDef<PolicySet>[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
id: 'select',
|
|
||||||
enableSorting: false,
|
|
||||||
header: () => <span className="sr-only">Выбор</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={assignedIds.includes(row.original.id)}
|
|
||||||
onCheckedChange={(v) => {
|
|
||||||
setSelectedSets(
|
|
||||||
v
|
|
||||||
? [...assignedIds, row.original.id]
|
|
||||||
: assignedIds.filter((x) => x !== row.original.id),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
aria-label={`Назначить ${row.original.name}`}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'name',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Набор" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link
|
|
||||||
to="/rules/$setId"
|
|
||||||
params={{ setId: row.original.id }}
|
|
||||||
className="min-w-0"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DataGridPrimaryCell
|
|
||||||
accent="primary"
|
|
||||||
title={row.original.name}
|
|
||||||
subtitle={row.original.description ?? undefined}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'enabled',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Статус" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<StatusBadge
|
|
||||||
status={row.original.enabled ? 'enabled' : 'disabled'}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'rules_count',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Правила" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="tabular-nums">{row.original.rules_count ?? 0}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[assignedIds],
|
|
||||||
)
|
|
||||||
|
|
||||||
const setsTable = useReactTable({
|
|
||||||
data: setsQ.data?.items ?? [],
|
|
||||||
columns: setColumns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getRowId: (r) => r.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const a = agentQ.data
|
const a = agentQ.data
|
||||||
|
|
||||||
|
const quickActions = useMemo(() => {
|
||||||
|
if (!a) return []
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
id: 'override',
|
||||||
|
title: 'IP override',
|
||||||
|
description: 'Allow/deny поверх политики',
|
||||||
|
icon: <ShieldPlusIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-warning [&_svg]:text-current',
|
||||||
|
badgeLabel: 'Открыть',
|
||||||
|
onSelect: () => setOverrideOpen(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clone',
|
||||||
|
title: 'Копировать наборы',
|
||||||
|
description: 'С другого агента + overrides',
|
||||||
|
icon: <CopyPlusIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-info [&_svg]:text-current',
|
||||||
|
badgeLabel: 'Открыть',
|
||||||
|
onSelect: () => setCloneOpen(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'install',
|
||||||
|
title: 'Install curl',
|
||||||
|
description: a.install_curl ? 'Скопировать one-liner' : 'Недоступен',
|
||||||
|
icon: <TerminalIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-primary [&_svg]:text-current',
|
||||||
|
badgeLabel: 'Копировать',
|
||||||
|
onSelect: () => {
|
||||||
|
if (a.install_curl) {
|
||||||
|
copyToClipboard(a.install_curl)
|
||||||
|
toast.success('Скопировано')
|
||||||
|
}
|
||||||
|
installRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (a.status === 'pending') {
|
||||||
|
actions.push({
|
||||||
|
id: 'approve',
|
||||||
|
title: 'Approve',
|
||||||
|
description: 'Выдать политику агенту',
|
||||||
|
icon: <CheckCircle2Icon aria-hidden />,
|
||||||
|
iconClassName: 'text-success [&_svg]:text-current',
|
||||||
|
badgeLabel: 'Выполнить',
|
||||||
|
onSelect: () => approve.mutate(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (a.status === 'approved') {
|
||||||
|
actions.push({
|
||||||
|
id: 'revoke',
|
||||||
|
title: 'Revoke',
|
||||||
|
description: 'Отозвать доступ агента',
|
||||||
|
icon: <ShieldOffIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-destructive [&_svg]:text-current',
|
||||||
|
badgeLabel: 'Выполнить',
|
||||||
|
onSelect: () => revoke.mutate(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return actions
|
||||||
|
}, [a, approve, copyToClipboard, revoke])
|
||||||
|
|
||||||
if (agentQ.isLoading || !a) {
|
if (agentQ.isLoading || !a) {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -362,214 +290,92 @@ function AgentDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<QuickActionGrid actions={quickActions} />
|
||||||
|
|
||||||
<DetailPanel.Section>
|
<DetailPanel.Section>
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<AgentLifecycleTimeline agent={a} />
|
<AgentLifecycleTimeline agent={a} />
|
||||||
|
|
||||||
<Frame dense spacing="sm">
|
<div ref={installRef}>
|
||||||
<FrameHeader>
|
<Frame dense spacing="sm">
|
||||||
<FrameTitle>Install / identity</FrameTitle>
|
<FrameHeader>
|
||||||
<FrameDescription>
|
<FrameTitle>Install / identity</FrameTitle>
|
||||||
Copy one-liner · hostname · token
|
<FrameDescription>
|
||||||
</FrameDescription>
|
Copy one-liner · hostname · token
|
||||||
</FrameHeader>
|
</FrameDescription>
|
||||||
<FramePanel className="flex flex-col gap-3">
|
</FrameHeader>
|
||||||
{a.install_curl ? (
|
<FramePanel className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-2">
|
{a.install_curl ? (
|
||||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
|
<div className="flex flex-col gap-2">
|
||||||
{a.install_curl}
|
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
|
||||||
</pre>
|
{a.install_curl}
|
||||||
<Button
|
</pre>
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="self-start"
|
size="sm"
|
||||||
onClick={() => {
|
className="self-start"
|
||||||
copyToClipboard(a.install_curl!)
|
onClick={() => {
|
||||||
toast.success('Скопировано')
|
copyToClipboard(a.install_curl!)
|
||||||
}}
|
toast.success('Скопировано')
|
||||||
>
|
}}
|
||||||
<Copy data-icon="inline-start" />
|
>
|
||||||
Копировать
|
<Copy data-icon="inline-start" />
|
||||||
</Button>
|
Копировать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Install curl недоступен
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="text-muted-foreground grid gap-1 text-sm">
|
||||||
|
<div>
|
||||||
|
Hostname:{' '}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{a.hostname ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Last seen IP:{' '}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{a.last_seen_ip ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Client:{' '}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{a.client_version ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Token prefix:{' '}
|
||||||
|
<span className="text-foreground font-mono">
|
||||||
|
{a.token_prefix}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</FramePanel>
|
||||||
<p className="text-muted-foreground text-sm">
|
</Frame>
|
||||||
Install curl недоступен
|
</div>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="text-muted-foreground grid gap-1 text-sm">
|
|
||||||
<div>
|
|
||||||
Hostname:{' '}
|
|
||||||
<span className="text-foreground">
|
|
||||||
{a.hostname ?? '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Last seen IP:{' '}
|
|
||||||
<span className="text-foreground">
|
|
||||||
{a.last_seen_ip ?? '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Client:{' '}
|
|
||||||
<span className="text-foreground">
|
|
||||||
{a.client_version ?? '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Token prefix:{' '}
|
|
||||||
<span className="text-foreground font-mono">
|
|
||||||
{a.token_prefix}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
<Frame dense spacing="sm">
|
<div className="lg:col-span-2">
|
||||||
<FrameHeader>
|
<AgentPolicySetsSortable agentId={id} />
|
||||||
<FrameTitle>Режим фильтра</FrameTitle>
|
</div>
|
||||||
<FrameDescription>
|
|
||||||
Задаётся наборами правил (не на агенте). Все назначенные
|
|
||||||
наборы должны иметь один режим.
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
a.policy_mode === 'whitelist'
|
|
||||||
? 'warning-light'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{a.policy_mode === 'whitelist'
|
|
||||||
? 'Белый список'
|
|
||||||
: 'Чёрный список'}
|
|
||||||
</Badge>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
render={<Link to="/rules" />}
|
|
||||||
>
|
|
||||||
Открыть правила
|
|
||||||
</Button>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
<Frame dense spacing="sm" className="lg:col-span-2">
|
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Наборы правил</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
Можно назначить несколько — мержатся при sync (один режим)
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel className="p-0">
|
|
||||||
<DataGrid
|
|
||||||
table={setsTable}
|
|
||||||
recordCount={(setsQ.data?.items ?? []).length}
|
|
||||||
tableLayout={{ dense: true }}
|
|
||||||
>
|
|
||||||
<DataGridTable />
|
|
||||||
</DataGrid>
|
|
||||||
</FramePanel>
|
|
||||||
<div className="border-t px-4 py-3">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={saveSets.isPending || selectedSets === null}
|
|
||||||
onClick={() => saveSets.mutate()}
|
|
||||||
>
|
|
||||||
Сохранить наборы
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
<Frame dense spacing="sm">
|
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Мгновенный IP override</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
Обновится на агенте на следующей итерации sync (~1 мин)
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>CIDR / IP</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="1.2.3.4/32"
|
|
||||||
value={cidr}
|
|
||||||
onChange={(e) => setCidr(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>Действие</Label>
|
|
||||||
<Select
|
|
||||||
value={action}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (v) setAction(v as 'allow' | 'deny')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="deny">deny</SelectItem>
|
|
||||||
<SelectItem value="allow">allow</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => addOverride.mutate()}
|
|
||||||
disabled={!cidr || addOverride.isPending}
|
|
||||||
>
|
|
||||||
Добавить override
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
<Frame dense spacing="sm">
|
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Копировать наборы</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
Копирует назначения наборов (+ overrides) с другого агента
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Select
|
|
||||||
value={cloneFrom || null}
|
|
||||||
onValueChange={(v) => setCloneFrom(v ?? '')}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Источник" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(agentsQ.data?.items ?? [])
|
|
||||||
.filter((x) => x.id !== id)
|
|
||||||
.map((x) => (
|
|
||||||
<SelectItem key={x.id} value={x.id}>
|
|
||||||
{x.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={!cloneFrom || clone.isPending}
|
|
||||||
onClick={() => clone.mutate()}
|
|
||||||
>
|
|
||||||
Клонировать (с overrides)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</div>
|
</div>
|
||||||
</DetailPanel.Section>
|
</DetailPanel.Section>
|
||||||
</DetailPanel>
|
</DetailPanel>
|
||||||
|
|
||||||
|
<AgentOverrideSheet
|
||||||
|
agentId={id}
|
||||||
|
open={overrideOpen}
|
||||||
|
onOpenChange={setOverrideOpen}
|
||||||
|
/>
|
||||||
|
<AgentCloneSetsSheet
|
||||||
|
agentId={id}
|
||||||
|
open={cloneOpen}
|
||||||
|
onOpenChange={setCloneOpen}
|
||||||
|
/>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
CircleAlertIcon,
|
CircleAlertIcon,
|
||||||
Copy,
|
Copy,
|
||||||
Inbox,
|
Inbox,
|
||||||
|
ListIcon,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
|
ShieldIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
KpiStatGrid,
|
KpiStatGrid,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageShell,
|
PageShell,
|
||||||
|
QuickActionGrid,
|
||||||
ResourcePage,
|
ResourcePage,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +66,16 @@ import {
|
|||||||
} from '@evofw/ui/components/tooltip'
|
} from '@evofw/ui/components/tooltip'
|
||||||
import type { Agent } from '@evofw/shared'
|
import type { Agent } from '@evofw/shared'
|
||||||
|
|
||||||
|
const packetFmt = new Intl.NumberFormat('ru-RU', {
|
||||||
|
notation: 'compact',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatPackets(n: number | undefined, hasApply: boolean): string {
|
||||||
|
if (!hasApply || n === undefined) return '—'
|
||||||
|
return packetFmt.format(n)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agents ops console — Solutions Agents DNA.
|
* Agents ops console — Solutions Agents DNA.
|
||||||
* Preview: https://reui.io/preview/base/solution-agents-1 · stats-12 · data-grid-filtering-2
|
* Preview: https://reui.io/preview/base/solution-agents-1 · stats-12 · data-grid-filtering-2
|
||||||
@@ -80,6 +93,7 @@ function AgentsPage() {
|
|||||||
const { copyToClipboard } = useCopyToClipboard()
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -184,11 +198,59 @@ function AgentsPage() {
|
|||||||
return undefined
|
return undefined
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const getSearchText = useCallback(
|
||||||
|
(item: Agent) =>
|
||||||
|
[item.name, item.hostname ?? '', item.last_seen_ip ?? '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const tabFilter = useCallback((item: Agent, tabId: string) => {
|
const tabFilter = useCallback((item: Agent, tabId: string) => {
|
||||||
if (tabId === 'all') return true
|
if (tabId === 'all') return true
|
||||||
return item.status === tabId
|
return item.status === tabId
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const quickActions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'add',
|
||||||
|
title: 'Добавить агента',
|
||||||
|
description: 'Invite + install one-liner',
|
||||||
|
icon: <Plus aria-hidden />,
|
||||||
|
iconClassName: 'text-primary [&_svg]:text-current',
|
||||||
|
badgeLabel: 'Открыть',
|
||||||
|
onSelect: () => setCreateOpen(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pending',
|
||||||
|
title: 'Pending',
|
||||||
|
description: `${counts.pending} ждут approve`,
|
||||||
|
icon: <Inbox aria-hidden />,
|
||||||
|
iconClassName: 'text-warning [&_svg]:text-current',
|
||||||
|
badgeLabel: 'Показать',
|
||||||
|
onSelect: () => setActiveTab('pending'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rules',
|
||||||
|
title: 'Наборы правил',
|
||||||
|
description: 'Политика firewall',
|
||||||
|
to: '/rules',
|
||||||
|
icon: <ShieldIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-info [&_svg]:text-current',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lists',
|
||||||
|
title: 'Списки',
|
||||||
|
description: 'IP / CIDR / community',
|
||||||
|
to: '/lists',
|
||||||
|
icon: <ListIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-muted-foreground [&_svg]:text-current',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[counts.pending],
|
||||||
|
)
|
||||||
|
|
||||||
const handleCopyCurl = useCallback(
|
const handleCopyCurl = useCallback(
|
||||||
(curl: string, e?: MouseEvent) => {
|
(curl: string, e?: MouseEvent) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
@@ -252,6 +314,66 @@ function AgentsPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'dropped',
|
||||||
|
accessorFn: (row) => row.last_apply_packets_dropped ?? -1,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Dropped" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const a = row.original
|
||||||
|
const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
|
||||||
|
const text = formatPackets(a.last_apply_packets_dropped, hasApply)
|
||||||
|
if (text === '—') {
|
||||||
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<span className="text-warning cursor-default tabular-nums text-sm font-medium" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Dropped с последнего apply
|
||||||
|
{a.last_apply_at ? ` · ${a.last_apply_at}` : ''}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accepted',
|
||||||
|
accessorFn: (row) => row.last_apply_packets_accepted ?? -1,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Accepted" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const a = row.original
|
||||||
|
const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
|
||||||
|
const text = formatPackets(a.last_apply_packets_accepted, hasApply)
|
||||||
|
if (text === '—') {
|
||||||
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<span className="text-success cursor-default tabular-nums text-sm font-medium" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Accepted с последнего apply
|
||||||
|
{a.last_apply_at ? ` · ${a.last_apply_at}` : ''}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'install',
|
id: 'install',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@@ -369,6 +491,8 @@ function AgentsPage() {
|
|||||||
|
|
||||||
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
|
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
|
||||||
|
|
||||||
|
<QuickActionGrid actions={quickActions} />
|
||||||
|
|
||||||
{counts.pending > 0 ? (
|
{counts.pending > 0 ? (
|
||||||
<Frame dense spacing="sm">
|
<Frame dense spacing="sm">
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
@@ -425,6 +549,10 @@ function AgentsPage() {
|
|||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
onClearFilters={() => setFilters([])}
|
onClearFilters={() => setFilters([])}
|
||||||
getFilterFieldValue={getFilterFieldValue}
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
searchPlaceholder="Поиск агентов…"
|
||||||
|
getSearchText={getSearchText}
|
||||||
onRowClick={(row) =>
|
onRowClick={(row) =>
|
||||||
void navigate({ to: '/agents/$id', params: { id: row.id } })
|
void navigate({ to: '/agents/$id', params: { id: row.id } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ function ListsPage() {
|
|||||||
const [source, setSource] = useState<CreateSource>('static')
|
const [source, setSource] = useState<CreateSource>('static')
|
||||||
const [extra, setExtra] = useState('')
|
const [extra, setExtra] = useState('')
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -128,6 +129,10 @@ function ListsPage() {
|
|||||||
|
|
||||||
const getFilterFieldValue = useCallback(listFilterFieldValue, [])
|
const getFilterFieldValue = useCallback(listFilterFieldValue, [])
|
||||||
const tabFilter = useCallback(listTabFilter, [])
|
const tabFilter = useCallback(listTabFilter, [])
|
||||||
|
const getSearchText = useCallback(
|
||||||
|
(item: (typeof items)[number]) => item.name,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const canCreate =
|
const canCreate =
|
||||||
Boolean(name.trim()) &&
|
Boolean(name.trim()) &&
|
||||||
@@ -170,6 +175,10 @@ function ListsPage() {
|
|||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
onClearFilters={() => setFilters([])}
|
onClearFilters={() => setFilters([])}
|
||||||
getFilterFieldValue={getFilterFieldValue}
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
searchPlaceholder="Поиск списков…"
|
||||||
|
getSearchText={getSearchText}
|
||||||
tabs={[...LIST_TABS]}
|
tabs={[...LIST_TABS]}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
|
|||||||
@@ -347,15 +347,13 @@ function PolicySetDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</DetailPanel.Section>
|
</DetailPanel.Section>
|
||||||
|
|
||||||
<DetailPanel.Section
|
<DetailPanel.Section>
|
||||||
title="Правила"
|
|
||||||
description="DnD порядок · Switch вкл/выкл. Preview: c-sortable-5 · settings-8"
|
|
||||||
>
|
|
||||||
<PolicyRulesSortable
|
<PolicyRulesSortable
|
||||||
setId={setId}
|
setId={setId}
|
||||||
rules={rules}
|
rules={rules}
|
||||||
policyMode={policyMode}
|
policyMode={policyMode}
|
||||||
onDelete={(id) => setDeleteRuleId(id)}
|
onDelete={(id) => setDeleteRuleId(id)}
|
||||||
|
onAdd={() => setRuleOpen(true)}
|
||||||
/>
|
/>
|
||||||
</DetailPanel.Section>
|
</DetailPanel.Section>
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ export const Route = createFileRoute('/_auth/rules/')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Policy sets list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
* Policy sets list — ResourcePage (Frame + Filters + DataGrid + search).
|
||||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · stats-12
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* Empty: https://reui.io/preview/base/empty-state-12
|
* Empty: https://reui.io/preview/base/empty-state-12
|
||||||
* Create sheet: https://reui.io/preview/base/sheet-8
|
* Create sheet: https://reui.io/preview/base/sheet-8
|
||||||
*/
|
*/
|
||||||
@@ -46,7 +46,7 @@ function PolicySetsPage() {
|
|||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
@@ -105,12 +105,11 @@ function PolicySetsPage() {
|
|||||||
return undefined
|
return undefined
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const tabFilter = useCallback((item: PolicySet, tabId: string) => {
|
const getSearchText = useCallback(
|
||||||
if (tabId === 'all') return true
|
(item: PolicySet) =>
|
||||||
if (tabId === 'enabled') return item.enabled
|
[item.name, item.description ?? ''].filter(Boolean).join(' '),
|
||||||
if (tabId === 'disabled') return !item.enabled
|
[],
|
||||||
return true
|
)
|
||||||
}, [])
|
|
||||||
|
|
||||||
const columns: ColumnDef<PolicySet>[] = useMemo(
|
const columns: ColumnDef<PolicySet>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -248,14 +247,10 @@ function PolicySetsPage() {
|
|||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
onClearFilters={() => setFilters([])}
|
onClearFilters={() => setFilters([])}
|
||||||
getFilterFieldValue={getFilterFieldValue}
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
tabs={[
|
searchQuery={searchQuery}
|
||||||
{ id: 'all', label: 'Все' },
|
onSearchChange={setSearchQuery}
|
||||||
{ id: 'enabled', label: 'Включён' },
|
searchPlaceholder="Поиск наборов…"
|
||||||
{ id: 'disabled', label: 'Выключен' },
|
getSearchText={getSearchText}
|
||||||
]}
|
|
||||||
activeTab={activeTab}
|
|
||||||
onTabChange={setActiveTab}
|
|
||||||
tabFilter={tabFilter}
|
|
||||||
isLoading={setsQ.isLoading}
|
isLoading={setsQ.isLoading}
|
||||||
isError={setsQ.isError}
|
isError={setsQ.isError}
|
||||||
error={setsQ.error}
|
error={setsQ.error}
|
||||||
|
|||||||
Reference in New Issue
Block a user