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): 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): 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(null) const [approveAllOpen, setApproveAllOpen] = useState(false) const [bulkDeleteIds, setBulkDeleteIds] = useState(null) const [rowSelection, setRowSelection] = useState({}) const [filters, setFilters] = useState([]) 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) => { void navigate({ search: (prev) => { const base = parseAgentsSearch(prev as Record) 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 (
{ctx.selectedCount} выбрано
{approvableSelected.length > 0 ? ( ) : null}
) }, [ canWrite, approvableSelected, selectedIds, approveBulk.isPending, deleteBulk.isPending, clearSelection, ], ) const kpiCards = useMemo( () => fleetKpiCards(counts, { pending: , invited: , approved: , stale: , }).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 = {} 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: , iconClassName: 'text-primary [&_svg]:text-current', badgeLabel: 'Открыть', onSelect: () => setCreateOpenState(true), }, { id: 'pending', title: 'Ожидают одобрения', description: `${counts.pending} ждут подтверждения`, icon: , iconClassName: 'text-warning [&_svg]:text-current', badgeLabel: 'Показать', onSelect: () => setActiveTab('pending'), }, { id: 'rules', title: 'Наборы правил', description: 'Политика фаервола', to: '/rules', icon: , iconClassName: 'text-info [&_svg]:text-current', }, { id: 'lists', title: 'Списки', description: 'IP / CIDR / community', to: '/lists', icon: , 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 = ( { const v = next[0] if (v === 'cards' || v === 'table') { setSearch({ view: v }) } }} variant="outline" size="sm" spacing={0} aria-label="Вид списка" > ) const addButton = canWrite ? ( ) : null return ( {addButton} } /> {showQuickActions ? : null} {counts.pending > 0 ? (
Требуют одобрения {counts.pending} агент(ов) ждут одобрения
{canWrite ? ( ) : null}
) : null} {applyErrorAgents.length > 0 ? (
Ошибки применения политики У {applyErrorAgents.length} агент(ов) последняя попытка apply завершилась ошибкой — откройте карточку для деталей.
{applyErrorAgents.map((a) => ( ))} ) : null} {view === 'table' ? ( 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} /> ) : ( void agentsQ.refetch()} > )} { setCreateOpenState(open) if (!open && addParam) setSearch({ add: false }) }} /> { if (!open) setSearch({ agent: '' }) }} onDelete={handleDeleteAgent} /> { if (!open) setDeleteAgentId(null) }} title="Удалить агента?" description={ deleteTargetName ? `Агент «${deleteTargetName}» будет удалён вместе с overrides, install-ссылками и статистикой. Это действие нельзя отменить.` : 'Агент будет удалён вместе с overrides, install-ссылками и статистикой. Это действие нельзя отменить.' } onConfirm={() => { if (deleteAgentId) removeAgent.mutate(deleteAgentId) }} disabled={removeAgent.isPending} /> 0 ? `Будут одобрены ${pendingIds.length} агент(ов): ${formatNames(pendingNames)}. Агентам будет назначен общий набор правил по умолчанию.` : 'Нет агентов, ожидающих одобрения.' } onConfirm={() => { setApproveAllOpen(false) approveBulk.mutate(pendingIds) }} disabled={approveBulk.isPending || pendingIds.length === 0} /> { 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} />
) }