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

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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 04:03:32 +07:00
co-authored by Cursor
parent 0653313a57
commit 33d301b191
11 changed files with 1082 additions and 431 deletions
@@ -0,0 +1,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>
) )
} }
+17
View File
@@ -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'],
+160 -354
View File
@@ -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>
) )
} }
+128
View File
@@ -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}
+2 -4
View File
@@ -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>
+12 -17
View File
@@ -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}