Files
EvoFirewall/apps/web/src/routes/_auth/agents/index.tsx
T

812 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}