feat(web): bulk-операции в таблице агентов и подтверждения опасных действий

This commit is contained in:
Denozordec
2026-09-25 01:07:05 +07:00
parent 0f7daf77e3
commit 98e4dcdefb
5 changed files with 252 additions and 19 deletions
@@ -38,6 +38,7 @@ import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
AgentCloneSetsSheet,
AgentOverrideSheet,
@@ -79,6 +80,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
const installRef = useRef<HTMLDivElement>(null)
const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false)
const [revokeOpen, setRevokeOpen] = useState(false)
const [resetStatsOpen, setResetStatsOpen] = useState(false)
const [fwTab, setFwTab] = useState('host')
const revoke = useMutation({
@@ -187,10 +190,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
<Button
variant="outline"
size="sm"
onClick={() => revoke.mutate()}
onClick={() => setRevokeOpen(true)}
disabled={revoke.isPending}
>
Revoke
Отозвать
</Button>
) : null}
{a.install_curl ? (
@@ -283,7 +286,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
size="sm"
variant="outline"
disabled={resetStats.isPending}
onClick={() => resetStats.mutate()}
onClick={() => setResetStatsOpen(true)}
>
Сбросить
</Button>
@@ -374,6 +377,32 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
open={cloneOpen}
onOpenChange={setCloneOpen}
/>
<ConfirmDialog
open={revokeOpen}
onOpenChange={setRevokeOpen}
title="Отозвать агента?"
confirmLabel="Отозвать"
description={`Агент «${a.name}» потеряет доступ к API управления и перестанет получать обновления политики. Действие нельзя отменить.`}
onConfirm={() => {
setRevokeOpen(false)
revoke.mutate()
}}
disabled={revoke.isPending}
/>
<ConfirmDialog
open={resetStatsOpen}
onOpenChange={setResetStatsOpen}
title="Сбросить статистику?"
confirmLabel="Сбросить"
description={`Счётчики пакетов и история статистики агента «${a.name}» будут обнулены и удалены. Действие нельзя отменить.`}
onConfirm={() => {
setResetStatsOpen(false)
resetStats.mutate()
}}
disabled={resetStats.isPending}
/>
</>
)
}
@@ -1,5 +1,5 @@
import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
import { Check, Copy, PanelRight, Trash2 } from 'lucide-react'
import type { Agent } from '@evofw/shared'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
@@ -55,6 +55,15 @@ export type AgentFleetDataGridProps = {
onRetry?: () => void
emptyAction?: ReactNode
toolbarExtra?: ReactNode
/** Bulk-операции: controlled selection пробрасывается в ResourcePage. */
enableRowSelection?: boolean
rowSelection?: RowSelectionState
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
selectionToolbar?: (ctx: {
selectedIds: string[]
selectedCount: number
clearSelection: () => void
}) => ReactNode
onSelect: (id: string) => void
onApprove: (id: string) => void
approvePending?: boolean
@@ -82,6 +91,10 @@ export function AgentFleetDataGrid({
onRetry,
emptyAction,
toolbarExtra,
enableRowSelection,
rowSelection,
onRowSelectionChange,
selectionToolbar,
onSelect,
onApprove,
approvePending,
@@ -346,6 +359,10 @@ export function AgentFleetDataGrid({
getSearchText={getSearchText}
tableLayout={{ width: 'fixed', columnsResizable: true }}
onRowClick={(row) => onSelect(row.id)}
enableRowSelection={enableRowSelection}
rowSelection={rowSelection}
onRowSelectionChange={onRowSelectionChange}
selectionToolbar={selectionToolbar}
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
+4 -1
View File
@@ -18,6 +18,8 @@ interface ConfirmDialogProps {
title: string
description: string
confirmLabel?: string
/** 'default' для позитивных действий (утвердить), 'destructive' — для опасных. */
confirmVariant?: 'default' | 'destructive'
cancelLabel?: string
onConfirm: () => void
disabled?: boolean
@@ -30,6 +32,7 @@ export function ConfirmDialog({
title,
description,
confirmLabel = 'Удалить',
confirmVariant = 'destructive',
cancelLabel = 'Отмена',
onConfirm,
disabled,
@@ -46,7 +49,7 @@ export function ConfirmDialog({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
<AlertDialogAction variant={confirmVariant} onClick={onConfirm}>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
@@ -80,6 +80,9 @@ export interface ResourcePageProps<T extends object> {
emptyState?: { title: string; description?: string; action?: ReactNode }
pageSize?: number
enableRowSelection?: boolean
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
rowSelection?: RowSelectionState
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
selectionToolbar?: (ctx: {
selectedIds: string[]
selectedCount: number
@@ -141,6 +144,8 @@ export function ResourcePage<T extends object>({
emptyState,
pageSize = 10,
enableRowSelection = false,
rowSelection: rowSelectionProp,
onRowSelectionChange: onRowSelectionChangeProp,
selectionToolbar,
toolbarExtra,
hideHeader = false,
@@ -156,7 +161,30 @@ export function ResourcePage<T extends object>({
const activeTab = controlledTab ?? internalTab
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const isSelectionControlled = rowSelectionProp !== undefined
const [internalRowSelection, setInternalRowSelection] =
useState<RowSelectionState>({})
const rowSelection = isSelectionControlled
? rowSelectionProp
: internalRowSelection
const handleRowSelectionChange = useCallback(
(
updater: RowSelectionState | ((prev: RowSelectionState) => RowSelectionState),
) => {
const current = isSelectionControlled
? rowSelectionProp
: internalRowSelection
const next = typeof updater === 'function' ? updater(current) : updater
if (isSelectionControlled) onRowSelectionChangeProp?.(next)
else setInternalRowSelection(next)
},
[
isSelectionControlled,
rowSelectionProp,
internalRowSelection,
onRowSelectionChangeProp,
],
)
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(
() => columnPinningProp ?? {},
)
@@ -224,8 +252,8 @@ export function ResourcePage<T extends object>({
const selectedCount = selectedIds.length
const clearSelection = useCallback(() => {
setRowSelection({})
}, [])
handleRowSelectionChange({})
}, [handleRowSelectionChange])
const tableLayout = useMemo(
() => ({
@@ -253,7 +281,7 @@ export function ResourcePage<T extends object>({
// без drag-handles — enableColumnResizing остаётся false.
enableColumnResizing: false,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onRowSelectionChange: handleRowSelectionChange,
onPaginationChange: setPagination,
onColumnPinningChange: setColumnPinning,
getCoreRowModel: getCoreRowModel(),
+166 -10
View File
@@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { RowSelectionState } from '@tanstack/react-table'
import { toast } from 'sonner'
import { useCallback, useMemo, useState } from 'react'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
@@ -13,6 +14,7 @@ import {
Plus,
ShieldIcon,
TableIcon,
Trash2,
UserPlus,
WifiOff,
} from 'lucide-react'
@@ -29,6 +31,7 @@ import {
FrameHeader,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import {
Alert,
AlertDescription,
@@ -85,12 +88,18 @@ export const Route = createFileRoute('/_auth/agents/')({
const AGENT_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'invited', label: 'Invited' },
{ id: 'pending', label: 'Pending' },
{ id: 'approved', label: 'Approved' },
{ id: 'revoked', label: 'Revoked' },
{ id: 'invited', label: 'Приглашённые' },
{ id: 'pending', label: 'Ожидают' },
{ id: 'approved', label: 'Одобренные' },
{ id: 'revoked', label: 'Отозванные' },
] as const
/** Список имён для подтверждений: первые 5, дальше «и ещё N». */
function formatNames(names: string[]): string {
if (names.length <= 5) return names.join(', ')
return `${names.slice(0, 5).join(', ')} и ещё ${names.length - 5}`
}
function AgentsPage() {
const navigate = useNavigate({ from: Route.fullPath })
const { agent: detailAgentId, view } = Route.useSearch()
@@ -101,6 +110,9 @@ function AgentsPage() {
const canWrite = useCan()('fw:agents:write')
const [createOpen, setCreateOpen] = useState(false)
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
const [approveAllOpen, setApproveAllOpen] = useState(false)
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all')
@@ -156,7 +168,7 @@ function AgentsPage() {
},
})
const approveAllPending = useMutation({
const approveBulk = useMutation({
mutationFn: (ids: string[]) =>
apiFetch('/api/v1/agents/approve-bulk', {
method: 'POST',
@@ -170,9 +182,39 @@ function AgentsPage() {
),
)
},
onSuccess: () => {
toast.success('Все pending одобрены')
onSuccess: (_data, ids) => {
toast.success(`Одобрено агентов: ${ids.length}`)
setRowSelection({})
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: (e: Error, _ids, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const deleteBulk = useMutation({
mutationFn: (ids: string[]) =>
apiFetch('/api/v1/agents/delete-bulk', {
method: 'POST',
body: JSON.stringify({ agent_ids: ids }),
}),
onMutate: (ids) => {
const target = new Set(ids)
return patchAgentsCache((items) =>
items.filter((a) => !target.has(a.id)),
)
},
onSuccess: (_data, ids) => {
toast.success(`Удалено агентов: ${ids.length}`)
setBulkDeleteIds(null)
setRowSelection({})
if (detailAgentId && ids.includes(detailAgentId)) {
setSearch({ agent: '' })
}
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: (e: Error, _ids, rollback) => {
rollback?.()
@@ -205,6 +247,77 @@ function AgentsPage() {
() => items.filter((a) => a.status === 'pending').map((a) => a.id),
[items],
)
const pendingNames = useMemo(
() =>
items.filter((a) => a.status === 'pending').map((a) => a.name),
[items],
)
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
)
const selectedAgents = useMemo(
() => items.filter((a) => selectedIds.includes(a.id)),
[items, selectedIds],
)
const approvableSelected = useMemo(
() =>
selectedAgents.filter(
(a) => a.status === 'pending' || a.status === 'invited',
),
[selectedAgents],
)
const clearSelection = useCallback(() => setRowSelection({}), [])
const selectionToolbar = useCallback(
(ctx: { selectedCount: number }) => {
if (ctx.selectedCount === 0 || !canWrite) return null
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<Badge variant="secondary" size="sm">
{ctx.selectedCount} выбрано
</Badge>
<div className="flex flex-wrap items-center gap-2">
{approvableSelected.length > 0 ? (
<Button
size="sm"
variant="outline"
disabled={approveBulk.isPending}
onClick={() =>
approveBulk.mutate(approvableSelected.map((a) => a.id))
}
>
<Check data-icon="inline-start" />
Утвердить ({approvableSelected.length})
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="text-destructive"
disabled={deleteBulk.isPending}
onClick={() => setBulkDeleteIds(selectedIds)}
>
<Trash2 data-icon="inline-start" />
Удалить
</Button>
<Button size="sm" variant="ghost" onClick={clearSelection}>
Снять выделение
</Button>
</div>
</div>
)
},
[
canWrite,
approvableSelected,
selectedIds,
approveBulk.isPending,
deleteBulk.isPending,
clearSelection,
],
)
const kpiCards = useMemo(
() =>
@@ -460,12 +573,12 @@ function AgentsPage() {
<Button
size="sm"
disabled={
approveAllPending.isPending || pendingIds.length === 0
approveBulk.isPending || pendingIds.length === 0
}
onClick={() => approveAllPending.mutate(pendingIds)}
onClick={() => setApproveAllOpen(true)}
>
<Check data-icon="inline-start" />
Approve all
Утвердить всех
</Button>
) : null}
</div>
@@ -505,6 +618,10 @@ function AgentsPage() {
onRetry={() => void agentsQ.refetch()}
emptyAction={addButton}
toolbarExtra={viewToggle}
enableRowSelection={canWrite}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
selectionToolbar={selectionToolbar}
onSelect={handleSelectAgent}
onApprove={(id) => approve.mutate(id)}
approvePending={approve.isPending}
@@ -578,6 +695,45 @@ function AgentsPage() {
}}
disabled={removeAgent.isPending}
/>
<ConfirmDialog
open={approveAllOpen}
onOpenChange={setApproveAllOpen}
title="Утвердить всех ожидающих?"
confirmLabel="Утвердить"
confirmVariant="default"
description={
pendingIds.length > 0
? `Будут одобрены ${pendingIds.length} агент(ов): ${formatNames(pendingNames)}. Агентам будет назначен общий набор правил по умолчанию.`
: 'Нет агентов, ожидающих одобрения.'
}
onConfirm={() => {
setApproveAllOpen(false)
approveBulk.mutate(pendingIds)
}}
disabled={approveBulk.isPending || pendingIds.length === 0}
/>
<ConfirmDialog
open={bulkDeleteIds !== null}
onOpenChange={(open) => {
if (!open) setBulkDeleteIds(null)
}}
title="Удалить выбранных агентов?"
description={
bulkDeleteIds && bulkDeleteIds.length > 0
? `Будут удалены ${bulkDeleteIds.length} агент(ов): ${formatNames(
selectedAgents
.filter((a) => bulkDeleteIds.includes(a.id))
.map((a) => a.name),
)}. Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.`
: 'Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.'
}
onConfirm={() => {
if (bulkDeleteIds) deleteBulk.mutate(bulkDeleteIds)
}}
disabled={deleteBulk.isPending}
/>
</PageShell>
)
}