From 33d301b19188855b1aded387c209ab0798677ab6 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 21 Jul 2026 04:03:32 +0700 Subject: [PATCH] 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 --- .../agents/agent-policy-sets-sortable.tsx | 312 +++++++++++ .../agents/agent-settings-sheets.tsx | 275 ++++++++++ .../components/reui-kit/quick-action-grid.tsx | 37 +- .../src/components/reui-kit/resource-page.tsx | 103 +++- .../rules/policy-rules-sortable.tsx | 83 ++- apps/web/src/queries/index.ts | 17 + apps/web/src/routes/_auth/agents/$id.tsx | 514 ++++++------------ apps/web/src/routes/_auth/agents/index.tsx | 128 +++++ apps/web/src/routes/_auth/lists/index.tsx | 9 + apps/web/src/routes/_auth/rules/$setId.tsx | 6 +- apps/web/src/routes/_auth/rules/index.tsx | 29 +- 11 files changed, 1082 insertions(+), 431 deletions(-) create mode 100644 apps/web/src/components/agents/agent-policy-sets-sortable.tsx create mode 100644 apps/web/src/components/agents/agent-settings-sheets.tsx diff --git a/apps/web/src/components/agents/agent-policy-sets-sortable.tsx b/apps/web/src/components/agents/agent-policy-sets-sortable.tsx new file mode 100644 index 0000000..4581af2 --- /dev/null +++ b/apps/web/src/components/agents/agent-policy-sets-sortable.tsx @@ -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([]) + const [addId, setAddId] = useState(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 ( +
+ + +
+
+ Наборы правил + + {items.length} + +
+ + Перетащите для приоритета · один режим на агента + +
+
+ + +
+
+ + {items.length === 0 ? ( + + + + ) : ( + + 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) => ( + + + + + + + +
+
+ + {row.name} + + + {row.policy_mode} + + +
+ {row.description ? ( + + {row.description} + + ) : null} +
+ + + + +
+ ))} +
+
+ )} + + + {conflictSets.length > 0 && items.length > 0 ? ( +

+ {conflictSets.length} набор(ов) скрыты из‑за другого режима ( + {assignedMode}). +

+ ) : null} +
+ ) +} diff --git a/apps/web/src/components/agents/agent-settings-sheets.tsx b/apps/web/src/components/agents/agent-settings-sheets.tsx new file mode 100644 index 0000000..f221d70 --- /dev/null +++ b/apps/web/src/components/agents/agent-settings-sheets.tsx @@ -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 ( + + + + IP override + + Мгновенные allow/deny поверх политики. Обновятся на агенте на + следующей итерации sync (~1 мин). + + + + +
+
+ + CIDR / IP + setCidr(e.target.value)} + /> + + + Действие + + + +
+ +
+

+ Активные{' '} + + {items.length} + +

