feat(web): bulk-операции в таблице агентов и подтверждения опасных действий
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user