Карточка агента — отдельная страница вместо Sheet: заголовок с действиями, ошибка применения, полоса показателей и линейные вкладки (политика, хост-фаервол, правила портов, заблокированное). Ссылки ?agent= ведут на страницу. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { Link, useNavigate } from '@tanstack/react-router'
|
|
import { toast } from 'sonner'
|
|
import { useState } from 'react'
|
|
import { ActivityIcon, ArrowLeftIcon, CircleAlertIcon, ClockIcon, CpuIcon, ShieldIcon } from 'lucide-react'
|
|
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
|
import { KpiStatGrid } from '@/components/reui-kit'
|
|
import { StatusBadge } from '@/components/status-badge'
|
|
import { platformLabel } from '@/components/agents/agent-platform-icon'
|
|
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
|
import { agentTrafficAccepted, agentTrafficDropped } from '@/components/agents/agent-traffic'
|
|
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
|
|
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
|
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
|
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
|
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
|
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
|
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
|
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
|
import { AgentCloneSetsSheet, AgentOverrideSheet } from '@/components/agents/agent-settings-sheets'
|
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
import { agentPreviewQueryOptions, agentQueryOptions } from '@/queries'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { formatDateTime, formatNumber, formatRelativeTime } from '@/lib/format'
|
|
import { useCan } from '@/lib/permissions'
|
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
|
import { Button } from '@evofw/ui/components/button'
|
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
|
import { Tabs, TabsContent } from '@evofw/ui/components/tabs'
|
|
|
|
type Confirm = 'revoke' | 'delete' | 'reset' | null
|
|
|
|
/**
|
|
* Страница агента по доске FwAgentDetail: заголовок с действиями, ошибка применения,
|
|
* полоса показателей и линейные вкладки «Политика», «Хост-фаервол», «Правила портов», «Заблокированное».
|
|
*/
|
|
export function AgentDetailPage({ agentId }: { agentId: string }) {
|
|
const qc = useQueryClient()
|
|
const navigate = useNavigate()
|
|
const { copyToClipboard } = useCopyToClipboard()
|
|
const canWrite = useCan()('fw:agents:write')
|
|
const agentQ = useQuery(agentQueryOptions(agentId))
|
|
const previewQ = useQuery(agentPreviewQueryOptions(agentId))
|
|
const [tab, setTab] = useState('policy')
|
|
const [overrideOpen, setOverrideOpen] = useState(false)
|
|
const [cloneOpen, setCloneOpen] = useState(false)
|
|
const [confirm, setConfirm] = useState<Confirm>(null)
|
|
|
|
const invalidate = () => {
|
|
void qc.invalidateQueries({ queryKey: ['agents'] })
|
|
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
|
}
|
|
const fail = (e: Error) => toast.error(e.message)
|
|
|
|
const approve = useMutation({
|
|
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
|
|
onSuccess: () => {
|
|
toast.success('Агент одобрен')
|
|
invalidate()
|
|
},
|
|
onError: fail,
|
|
})
|
|
const revoke = useMutation({
|
|
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
|
|
onSuccess: () => {
|
|
toast.success('Агент отозван')
|
|
invalidate()
|
|
},
|
|
onError: fail,
|
|
})
|
|
const resetStats = useMutation({
|
|
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/stats/reset`, { method: 'POST' }),
|
|
onSuccess: () => {
|
|
toast.success('Статистика сброшена')
|
|
invalidate()
|
|
for (const key of ['stats', 'blocked-ips', 'blocked-ports']) {
|
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, key] })
|
|
}
|
|
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
|
|
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
|
},
|
|
onError: fail,
|
|
})
|
|
const remove = useMutation({
|
|
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}`, { method: 'DELETE' }),
|
|
onSuccess: () => {
|
|
toast.success('Агент удалён')
|
|
void qc.invalidateQueries({ queryKey: ['agents'] })
|
|
void navigate({ to: '/agents', search: { view: 'cards' } })
|
|
},
|
|
onError: fail,
|
|
})
|
|
|
|
const a = agentQ.data
|
|
|
|
if (agentQ.isError) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<CircleAlertIcon />
|
|
<AlertTitle>Ошибка загрузки</AlertTitle>
|
|
<AlertDescription className="flex flex-col gap-2">
|
|
<span>{agentQ.error?.message ?? 'Не удалось загрузить агента'}</span>
|
|
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={() => void agentQ.refetch()}>
|
|
Повторить
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)
|
|
}
|
|
|
|
if (agentQ.isLoading || !a) {
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<Skeleton className="h-10 w-56" />
|
|
<Skeleton className="h-24 w-full" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const isLinux = a.platform === 'linux'
|
|
const sub = [
|
|
a.hostname,
|
|
platformLabel(a.platform),
|
|
`поколение ${a.policy_generation}`,
|
|
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ')
|
|
|
|
const confirmCopy: Record<Exclude<Confirm, null>, { title: string; label: string; text: string; run: () => void; pending: boolean }> = {
|
|
revoke: {
|
|
title: 'Отозвать агента?',
|
|
label: 'Отозвать',
|
|
text: `Агент «${a.name}» потеряет доступ к API управления и перестанет получать обновления политики. Действие нельзя отменить.`,
|
|
run: () => revoke.mutate(),
|
|
pending: revoke.isPending,
|
|
},
|
|
delete: {
|
|
title: 'Удалить агента?',
|
|
label: 'Удалить',
|
|
text: `Агент «${a.name}» будет удалён вместе с ссылками и статистикой. Это действие нельзя отменить.`,
|
|
run: () => remove.mutate(),
|
|
pending: remove.isPending,
|
|
},
|
|
reset: {
|
|
title: 'Сбросить статистику?',
|
|
label: 'Сбросить',
|
|
text: `Счётчики пакетов и история статистики агента «${a.name}» будут обнулены и удалены. Действие нельзя отменить.`,
|
|
run: () => resetStats.mutate(),
|
|
pending: resetStats.isPending,
|
|
},
|
|
}
|
|
const active = confirm ? confirmCopy[confirm] : null
|
|
|
|
return (
|
|
<>
|
|
<Button variant="ghost" size="sm" className="-ml-2 w-fit" render={<Link to="/agents" search={{ view: 'cards' }} />} nativeButton={false}>
|
|
<ArrowLeftIcon aria-hidden /> Агенты
|
|
</Button>
|
|
<PageHead
|
|
title={a.name}
|
|
sub={sub}
|
|
actions={
|
|
<>
|
|
<span className="flex items-center gap-1.5">
|
|
<AgentOnlineDot agent={a} />
|
|
<StatusBadge status={a.status} />
|
|
</span>
|
|
{canWrite ? (
|
|
<>
|
|
<Button variant="outline" onClick={() => setOverrideOpen(true)}>
|
|
IP override
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setCloneOpen(true)}>
|
|
Копировать наборы
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
{a.install_curl ? (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
copyToClipboard(a.install_curl!)
|
|
toast.success('Команда установки скопирована')
|
|
}}
|
|
>
|
|
Команда установки
|
|
</Button>
|
|
) : null}
|
|
{canWrite && a.status === 'pending' ? (
|
|
<Button onClick={() => approve.mutate()} disabled={approve.isPending}>
|
|
Утвердить
|
|
</Button>
|
|
) : null}
|
|
{canWrite && a.status === 'approved' ? (
|
|
<Button variant="outline" onClick={() => setConfirm('revoke')}>
|
|
Отозвать
|
|
</Button>
|
|
) : null}
|
|
{canWrite ? (
|
|
<Button variant="outline" className="text-destructive" onClick={() => setConfirm('delete')}>
|
|
Удалить агента
|
|
</Button>
|
|
) : null}
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{a.last_apply_error ? (
|
|
<Alert variant="destructive">
|
|
<CircleAlertIcon />
|
|
<AlertTitle>Ошибка применения политики.</AlertTitle>
|
|
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<KpiStatGrid
|
|
aria-label="Показатели агента"
|
|
cards={[
|
|
{
|
|
id: 'traffic',
|
|
label: 'Трафик',
|
|
value: `↓${formatNumber(agentTrafficDropped(a))} · ↑${formatNumber(agentTrafficAccepted(a))}`,
|
|
hint: 'накопительно',
|
|
icon: <ActivityIcon aria-hidden />,
|
|
iconClassName: 'text-warning',
|
|
variant: 'warning',
|
|
onClick: canWrite ? () => setConfirm('reset') : undefined,
|
|
},
|
|
{ id: 'kernel', label: 'Ядро', value: a.last_apply_kernel_method ?? '—', hint: 'метод применения', icon: <CpuIcon aria-hidden />, iconClassName: 'text-info' },
|
|
{
|
|
id: 'apply',
|
|
label: 'Последнее применение',
|
|
value: formatDateTime(a.last_apply_at),
|
|
hint: a.last_apply_at ? formatRelativeTime(a.last_apply_at) : undefined,
|
|
icon: <ClockIcon aria-hidden />,
|
|
},
|
|
{
|
|
id: 'default',
|
|
label: 'Если не совпало',
|
|
value: a.default_action === 'drop' ? 'Drop' : 'Accept',
|
|
hint: 'default action',
|
|
icon: <ShieldIcon aria-hidden />,
|
|
variant: a.default_action === 'drop' ? 'warning' : 'default',
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Tabs value={tab} onValueChange={setTab} className="min-w-0 gap-4">
|
|
<PageTabsList>
|
|
<PageTab value="policy" label="Политика" />
|
|
{isLinux ? <PageTab value="host" label="Хост-фаервол" /> : null}
|
|
{isLinux ? <PageTab value="acl" label="Правила портов" /> : null}
|
|
<PageTab value="hits" label="Заблокированное" />
|
|
</PageTabsList>
|
|
<TabsContent value="policy" className="flex min-w-0 flex-col gap-5">
|
|
<div className="@container grid gap-5 @4xl:grid-cols-3">
|
|
<div className="@4xl:col-span-2">
|
|
<AgentPolicyTrace preview={previewQ.data} isLoading={previewQ.isLoading} />
|
|
</div>
|
|
<AgentFactsPanel agent={a} />
|
|
</div>
|
|
<AgentPolicySetsSortable agentId={agentId} />
|
|
<AgentEffectiveCidrs preview={previewQ.data} isLoading={previewQ.isLoading} />
|
|
</TabsContent>
|
|
{isLinux ? (
|
|
<TabsContent value="host" className="min-w-0">
|
|
<AgentHostFirewall agentId={agentId} />
|
|
</TabsContent>
|
|
) : null}
|
|
{isLinux ? (
|
|
<TabsContent value="acl" className="min-w-0">
|
|
<AgentPortAcl agentId={agentId} />
|
|
</TabsContent>
|
|
) : null}
|
|
<TabsContent value="hits" className="flex min-w-0 flex-col gap-5">
|
|
{isLinux ? <AgentBlockedPorts agentId={agentId} /> : null}
|
|
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<AgentOverrideSheet agentId={agentId} open={overrideOpen} onOpenChange={setOverrideOpen} />
|
|
<AgentCloneSetsSheet agentId={agentId} open={cloneOpen} onOpenChange={setCloneOpen} />
|
|
<ConfirmDialog
|
|
open={confirm !== null}
|
|
onOpenChange={(o) => !o && setConfirm(null)}
|
|
title={active?.title ?? ''}
|
|
confirmLabel={active?.label ?? ''}
|
|
description={active?.text ?? ''}
|
|
onConfirm={() => {
|
|
active?.run()
|
|
setConfirm(null)
|
|
}}
|
|
disabled={active?.pending}
|
|
/>
|
|
</>
|
|
)
|
|
}
|