812 lines
26 KiB
TypeScript
812 lines
26 KiB
TypeScript
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'
|
||
import {
|
||
Check,
|
||
CheckCircle2,
|
||
CircleAlertIcon,
|
||
Inbox,
|
||
LayoutGridIcon,
|
||
ListIcon,
|
||
Plus,
|
||
ShieldIcon,
|
||
TableIcon,
|
||
Trash2,
|
||
UserPlus,
|
||
WifiOff,
|
||
} from 'lucide-react'
|
||
import {
|
||
applyFiltersToData,
|
||
KpiStatGrid,
|
||
PageHeader,
|
||
PageShell,
|
||
QuickActionGrid,
|
||
} from '@/components/reui-kit'
|
||
import {
|
||
Frame,
|
||
FrameDescription,
|
||
FrameHeader,
|
||
FramePanel,
|
||
FrameTitle,
|
||
} from '@/components/reui/frame'
|
||
import { Badge } from '@/components/reui/badge'
|
||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
|
||
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
|
||
import { AgentFleetDataGrid } from '@/components/agents/agent-fleet-data-grid'
|
||
import { AgentsFleetChrome } from '@/components/agents/agents-fleet-chrome'
|
||
import {
|
||
computeFleetCounts,
|
||
fleetKpiCards,
|
||
} from '@/components/agents/agents-fleet-kpis'
|
||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||
import { QueryState } from '@/components/query-state'
|
||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||
import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
|
||
import { apiFetch } from '@/lib/api'
|
||
import { isAgentOnline } from '@/lib/agent-online'
|
||
import { useCan } from '@/lib/permissions'
|
||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||
import { Button } from '@evofw/ui/components/button'
|
||
import {
|
||
ToggleGroup,
|
||
ToggleGroupItem,
|
||
} from '@evofw/ui/components/toggle-group'
|
||
import type { Agent } from '@evofw/shared'
|
||
|
||
/**
|
||
* Agents ops console — cards/table toggle + full detail Sheet.
|
||
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12
|
||
* · https://reui.io/preview/base/data-grid-filtering-2 · solution-agents-2
|
||
* · https://reui.io/preview/base/solution-inventory-9 · solution-agents-3
|
||
*/
|
||
|
||
type AgentsSearch = {
|
||
agent?: string
|
||
view: 'cards' | 'table'
|
||
/** Открыть sheet добавления агента (из ⌘K-палитры). */
|
||
add?: boolean
|
||
}
|
||
|
||
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
|
||
const view = search.view === 'table' ? 'table' : 'cards'
|
||
const agent =
|
||
typeof search.agent === 'string' && search.agent.length > 0
|
||
? search.agent
|
||
: undefined
|
||
const add = search.add === true
|
||
return { view, agent, add }
|
||
}
|
||
|
||
export const Route = createFileRoute('/_auth/agents/')({
|
||
validateSearch: (search: Record<string, unknown>): AgentsSearch =>
|
||
parseAgentsSearch(search),
|
||
component: AgentsPage,
|
||
})
|
||
|
||
const AGENT_TABS = [
|
||
{ id: 'all', label: 'Все' },
|
||
{ 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, add: addParam } = Route.useSearch()
|
||
const qc = useQueryClient()
|
||
const agentsQ = useQuery(agentsQueryOptions())
|
||
const settingsQ = useQuery(settingsQueryOptions())
|
||
const { copyToClipboard } = useCopyToClipboard()
|
||
const canWrite = useCan()('fw:agents:write')
|
||
const [createOpenState, setCreateOpenState] = useState(false)
|
||
const createOpen = createOpenState || addParam === true
|
||
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')
|
||
|
||
const detailOpen = Boolean(detailAgentId)
|
||
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
|
||
|
||
const setSearch = useCallback(
|
||
(next: Partial<AgentsSearch>) => {
|
||
void navigate({
|
||
search: (prev) => {
|
||
const base = parseAgentsSearch(prev as Record<string, unknown>)
|
||
return {
|
||
view: next.view ?? base.view,
|
||
agent:
|
||
next.agent !== undefined
|
||
? next.agent || undefined
|
||
: base.agent,
|
||
add:
|
||
next.add !== undefined
|
||
? next.add || undefined
|
||
: base.add,
|
||
} satisfies AgentsSearch
|
||
},
|
||
replace: true,
|
||
})
|
||
},
|
||
[navigate],
|
||
)
|
||
|
||
/** Optimistically patch the agents list; returns a rollback fn. */
|
||
const patchAgentsCache = useCallback(
|
||
(patch: (items: Agent[]) => Agent[]) => {
|
||
const prev = qc.getQueryData<{ items: Agent[] }>(['agents'])
|
||
if (prev) qc.setQueryData(['agents'], { items: patch(prev.items) })
|
||
return () => {
|
||
if (prev) qc.setQueryData(['agents'], prev)
|
||
}
|
||
},
|
||
[qc],
|
||
)
|
||
|
||
const approve = useMutation({
|
||
mutationFn: (id: string) =>
|
||
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
|
||
onMutate: (id) =>
|
||
patchAgentsCache((items) =>
|
||
items.map((a) => (a.id === id ? { ...a, status: 'approved' } : a)),
|
||
),
|
||
onSuccess: () => {
|
||
toast.success('Агент одобрен')
|
||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||
},
|
||
onError: (e: Error, _id, rollback) => {
|
||
rollback?.()
|
||
toast.error(e.message)
|
||
},
|
||
})
|
||
|
||
const approveBulk = useMutation({
|
||
mutationFn: (ids: string[]) =>
|
||
apiFetch('/api/v1/agents/approve-bulk', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ agent_ids: ids }),
|
||
}),
|
||
onMutate: (ids) => {
|
||
const target = new Set(ids)
|
||
return patchAgentsCache((items) =>
|
||
items.map((a) =>
|
||
target.has(a.id) ? { ...a, status: 'approved' } : a,
|
||
),
|
||
)
|
||
},
|
||
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?.()
|
||
toast.error(e.message)
|
||
},
|
||
})
|
||
|
||
const removeAgent = useMutation({
|
||
mutationFn: (id: string) =>
|
||
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
|
||
onMutate: (id) => patchAgentsCache((items) => items.filter((a) => a.id !== id)),
|
||
onSuccess: (_data, id) => {
|
||
toast.success('Агент удалён')
|
||
setDeleteAgentId(null)
|
||
if (detailAgentId === id) {
|
||
setSearch({ agent: '' })
|
||
}
|
||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||
},
|
||
onError: (e: Error, _id, rollback) => {
|
||
rollback?.()
|
||
toast.error(e.message)
|
||
},
|
||
})
|
||
|
||
const items = agentsQ.data?.items ?? []
|
||
const counts = useMemo(() => computeFleetCounts(items), [items])
|
||
const pendingIds = useMemo(
|
||
() => 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(
|
||
() =>
|
||
fleetKpiCards(counts, {
|
||
pending: <Inbox aria-hidden />,
|
||
invited: <UserPlus aria-hidden />,
|
||
approved: <CheckCircle2 aria-hidden />,
|
||
stale: <WifiOff aria-hidden />,
|
||
}).map((card) => ({
|
||
...card,
|
||
onSelect: () => {
|
||
if (card.id === 'pending') setActiveTab('pending')
|
||
else if (card.id === 'invited') setActiveTab('invited')
|
||
else if (card.id === 'approved') setActiveTab('approved')
|
||
else if (card.id === 'stale') {
|
||
setActiveTab('all')
|
||
setFilters([
|
||
{ id: 'stale', field: 'online', operator: 'is', values: ['offline'] },
|
||
])
|
||
}
|
||
},
|
||
})),
|
||
[counts],
|
||
)
|
||
|
||
const filterFields: FilterFieldConfig[] = useMemo(
|
||
() => [
|
||
{
|
||
key: 'name',
|
||
label: 'Имя',
|
||
type: 'text',
|
||
placeholder: 'Поиск…',
|
||
},
|
||
{
|
||
key: 'status',
|
||
label: 'Статус',
|
||
type: 'select',
|
||
options: [
|
||
{ value: 'invited', label: 'Приглашён' },
|
||
{ value: 'pending', label: 'Ожидает одобрения' },
|
||
{ value: 'approved', label: 'Одобрен' },
|
||
{ value: 'revoked', label: 'Отозван' },
|
||
],
|
||
},
|
||
{
|
||
key: 'platform',
|
||
label: 'Платформа',
|
||
type: 'select',
|
||
options: [
|
||
{ value: 'linux', label: 'Linux' },
|
||
{ value: 'mikrotik', label: 'MikroTik' },
|
||
],
|
||
},
|
||
{
|
||
key: 'online',
|
||
label: 'На связи',
|
||
type: 'select',
|
||
options: [
|
||
{ value: 'online', label: 'На связи' },
|
||
{ value: 'offline', label: 'Не на связи' },
|
||
],
|
||
},
|
||
],
|
||
[],
|
||
)
|
||
|
||
const getFilterFieldValue = useCallback((item: Agent, field: string) => {
|
||
if (field === 'name') return item.name
|
||
if (field === 'status') return item.status
|
||
if (field === 'platform') return item.platform
|
||
if (field === 'online') return isAgentOnline(item) ? 'online' : 'offline'
|
||
return undefined
|
||
}, [])
|
||
|
||
const applySearch = useCallback(
|
||
(data: Agent[]) => {
|
||
const q = searchQuery.trim().toLowerCase()
|
||
if (!q) return data
|
||
return data.filter((item) =>
|
||
[item.name, item.hostname ?? '', item.last_seen_ip ?? '']
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
.toLowerCase()
|
||
.includes(q),
|
||
)
|
||
},
|
||
[searchQuery],
|
||
)
|
||
|
||
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 filteredItems = useMemo(() => {
|
||
let result = applySearch(items)
|
||
result = applyFiltersToData(result, filters, getFilterFieldValue)
|
||
if (activeTab !== 'all') {
|
||
result = result.filter((item) => tabFilter(item, activeTab))
|
||
}
|
||
return result
|
||
}, [items, applySearch, filters, getFilterFieldValue, activeTab, tabFilter])
|
||
|
||
const tabCounts = useMemo(() => {
|
||
const base = applyFiltersToData(
|
||
applySearch(items),
|
||
filters,
|
||
getFilterFieldValue,
|
||
)
|
||
const next: Record<string, number> = {}
|
||
for (const tab of AGENT_TABS) {
|
||
next[tab.id] =
|
||
tab.id === 'all'
|
||
? base.length
|
||
: base.filter((item) => tabFilter(item, tab.id)).length
|
||
}
|
||
return next
|
||
}, [items, applySearch, filters, getFilterFieldValue, tabFilter])
|
||
|
||
const countedTabs = useMemo(
|
||
() =>
|
||
AGENT_TABS.map((tab) => ({
|
||
id: tab.id,
|
||
label: tab.label,
|
||
count: tabCounts[tab.id] ?? 0,
|
||
})),
|
||
[tabCounts],
|
||
)
|
||
|
||
const quickActions = useMemo(
|
||
() => [
|
||
{
|
||
id: 'add',
|
||
title: 'Добавить агента',
|
||
description: 'Приглашение и команда установки',
|
||
icon: <Plus aria-hidden />,
|
||
iconClassName: 'text-primary [&_svg]:text-current',
|
||
badgeLabel: 'Открыть',
|
||
onSelect: () => setCreateOpenState(true),
|
||
},
|
||
{
|
||
id: 'pending',
|
||
title: 'Ожидают одобрения',
|
||
description: `${counts.pending} ждут подтверждения`,
|
||
icon: <Inbox aria-hidden />,
|
||
iconClassName: 'text-warning [&_svg]:text-current',
|
||
badgeLabel: 'Показать',
|
||
onSelect: () => setActiveTab('pending'),
|
||
},
|
||
{
|
||
id: 'rules',
|
||
title: 'Наборы правил',
|
||
description: 'Политика фаервола',
|
||
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 applyErrorAgents = useMemo(
|
||
() => items.filter((a) => a.last_apply_error),
|
||
[items],
|
||
)
|
||
|
||
const filteredEmptyMessage = useMemo(() => {
|
||
switch (activeTab) {
|
||
case 'pending':
|
||
return 'Нет агентов на одобрении — всё обработано.'
|
||
case 'invited':
|
||
return 'Нет приглашённых агентов.'
|
||
case 'approved':
|
||
return 'Нет одобренных агентов.'
|
||
case 'revoked':
|
||
return 'Нет отозванных агентов.'
|
||
default:
|
||
return undefined
|
||
}
|
||
}, [activeTab])
|
||
|
||
const handleSelectAgent = useCallback(
|
||
(id: string) => {
|
||
setSearch({ agent: id })
|
||
},
|
||
[setSearch],
|
||
)
|
||
|
||
const handleDeleteAgent = useCallback((id: string) => {
|
||
setDeleteAgentId(id)
|
||
}, [])
|
||
|
||
const deleteTargetName = useMemo(
|
||
() => items.find((a) => a.id === deleteAgentId)?.name,
|
||
[items, deleteAgentId],
|
||
)
|
||
|
||
const handleClearFilters = useCallback(() => {
|
||
setFilters([])
|
||
setSearchQuery('')
|
||
}, [])
|
||
|
||
const handleCopyInstall = useCallback(
|
||
(curl: string) => {
|
||
copyToClipboard(curl)
|
||
toast.success('Скопировано')
|
||
},
|
||
[copyToClipboard],
|
||
)
|
||
|
||
const viewToggle = (
|
||
<ToggleGroup
|
||
multiple={false}
|
||
value={[view]}
|
||
onValueChange={(next) => {
|
||
const v = next[0]
|
||
if (v === 'cards' || v === 'table') {
|
||
setSearch({ view: v })
|
||
}
|
||
}}
|
||
variant="outline"
|
||
size="sm"
|
||
spacing={0}
|
||
aria-label="Вид списка"
|
||
>
|
||
<ToggleGroupItem value="cards" aria-label="Карточки">
|
||
<LayoutGridIcon className="size-4" />
|
||
</ToggleGroupItem>
|
||
<ToggleGroupItem value="table" aria-label="Таблица">
|
||
<TableIcon className="size-4" />
|
||
</ToggleGroupItem>
|
||
</ToggleGroup>
|
||
)
|
||
|
||
const addButton = canWrite ? (
|
||
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||
<Plus data-icon="inline-start" />
|
||
Добавить агента
|
||
</Button>
|
||
) : null
|
||
|
||
return (
|
||
<PageShell>
|
||
<PageHeader
|
||
title="Агенты"
|
||
description="Приглашение, установка и одобрение агентов"
|
||
actions={
|
||
<>
|
||
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
|
||
{addButton}
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
|
||
|
||
{showQuickActions ? <QuickActionGrid actions={quickActions} /> : null}
|
||
|
||
{counts.pending > 0 ? (
|
||
<Frame dense spacing="sm">
|
||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||
<div className="flex min-w-0 flex-col gap-px">
|
||
<FrameTitle>Требуют одобрения</FrameTitle>
|
||
<FrameDescription>
|
||
{counts.pending} агент(ов) ждут одобрения
|
||
</FrameDescription>
|
||
</div>
|
||
<div className="flex shrink-0 flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setActiveTab('pending')}
|
||
>
|
||
Показать
|
||
</Button>
|
||
{canWrite ? (
|
||
<Button
|
||
size="sm"
|
||
disabled={
|
||
approveBulk.isPending || pendingIds.length === 0
|
||
}
|
||
onClick={() => setApproveAllOpen(true)}
|
||
>
|
||
<Check data-icon="inline-start" />
|
||
Утвердить всех
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</FrameHeader>
|
||
</Frame>
|
||
) : null}
|
||
|
||
{applyErrorAgents.length > 0 ? (
|
||
<Frame dense spacing="sm">
|
||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||
<div className="flex min-w-0 flex-col gap-px">
|
||
<FrameTitle>Ошибки применения политики</FrameTitle>
|
||
<FrameDescription>
|
||
У {applyErrorAgents.length} агент(ов) последняя попытка apply
|
||
завершилась ошибкой — откройте карточку для деталей.
|
||
</FrameDescription>
|
||
</div>
|
||
</FrameHeader>
|
||
<FramePanel className="flex flex-wrap gap-2">
|
||
{applyErrorAgents.map((a) => (
|
||
<Button
|
||
key={a.id}
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-destructive"
|
||
title={a.last_apply_error ?? undefined}
|
||
onClick={() => setSearch({ agent: a.id })}
|
||
>
|
||
<CircleAlertIcon className="size-3.5" aria-hidden />
|
||
{a.name}
|
||
</Button>
|
||
))}
|
||
</FramePanel>
|
||
</Frame>
|
||
) : null}
|
||
|
||
{view === 'table' ? (
|
||
<AgentFleetDataGrid
|
||
data={items}
|
||
filterFields={filterFields}
|
||
filters={filters}
|
||
onFiltersChange={setFilters}
|
||
onClearFilters={handleClearFilters}
|
||
getFilterFieldValue={getFilterFieldValue}
|
||
searchQuery={searchQuery}
|
||
onSearchChange={setSearchQuery}
|
||
getSearchText={getSearchText}
|
||
tabs={[...AGENT_TABS]}
|
||
activeTab={activeTab}
|
||
onTabChange={setActiveTab}
|
||
tabFilter={tabFilter}
|
||
isLoading={agentsQ.isLoading}
|
||
isError={agentsQ.isError}
|
||
error={agentsQ.error}
|
||
onRetry={() => void agentsQ.refetch()}
|
||
emptyAction={addButton}
|
||
filteredEmptyMessage={filteredEmptyMessage}
|
||
toolbarExtra={viewToggle}
|
||
enableRowSelection={canWrite}
|
||
rowSelection={rowSelection}
|
||
onRowSelectionChange={setRowSelection}
|
||
selectionToolbar={selectionToolbar}
|
||
onSelect={handleSelectAgent}
|
||
onApprove={(id) => approve.mutate(id)}
|
||
approvePending={approve.isPending}
|
||
onCopyInstall={handleCopyInstall}
|
||
onDelete={handleDeleteAgent}
|
||
/>
|
||
) : (
|
||
<QueryState
|
||
isError={agentsQ.isError}
|
||
error={agentsQ.error}
|
||
onRetry={() => void agentsQ.refetch()}
|
||
>
|
||
<AgentsFleetChrome
|
||
tabs={countedTabs}
|
||
activeTab={activeTab}
|
||
onTabChange={setActiveTab}
|
||
filterFields={filterFields}
|
||
filters={filters}
|
||
onFiltersChange={setFilters}
|
||
onClearFilters={handleClearFilters}
|
||
searchQuery={searchQuery}
|
||
onSearchChange={setSearchQuery}
|
||
toolbarExtra={viewToggle}
|
||
>
|
||
<AgentCardsGrid
|
||
agents={filteredItems}
|
||
selectedId={detailOpen ? detailAgentId : null}
|
||
onSelect={handleSelectAgent}
|
||
onDelete={handleDeleteAgent}
|
||
isLoading={agentsQ.isLoading}
|
||
emptyTitle={
|
||
items.length === 0
|
||
? 'Нет агентов'
|
||
: 'Нет записей по фильтрам'
|
||
}
|
||
emptyDescription={
|
||
items.length === 0
|
||
? 'Создайте агента — он появится в каталоге со статусом «Приглашён» и командой установки.'
|
||
: undefined
|
||
}
|
||
emptyAction={items.length === 0 ? addButton : undefined}
|
||
/>
|
||
</AgentsFleetChrome>
|
||
</QueryState>
|
||
)}
|
||
|
||
<AddAgentSheet
|
||
open={createOpen}
|
||
onOpenChange={(open) => {
|
||
setCreateOpenState(open)
|
||
if (!open && addParam) setSearch({ add: false })
|
||
}}
|
||
/>
|
||
|
||
<AgentDetailSheet
|
||
agentId={detailAgentId ?? null}
|
||
open={detailOpen}
|
||
onOpenChange={(open) => {
|
||
if (!open) setSearch({ agent: '' })
|
||
}}
|
||
onDelete={handleDeleteAgent}
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
open={deleteAgentId !== null}
|
||
onOpenChange={(open) => {
|
||
if (!open) setDeleteAgentId(null)
|
||
}}
|
||
title="Удалить агента?"
|
||
description={
|
||
deleteTargetName
|
||
? `Агент «${deleteTargetName}» будет удалён вместе с overrides, install-ссылками и статистикой. Это действие нельзя отменить.`
|
||
: 'Агент будет удалён вместе с overrides, install-ссылками и статистикой. Это действие нельзя отменить.'
|
||
}
|
||
onConfirm={() => {
|
||
if (deleteAgentId) removeAgent.mutate(deleteAgentId)
|
||
}}
|
||
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>
|
||
)
|
||
}
|