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 { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
import { AgentPortAcl } from '@/components/agents/agent-port-acl' import { AgentPortAcl } from '@/components/agents/agent-port-acl'
import { CountedLineTabs } from '@/components/counted-line-tabs' import { CountedLineTabs } from '@/components/counted-line-tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { import {
AgentCloneSetsSheet, AgentCloneSetsSheet,
AgentOverrideSheet, AgentOverrideSheet,
@@ -79,6 +80,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
const installRef = useRef<HTMLDivElement>(null) const installRef = useRef<HTMLDivElement>(null)
const [overrideOpen, setOverrideOpen] = useState(false) const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false) const [cloneOpen, setCloneOpen] = useState(false)
const [revokeOpen, setRevokeOpen] = useState(false)
const [resetStatsOpen, setResetStatsOpen] = useState(false)
const [fwTab, setFwTab] = useState('host') const [fwTab, setFwTab] = useState('host')
const revoke = useMutation({ const revoke = useMutation({
@@ -187,10 +190,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => revoke.mutate()} onClick={() => setRevokeOpen(true)}
disabled={revoke.isPending} disabled={revoke.isPending}
> >
Revoke Отозвать
</Button> </Button>
) : null} ) : null}
{a.install_curl ? ( {a.install_curl ? (
@@ -283,7 +286,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
size="sm" size="sm"
variant="outline" variant="outline"
disabled={resetStats.isPending} disabled={resetStats.isPending}
onClick={() => resetStats.mutate()} onClick={() => setResetStatsOpen(true)}
> >
Сбросить Сбросить
</Button> </Button>
@@ -374,6 +377,32 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
open={cloneOpen} open={cloneOpen}
onOpenChange={setCloneOpen} 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 { 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 { Check, Copy, PanelRight, Trash2 } from 'lucide-react'
import type { Agent } from '@evofw/shared' import type { Agent } from '@evofw/shared'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
@@ -55,6 +55,15 @@ export type AgentFleetDataGridProps = {
onRetry?: () => void onRetry?: () => void
emptyAction?: ReactNode emptyAction?: ReactNode
toolbarExtra?: 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 onSelect: (id: string) => void
onApprove: (id: string) => void onApprove: (id: string) => void
approvePending?: boolean approvePending?: boolean
@@ -82,6 +91,10 @@ export function AgentFleetDataGrid({
onRetry, onRetry,
emptyAction, emptyAction,
toolbarExtra, toolbarExtra,
enableRowSelection,
rowSelection,
onRowSelectionChange,
selectionToolbar,
onSelect, onSelect,
onApprove, onApprove,
approvePending, approvePending,
@@ -346,6 +359,10 @@ export function AgentFleetDataGrid({
getSearchText={getSearchText} getSearchText={getSearchText}
tableLayout={{ width: 'fixed', columnsResizable: true }} tableLayout={{ width: 'fixed', columnsResizable: true }}
onRowClick={(row) => onSelect(row.id)} onRowClick={(row) => onSelect(row.id)}
enableRowSelection={enableRowSelection}
rowSelection={rowSelection}
onRowSelectionChange={onRowSelectionChange}
selectionToolbar={selectionToolbar}
tabs={tabs} tabs={tabs}
activeTab={activeTab} activeTab={activeTab}
onTabChange={onTabChange} onTabChange={onTabChange}
+4 -1
View File
@@ -18,6 +18,8 @@ interface ConfirmDialogProps {
title: string title: string
description: string description: string
confirmLabel?: string confirmLabel?: string
/** 'default' для позитивных действий (утвердить), 'destructive' — для опасных. */
confirmVariant?: 'default' | 'destructive'
cancelLabel?: string cancelLabel?: string
onConfirm: () => void onConfirm: () => void
disabled?: boolean disabled?: boolean
@@ -30,6 +32,7 @@ export function ConfirmDialog({
title, title,
description, description,
confirmLabel = 'Удалить', confirmLabel = 'Удалить',
confirmVariant = 'destructive',
cancelLabel = 'Отмена', cancelLabel = 'Отмена',
onConfirm, onConfirm,
disabled, disabled,
@@ -46,7 +49,7 @@ export function ConfirmDialog({
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel> <AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}> <AlertDialogAction variant={confirmVariant} onClick={onConfirm}>
{confirmLabel} {confirmLabel}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@@ -80,6 +80,9 @@ export interface ResourcePageProps<T extends object> {
emptyState?: { title: string; description?: string; action?: ReactNode } emptyState?: { title: string; description?: string; action?: ReactNode }
pageSize?: number pageSize?: number
enableRowSelection?: boolean enableRowSelection?: boolean
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
rowSelection?: RowSelectionState
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
selectionToolbar?: (ctx: { selectionToolbar?: (ctx: {
selectedIds: string[] selectedIds: string[]
selectedCount: number selectedCount: number
@@ -141,6 +144,8 @@ export function ResourcePage<T extends object>({
emptyState, emptyState,
pageSize = 10, pageSize = 10,
enableRowSelection = false, enableRowSelection = false,
rowSelection: rowSelectionProp,
onRowSelectionChange: onRowSelectionChangeProp,
selectionToolbar, selectionToolbar,
toolbarExtra, toolbarExtra,
hideHeader = false, hideHeader = false,
@@ -156,7 +161,30 @@ export function ResourcePage<T extends object>({
const activeTab = controlledTab ?? internalTab const activeTab = controlledTab ?? internalTab
const [sorting, setSorting] = useState<SortingState>([]) 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>( const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(
() => columnPinningProp ?? {}, () => columnPinningProp ?? {},
) )
@@ -224,8 +252,8 @@ export function ResourcePage<T extends object>({
const selectedCount = selectedIds.length const selectedCount = selectedIds.length
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
setRowSelection({}) handleRowSelectionChange({})
}, []) }, [handleRowSelectionChange])
const tableLayout = useMemo( const tableLayout = useMemo(
() => ({ () => ({
@@ -253,7 +281,7 @@ export function ResourcePage<T extends object>({
// без drag-handles — enableColumnResizing остаётся false. // без drag-handles — enableColumnResizing остаётся false.
enableColumnResizing: false, enableColumnResizing: false,
onSortingChange: setSorting, onSortingChange: setSorting,
onRowSelectionChange: setRowSelection, onRowSelectionChange: handleRowSelectionChange,
onPaginationChange: setPagination, onPaginationChange: setPagination,
onColumnPinningChange: setColumnPinning, onColumnPinningChange: setColumnPinning,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
+166 -10
View File
@@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { RowSelectionState } from '@tanstack/react-table'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
@@ -13,6 +14,7 @@ import {
Plus, Plus,
ShieldIcon, ShieldIcon,
TableIcon, TableIcon,
Trash2,
UserPlus, UserPlus,
WifiOff, WifiOff,
} from 'lucide-react' } from 'lucide-react'
@@ -29,6 +31,7 @@ import {
FrameHeader, FrameHeader,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { import {
Alert, Alert,
AlertDescription, AlertDescription,
@@ -85,12 +88,18 @@ export const Route = createFileRoute('/_auth/agents/')({
const AGENT_TABS = [ const AGENT_TABS = [
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
{ id: 'invited', label: 'Invited' }, { id: 'invited', label: 'Приглашённые' },
{ id: 'pending', label: 'Pending' }, { id: 'pending', label: 'Ожидают' },
{ id: 'approved', label: 'Approved' }, { id: 'approved', label: 'Одобренные' },
{ id: 'revoked', label: 'Revoked' }, { id: 'revoked', label: 'Отозванные' },
] as const ] 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() { function AgentsPage() {
const navigate = useNavigate({ from: Route.fullPath }) const navigate = useNavigate({ from: Route.fullPath })
const { agent: detailAgentId, view } = Route.useSearch() const { agent: detailAgentId, view } = Route.useSearch()
@@ -101,6 +110,9 @@ function AgentsPage() {
const canWrite = useCan()('fw:agents:write') const canWrite = useCan()('fw:agents:write')
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null) 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 [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all') const [activeTab, setActiveTab] = useState('all')
@@ -156,7 +168,7 @@ function AgentsPage() {
}, },
}) })
const approveAllPending = useMutation({ const approveBulk = useMutation({
mutationFn: (ids: string[]) => mutationFn: (ids: string[]) =>
apiFetch('/api/v1/agents/approve-bulk', { apiFetch('/api/v1/agents/approve-bulk', {
method: 'POST', method: 'POST',
@@ -170,9 +182,39 @@ function AgentsPage() {
), ),
) )
}, },
onSuccess: () => { onSuccess: (_data, ids) => {
toast.success('Все pending одобрены') toast.success(`Одобрено агентов: ${ids.length}`)
setRowSelection({})
void qc.invalidateQueries({ queryKey: ['agents'] }) 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) => { onError: (e: Error, _ids, rollback) => {
rollback?.() rollback?.()
@@ -205,6 +247,77 @@ function AgentsPage() {
() => items.filter((a) => a.status === 'pending').map((a) => a.id), () => items.filter((a) => a.status === 'pending').map((a) => a.id),
[items], [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( const kpiCards = useMemo(
() => () =>
@@ -460,12 +573,12 @@ function AgentsPage() {
<Button <Button
size="sm" size="sm"
disabled={ disabled={
approveAllPending.isPending || pendingIds.length === 0 approveBulk.isPending || pendingIds.length === 0
} }
onClick={() => approveAllPending.mutate(pendingIds)} onClick={() => setApproveAllOpen(true)}
> >
<Check data-icon="inline-start" /> <Check data-icon="inline-start" />
Approve all Утвердить всех
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -505,6 +618,10 @@ function AgentsPage() {
onRetry={() => void agentsQ.refetch()} onRetry={() => void agentsQ.refetch()}
emptyAction={addButton} emptyAction={addButton}
toolbarExtra={viewToggle} toolbarExtra={viewToggle}
enableRowSelection={canWrite}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
selectionToolbar={selectionToolbar}
onSelect={handleSelectAgent} onSelect={handleSelectAgent}
onApprove={(id) => approve.mutate(id)} onApprove={(id) => approve.mutate(id)}
approvePending={approve.isPending} approvePending={approve.isPending}
@@ -578,6 +695,45 @@ function AgentsPage() {
}} }}
disabled={removeAgent.isPending} 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> </PageShell>
) )
} }