+ {items.length === 0 ? ( +

Пока нет overrides

+ ) : ( +
    + {items.map((o) => ( +
  • + + {o.cidr} + + + {o.action} + + +
  • + ))} +
+ )} +
+
+
+ + + + +
+
+ ) +} + +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(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 ( + + + + Копировать наборы + + Копирует назначения наборов и overrides с другого агента. + + + +
+ + Источник + + +
+ + + + + +
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/quick-action-grid.tsx b/apps/web/src/components/reui-kit/quick-action-grid.tsx index 2485a06..af56524 100644 --- a/apps/web/src/components/reui-kit/quick-action-grid.tsx +++ b/apps/web/src/components/reui-kit/quick-action-grid.tsx @@ -16,10 +16,14 @@ export interface QuickActionItem { id: string title: string description: string - to: string + /** Route link — mutually exclusive with onSelect for navigation */ + to?: string search?: Record + /** Click handler (sheets / tab switch) when no `to` */ + onSelect?: () => void icon?: ReactNode iconClassName?: string + badgeLabel?: string } interface QuickActionGridProps { @@ -51,7 +55,7 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
{action.title} - Перейти + {action.badgeLabel ?? 'Перейти'}

@@ -64,7 +68,7 @@ function QuickActionBody({ action }: { action: QuickActionItem }) { /** * KPI-like quick actions strip (horizontal Frame tiles). - * Preview: https://reui.io/preview/base/stats-12 + * Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 */ export function QuickActionGrid({ actions, @@ -88,14 +92,25 @@ export function QuickActionGrid({ key={action.id} className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2" > - - - + {action.to ? ( + + + + ) : ( + + )} ))} diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index bb2b402..2771484 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -9,7 +9,7 @@ import { type RowSelectionState, type SortingState, } from '@tanstack/react-table' -import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react' +import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-react' import { CountedLineTabs } from '@/components/counted-line-tabs' import { Badge } from '@/components/reui/badge' @@ -31,6 +31,11 @@ import { FrameTitle, } from '@/components/reui/frame' import { Button } from '@evofw/ui/components/button' +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from '@evofw/ui/components/input-group' import { Separator } from '@evofw/ui/components/separator' import { Skeleton } from '@evofw/ui/components/skeleton' import { @@ -78,6 +83,11 @@ export interface ResourcePageProps { toolbarExtra?: ReactNode hideHeader?: boolean onRowClick?: (row: T) => void + /** Toolbar search — DNA data-grid-filtering-2 */ + searchQuery?: string + onSearchChange?: (query: string) => void + searchPlaceholder?: string + getSearchText?: (item: T) => string } function ResourcePageSkeleton() { @@ -126,6 +136,10 @@ export function ResourcePage({ toolbarExtra, hideHeader = false, onRowClick, + searchQuery = '', + onSearchChange, + searchPlaceholder = 'Поиск…', + getSearchText, }: ResourcePageProps) { const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all') const activeTab = controlledTab ?? internalTab @@ -143,17 +157,41 @@ export function ResourcePage({ ) }, []) + const applySearch = useCallback( + (items: T[]) => { + const q = searchQuery.trim().toLowerCase() + if (!q || !getSearchText) return items + return items.filter((item) => + getSearchText(item).toLowerCase().includes(q), + ) + }, + [searchQuery, getSearchText], + ) + const filteredData = useMemo(() => { - let result = applyFiltersToData(data, filters, getFilterFieldValue) + let result = applySearch(data) + result = applyFiltersToData(result, filters, getFilterFieldValue) if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') { result = result.filter((item) => tabFilter(item, activeTab)) } return result - }, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab]) + }, [ + data, + applySearch, + filters, + getFilterFieldValue, + tabs, + tabFilter, + activeTab, + ]) const tabCounts = useMemo(() => { if (!tabs?.length || !tabFilter) return {} - const base = applyFiltersToData(data, filters, getFilterFieldValue) + const base = applyFiltersToData( + applySearch(data), + filters, + getFilterFieldValue, + ) const counts: Record = {} for (const tab of tabs) { counts[tab.id] = @@ -162,7 +200,7 @@ export function ResourcePage({ : base.filter((item) => tabFilter(item, tab.id)).length } return counts - }, [tabs, tabFilter, data, filters, getFilterFieldValue]) + }, [tabs, tabFilter, data, applySearch, filters, getFilterFieldValue]) const selectedIds = useMemo( () => Object.keys(rowSelection).filter((id) => rowSelection[id]), @@ -208,8 +246,20 @@ export function ResourcePage({ const handleClear = useCallback(() => { onClearFilters?.() + onSearchChange?.('') resetPagination() - }, [onClearFilters, resetPagination]) + }, [onClearFilters, onSearchChange, resetPagination]) + + const handleSearchChange = useCallback( + (value: string) => { + onSearchChange?.(value) + resetPagination() + }, + [onSearchChange, resetPagination], + ) + + const showSearch = Boolean(onSearchChange && getSearchText) + const showClear = Boolean(onClearFilters || (showSearch && searchQuery)) const countedTabs = useMemo( () => @@ -341,18 +391,33 @@ export function ResourcePage({ ) : null}

- -
) } diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index 2f9b6f5..ac470a9 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -93,10 +93,27 @@ export const agentPolicySetsQueryOptions = (agentId: string) => name: string description?: string | null enabled: boolean + policy_mode: 'blacklist' | 'whitelist' }[] }>(`/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 = () => queryOptions({ queryKey: ['install-context'], diff --git a/apps/web/src/routes/_auth/agents/$id.tsx b/apps/web/src/routes/_auth/agents/$id.tsx index 77817fb..58a7f05 100644 --- a/apps/web/src/routes/_auth/agents/$id.tsx +++ b/apps/web/src/routes/_auth/agents/$id.tsx @@ -1,16 +1,24 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { useEffect, useMemo, useState } from 'react' -import type { ColumnDef } from '@tanstack/react-table' +import { useMemo, useRef, useState } from 'react' import { BanIcon, CheckCircle2Icon, + CircleAlertIcon, ClockIcon, Copy, CpuIcon, + CopyPlusIcon, + ShieldOffIcon, + ShieldPlusIcon, + TerminalIcon, } from 'lucide-react' -import { PageShell, DetailPanel } from '@/components/reui-kit' +import { + DetailPanel, + PageShell, + QuickActionGrid, +} from '@/components/reui-kit' import { Frame, FrameDescription, @@ -24,43 +32,25 @@ import { AlertTitle, } from '@/components/reui/alert' import { StatusBadge } from '@/components/status-badge' -import { Badge } from '@/components/reui/badge' import { AgentPlatformIcon, platformLabel, } from '@/components/agents/agent-platform-icon' import { AgentLifecycleTimeline } from '@/components/agents/agent-lifecycle-timeline' -import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' -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 { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable' import { - agentQueryOptions, - agentsQueryOptions, - agentPolicySetsQueryOptions, - policySetsQueryOptions, -} from '@/queries' + AgentCloneSetsSheet, + AgentOverrideSheet, +} from '@/components/agents/agent-settings-sheets' +import { agentQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' 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 type { PolicySet } from '@evofw/shared' -import { CircleAlertIcon } from 'lucide-react' /** * 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')({ @@ -78,20 +68,9 @@ function AgentDetailPage() { const qc = useQueryClient() const { copyToClipboard } = useCopyToClipboard() const agentQ = useQuery(agentQueryOptions(id)) - const setsQ = useQuery(policySetsQueryOptions()) - const assignedQ = useQuery(agentPolicySetsQueryOptions(id)) - const agentsQ = useQuery(agentsQueryOptions()) - const [cidr, setCidr] = useState('') - const [action, setAction] = useState<'allow' | 'deny'>('deny') - const [cloneFrom, setCloneFrom] = useState('') - const [selectedSets, setSelectedSets] = useState(null) - - useEffect(() => { - setSelectedSets(null) - }, [id, assignedQ.data]) - - const assignedIds = - selectedSets ?? assignedQ.data?.items.map((i) => i.set_id) ?? [] + const installRef = useRef(null) + const [overrideOpen, setOverrideOpen] = useState(false) + const [cloneOpen, setCloneOpen] = useState(false) const revoke = useMutation({ mutationFn: () => @@ -113,121 +92,70 @@ function AgentDetailPage() { 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[] = useMemo( - () => [ - { - id: 'select', - enableSorting: false, - header: () => Выбор, - cell: ({ row }) => ( - { - setSelectedSets( - v - ? [...assignedIds, row.original.id] - : assignedIds.filter((x) => x !== row.original.id), - ) - }} - aria-label={`Назначить ${row.original.name}`} - /> - ), - }, - { - accessorKey: 'name', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - e.stopPropagation()} - > - - - ), - }, - { - accessorKey: 'enabled', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'rules_count', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - {row.original.rules_count ?? 0} - ), - }, - ], - [assignedIds], - ) - - const setsTable = useReactTable({ - data: setsQ.data?.items ?? [], - columns: setColumns, - getCoreRowModel: getCoreRowModel(), - getRowId: (r) => r.id, - }) - const a = agentQ.data + + const quickActions = useMemo(() => { + if (!a) return [] + const actions = [ + { + id: 'override', + title: 'IP override', + description: 'Allow/deny поверх политики', + icon: , + iconClassName: 'text-warning [&_svg]:text-current', + badgeLabel: 'Открыть', + onSelect: () => setOverrideOpen(true), + }, + { + id: 'clone', + title: 'Копировать наборы', + description: 'С другого агента + overrides', + icon: , + iconClassName: 'text-info [&_svg]:text-current', + badgeLabel: 'Открыть', + onSelect: () => setCloneOpen(true), + }, + { + id: 'install', + title: 'Install curl', + description: a.install_curl ? 'Скопировать one-liner' : 'Недоступен', + icon: , + 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: , + iconClassName: 'text-success [&_svg]:text-current', + badgeLabel: 'Выполнить', + onSelect: () => approve.mutate(), + }) + } + if (a.status === 'approved') { + actions.push({ + id: 'revoke', + title: 'Revoke', + description: 'Отозвать доступ агента', + icon: , + iconClassName: 'text-destructive [&_svg]:text-current', + badgeLabel: 'Выполнить', + onSelect: () => revoke.mutate(), + }) + } + return actions + }, [a, approve, copyToClipboard, revoke]) + if (agentQ.isLoading || !a) { return ( @@ -362,214 +290,92 @@ function AgentDetailPage() { ]} /> + +
- - - Install / identity - - Copy one-liner · hostname · token - - - - {a.install_curl ? ( -
-
-                      {a.install_curl}
-                    
- +
+ + + Install / identity + + Copy one-liner · hostname · token + + + + {a.install_curl ? ( +
+
+                        {a.install_curl}
+                      
+ +
+ ) : ( +

+ Install curl недоступен +

+ )} +
+
+ Hostname:{' '} + + {a.hostname ?? '—'} + +
+
+ Last seen IP:{' '} + + {a.last_seen_ip ?? '—'} + +
+
+ Client:{' '} + + {a.client_version ?? '—'} + +
+
+ Token prefix:{' '} + + {a.token_prefix} + +
- ) : ( -

- Install curl недоступен -

- )} -
-
- Hostname:{' '} - - {a.hostname ?? '—'} - -
-
- Last seen IP:{' '} - - {a.last_seen_ip ?? '—'} - -
-
- Client:{' '} - - {a.client_version ?? '—'} - -
-
- Token prefix:{' '} - - {a.token_prefix} - -
-
-
- + + +
- - - Режим фильтра - - Задаётся наборами правил (не на агенте). Все назначенные - наборы должны иметь один режим. - - - - - {a.policy_mode === 'whitelist' - ? 'Белый список' - : 'Чёрный список'} - - - - - - - - Наборы правил - - Можно назначить несколько — мержатся при sync (один режим) - - - - - - - -
- -
- - - - - Мгновенный IP override - - Обновится на агенте на следующей итерации sync (~1 мин) - - - -
-
- - setCidr(e.target.value)} - /> -
-
- - -
- -
-
- - - - - Копировать наборы - - Копирует назначения наборов (+ overrides) с другого агента - - - -
- - -
-
- +
+ +
+ + + ) } diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index fbc119a..245d67b 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -7,8 +7,10 @@ import { CircleAlertIcon, Copy, Inbox, + ListIcon, Pencil, Plus, + ShieldIcon, Trash2, UserPlus, WifiOff, @@ -20,6 +22,7 @@ import { KpiStatGrid, PageHeader, PageShell, + QuickActionGrid, ResourcePage, } from '@/components/reui-kit' import { @@ -63,6 +66,16 @@ import { } from '@evofw/ui/components/tooltip' 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. * 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 [createOpen, setCreateOpen] = useState(false) const [filters, setFilters] = useState([]) + const [searchQuery, setSearchQuery] = useState('') const [activeTab, setActiveTab] = useState('all') const [deleteId, setDeleteId] = useState(null) @@ -184,11 +198,59 @@ function AgentsPage() { 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) => { if (tabId === 'all') return true return item.status === tabId }, []) + const quickActions = useMemo( + () => [ + { + id: 'add', + title: 'Добавить агента', + description: 'Invite + install one-liner', + icon: , + iconClassName: 'text-primary [&_svg]:text-current', + badgeLabel: 'Открыть', + onSelect: () => setCreateOpen(true), + }, + { + id: 'pending', + title: 'Pending', + description: `${counts.pending} ждут approve`, + icon: , + iconClassName: 'text-warning [&_svg]:text-current', + badgeLabel: 'Показать', + onSelect: () => setActiveTab('pending'), + }, + { + id: 'rules', + title: 'Наборы правил', + description: 'Политика firewall', + to: '/rules', + icon: , + iconClassName: 'text-info [&_svg]:text-current', + }, + { + id: 'lists', + title: 'Списки', + description: 'IP / CIDR / community', + to: '/lists', + icon: , + iconClassName: 'text-muted-foreground [&_svg]:text-current', + }, + ], + [counts.pending], + ) + const handleCopyCurl = useCallback( (curl: string, e?: MouseEvent) => { e?.stopPropagation() @@ -252,6 +314,66 @@ function AgentsPage() { ) }, }, + { + id: 'dropped', + accessorFn: (row) => row.last_apply_packets_dropped ?? -1, + header: ({ column }) => ( + + ), + 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 + } + return ( + + + } + > + {text} + + + Dropped с последнего apply + {a.last_apply_at ? ` · ${a.last_apply_at}` : ''} + + + ) + }, + }, + { + id: 'accepted', + accessorFn: (row) => row.last_apply_packets_accepted ?? -1, + header: ({ column }) => ( + + ), + 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 + } + return ( + + + } + > + {text} + + + Accepted с последнего apply + {a.last_apply_at ? ` · ${a.last_apply_at}` : ''} + + + ) + }, + }, { id: 'install', enableSorting: false, @@ -369,6 +491,8 @@ function AgentsPage() { + + {counts.pending > 0 ? ( @@ -425,6 +549,10 @@ function AgentsPage() { onFiltersChange={setFilters} onClearFilters={() => setFilters([])} getFilterFieldValue={getFilterFieldValue} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + searchPlaceholder="Поиск агентов…" + getSearchText={getSearchText} onRowClick={(row) => void navigate({ to: '/agents/$id', params: { id: row.id } }) } diff --git a/apps/web/src/routes/_auth/lists/index.tsx b/apps/web/src/routes/_auth/lists/index.tsx index 2a824c1..41831f5 100644 --- a/apps/web/src/routes/_auth/lists/index.tsx +++ b/apps/web/src/routes/_auth/lists/index.tsx @@ -65,6 +65,7 @@ function ListsPage() { const [source, setSource] = useState('static') const [extra, setExtra] = useState('') const [filters, setFilters] = useState([]) + const [searchQuery, setSearchQuery] = useState('') const [activeTab, setActiveTab] = useState('all') const [deleteListId, setDeleteListId] = useState(null) @@ -128,6 +129,10 @@ function ListsPage() { const getFilterFieldValue = useCallback(listFilterFieldValue, []) const tabFilter = useCallback(listTabFilter, []) + const getSearchText = useCallback( + (item: (typeof items)[number]) => item.name, + [], + ) const canCreate = Boolean(name.trim()) && @@ -170,6 +175,10 @@ function ListsPage() { onFiltersChange={setFilters} onClearFilters={() => setFilters([])} getFilterFieldValue={getFilterFieldValue} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + searchPlaceholder="Поиск списков…" + getSearchText={getSearchText} tabs={[...LIST_TABS]} activeTab={activeTab} onTabChange={setActiveTab} diff --git a/apps/web/src/routes/_auth/rules/$setId.tsx b/apps/web/src/routes/_auth/rules/$setId.tsx index a5f4798..7aa3635 100644 --- a/apps/web/src/routes/_auth/rules/$setId.tsx +++ b/apps/web/src/routes/_auth/rules/$setId.tsx @@ -347,15 +347,13 @@ function PolicySetDetailPage() { /> - + setDeleteRuleId(id)} + onAdd={() => setRuleOpen(true)} /> diff --git a/apps/web/src/routes/_auth/rules/index.tsx b/apps/web/src/routes/_auth/rules/index.tsx index 1e5707d..4f1ade0 100644 --- a/apps/web/src/routes/_auth/rules/index.tsx +++ b/apps/web/src/routes/_auth/rules/index.tsx @@ -33,8 +33,8 @@ export const Route = createFileRoute('/_auth/rules/')({ }) /** - * Policy sets list — ResourcePage (Frame + tabs + Filters + DataGrid). - * Preview: https://reui.io/preview/base/data-grid-filtering-2 · stats-12 + * Policy sets list — ResourcePage (Frame + Filters + DataGrid + search). + * Preview: https://reui.io/preview/base/data-grid-filtering-2 * Empty: https://reui.io/preview/base/empty-state-12 * Create sheet: https://reui.io/preview/base/sheet-8 */ @@ -46,7 +46,7 @@ function PolicySetsPage() { const [name, setName] = useState('') const [description, setDescription] = useState('') const [filters, setFilters] = useState([]) - const [activeTab, setActiveTab] = useState('all') + const [searchQuery, setSearchQuery] = useState('') const [deleteId, setDeleteId] = useState(null) const create = useMutation({ @@ -105,12 +105,11 @@ function PolicySetsPage() { return undefined }, []) - const tabFilter = useCallback((item: PolicySet, tabId: string) => { - if (tabId === 'all') return true - if (tabId === 'enabled') return item.enabled - if (tabId === 'disabled') return !item.enabled - return true - }, []) + const getSearchText = useCallback( + (item: PolicySet) => + [item.name, item.description ?? ''].filter(Boolean).join(' '), + [], + ) const columns: ColumnDef[] = useMemo( () => [ @@ -248,14 +247,10 @@ function PolicySetsPage() { onFiltersChange={setFilters} onClearFilters={() => setFilters([])} getFilterFieldValue={getFilterFieldValue} - tabs={[ - { id: 'all', label: 'Все' }, - { id: 'enabled', label: 'Включён' }, - { id: 'disabled', label: 'Выключен' }, - ]} - activeTab={activeTab} - onTabChange={setActiveTab} - tabFilter={tabFilter} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + searchPlaceholder="Поиск наборов…" + getSearchText={getSearchText} isLoading={setsQ.isLoading} isError={setsQ.isError} error={setsQ.error}