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:
@@ -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<string[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSets(null)
|
||||
}, [id, assignedQ.data])
|
||||
|
||||
const assignedIds =
|
||||
selectedSets ?? assignedQ.data?.items.map((i) => i.set_id) ?? []
|
||||
const installRef = useRef<HTMLDivElement>(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<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 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) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -362,214 +290,92 @@ function AgentDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<QuickActionGrid actions={quickActions} />
|
||||
|
||||
<DetailPanel.Section>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<AgentLifecycleTimeline agent={a} />
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Install / identity</FrameTitle>
|
||||
<FrameDescription>
|
||||
Copy one-liner · hostname · token
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
{a.install_curl ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
|
||||
{a.install_curl}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Копировать
|
||||
</Button>
|
||||
<div ref={installRef}>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Install / identity</FrameTitle>
|
||||
<FrameDescription>
|
||||
Copy one-liner · hostname · token
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
{a.install_curl ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
|
||||
{a.install_curl}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Копировать
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Режим фильтра</FrameTitle>
|
||||
<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 className="lg:col-span-2">
|
||||
<AgentPolicySetsSortable agentId={id} />
|
||||
</div>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
<AgentOverrideSheet
|
||||
agentId={id}
|
||||
open={overrideOpen}
|
||||
onOpenChange={setOverrideOpen}
|
||||
/>
|
||||
<AgentCloneSetsSheet
|
||||
agentId={id}
|
||||
open={cloneOpen}
|
||||
onOpenChange={setCloneOpen}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(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: <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(
|
||||
(curl: string, e?: MouseEvent) => {
|
||||
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',
|
||||
enableSorting: false,
|
||||
@@ -369,6 +491,8 @@ function AgentsPage() {
|
||||
|
||||
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
|
||||
|
||||
<QuickActionGrid actions={quickActions} />
|
||||
|
||||
{counts.pending > 0 ? (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ function ListsPage() {
|
||||
const [source, setSource] = useState<CreateSource>('static')
|
||||
const [extra, setExtra] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [deleteListId, setDeleteListId] = useState<string | null>(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}
|
||||
|
||||
@@ -347,15 +347,13 @@ function PolicySetDetailPage() {
|
||||
/>
|
||||
</DetailPanel.Section>
|
||||
|
||||
<DetailPanel.Section
|
||||
title="Правила"
|
||||
description="DnD порядок · Switch вкл/выкл. Preview: c-sortable-5 · settings-8"
|
||||
>
|
||||
<DetailPanel.Section>
|
||||
<PolicyRulesSortable
|
||||
setId={setId}
|
||||
rules={rules}
|
||||
policyMode={policyMode}
|
||||
onDelete={(id) => setDeleteRuleId(id)}
|
||||
onAdd={() => setRuleOpen(true)}
|
||||
/>
|
||||
</DetailPanel.Section>
|
||||
|
||||
|
||||
@@ -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<Filter[]>([])
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(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<PolicySet>[] = 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}
|
||||
|
||||
Reference in New Issue
Block a user