425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { toast } from 'sonner'
|
|
import { useRef, useState } from 'react'
|
|
import {
|
|
ActivityIcon,
|
|
CircleAlertIcon,
|
|
ClockIcon,
|
|
Copy,
|
|
CopyPlusIcon,
|
|
CpuIcon,
|
|
MoreHorizontalIcon,
|
|
ShieldPlusIcon,
|
|
TerminalIcon,
|
|
Trash2,
|
|
} from 'lucide-react'
|
|
import { DetailPanel } from '@/components/reui-kit'
|
|
import {
|
|
Alert,
|
|
AlertDescription,
|
|
AlertTitle,
|
|
} from '@/components/reui/alert'
|
|
import { Badge } from '@/components/reui/badge'
|
|
import { StatusBadge } from '@/components/status-badge'
|
|
import { AgentPlatformIcon, 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 { CountedLineTabs } from '@/components/counted-line-tabs'
|
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
import {
|
|
AgentCloneSetsSheet,
|
|
AgentOverrideSheet,
|
|
} from '@/components/agents/agent-settings-sheets'
|
|
import {
|
|
agentPreviewQueryOptions,
|
|
agentQueryOptions,
|
|
} from '@/queries'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { formatDateTime, formatRelativeTime } from '@/lib/format'
|
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
|
import { Button } from '@evofw/ui/components/button'
|
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
|
import { TabsContent } from '@evofw/ui/components/tabs'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@evofw/ui/components/dropdown-menu'
|
|
|
|
/**
|
|
* Full agent detail body — SA3 DNA for Sheet (and redirect target).
|
|
* Preview: https://reui.io/preview/base/solution-agents-3
|
|
* · https://reui.io/preview/base/stats-12
|
|
* · https://reui.io/preview/base/form-7
|
|
*/
|
|
|
|
type AgentDetailViewProps = {
|
|
agentId: string
|
|
onDelete?: (id: string) => void
|
|
}
|
|
|
|
export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|
const qc = useQueryClient()
|
|
const { copyToClipboard } = useCopyToClipboard()
|
|
const agentQ = useQuery(agentQueryOptions(agentId))
|
|
const previewQ = useQuery(agentPreviewQueryOptions(agentId))
|
|
const installRef = useRef<HTMLDivElement>(null)
|
|
const [overrideOpen, setOverrideOpen] = useState(false)
|
|
const [cloneOpen, setCloneOpen] = useState(false)
|
|
const [revokeOpen, setRevokeOpen] = useState(false)
|
|
const [resetStatsOpen, setResetStatsOpen] = useState(false)
|
|
const [fwTab, setFwTab] = useState('host')
|
|
|
|
const revoke = useMutation({
|
|
mutationFn: () =>
|
|
apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
|
|
onSuccess: () => {
|
|
toast.success('Агент отозван')
|
|
void qc.invalidateQueries({ queryKey: ['agents'] })
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
})
|
|
|
|
const approve = useMutation({
|
|
mutationFn: () =>
|
|
apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
|
|
onSuccess: () => {
|
|
toast.success('Агент одобрен')
|
|
void qc.invalidateQueries({ queryKey: ['agents'] })
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
})
|
|
|
|
const resetStats = useMutation({
|
|
mutationFn: () =>
|
|
apiFetch(`/api/v1/agents/${agentId}/stats/reset`, { method: 'POST' }),
|
|
onSuccess: () => {
|
|
toast.success('Статистика сброшена')
|
|
void qc.invalidateQueries({ queryKey: ['agents'] })
|
|
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
|
void qc.invalidateQueries({ queryKey: ['stats'] })
|
|
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
})
|
|
|
|
const a = agentQ.data
|
|
|
|
if (agentQ.isLoading || !a) {
|
|
return (
|
|
<div className="flex flex-col gap-4 p-4">
|
|
<Skeleton className="h-10 w-56" />
|
|
<Skeleton className="h-24 w-full" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (agentQ.isError) {
|
|
return (
|
|
<div className="p-4">
|
|
<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>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const headerDesc = [
|
|
a.hostname,
|
|
platformLabel(a.platform),
|
|
`поколение ${a.policy_generation}`,
|
|
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ')
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col gap-4 p-4">
|
|
<DetailPanel>
|
|
<DetailPanel.Header
|
|
title={a.name}
|
|
description={headerDesc}
|
|
actions={
|
|
<>
|
|
<AgentPlatformIcon platform={a.platform} />
|
|
<span className="flex items-center gap-1.5">
|
|
<AgentOnlineDot agent={a} />
|
|
<StatusBadge status={a.status} />
|
|
</span>
|
|
<Badge
|
|
variant={
|
|
a.default_action === 'drop'
|
|
? 'warning-light'
|
|
: 'success-light'
|
|
}
|
|
size="sm"
|
|
radius="full"
|
|
>
|
|
{a.default_action === 'drop' ? 'Drop' : 'Accept'}
|
|
</Badge>
|
|
{a.status === 'pending' ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => approve.mutate()}
|
|
disabled={approve.isPending}
|
|
>
|
|
Approve
|
|
</Button>
|
|
) : null}
|
|
{a.status === 'approved' ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setRevokeOpen(true)}
|
|
disabled={revoke.isPending}
|
|
>
|
|
Отозвать
|
|
</Button>
|
|
) : null}
|
|
{a.install_curl ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
copyToClipboard(a.install_curl!)
|
|
toast.success('Скопировано')
|
|
}}
|
|
>
|
|
<Copy data-icon="inline-start" />
|
|
Install
|
|
</Button>
|
|
) : null}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
aria-label="Ещё"
|
|
/>
|
|
}
|
|
>
|
|
<MoreHorizontalIcon className="size-4" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => setOverrideOpen(true)}>
|
|
<ShieldPlusIcon className="size-4" />
|
|
IP override
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setCloneOpen(true)}>
|
|
<CopyPlusIcon className="size-4" />
|
|
Копировать наборы
|
|
</DropdownMenuItem>
|
|
{a.install_curl ? (
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
copyToClipboard(a.install_curl!)
|
|
toast.success('Скопировано')
|
|
installRef.current?.scrollIntoView({
|
|
behavior: 'smooth',
|
|
})
|
|
}}
|
|
>
|
|
<TerminalIcon className="size-4" />
|
|
Install curl
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{onDelete ? (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={() => onDelete(agentId)}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
Удалить агента
|
|
</DropdownMenuItem>
|
|
</>
|
|
) : null}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{a.last_apply_error ? (
|
|
<Alert variant="destructive">
|
|
<CircleAlertIcon />
|
|
<AlertTitle>Ошибка apply</AlertTitle>
|
|
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<DetailPanel.Metrics
|
|
cards={[
|
|
{
|
|
id: 'traffic',
|
|
icon: <ActivityIcon aria-hidden />,
|
|
iconClassName: 'text-warning',
|
|
label: 'Traffic',
|
|
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
|
|
hint: 'накопительно',
|
|
variant: 'warning',
|
|
footer: (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={resetStats.isPending}
|
|
onClick={() => setResetStatsOpen(true)}
|
|
>
|
|
Сбросить
|
|
</Button>
|
|
),
|
|
},
|
|
{
|
|
id: 'kernel',
|
|
icon: <CpuIcon aria-hidden />,
|
|
iconClassName: 'text-info',
|
|
label: 'Kernel',
|
|
description: a.last_apply_kernel_method ?? '—',
|
|
},
|
|
{
|
|
id: 'apply',
|
|
icon: <ClockIcon aria-hidden />,
|
|
iconClassName: 'text-primary',
|
|
label: 'Last apply',
|
|
description: formatDateTime(a.last_apply_at),
|
|
hint: a.last_apply_at
|
|
? formatRelativeTime(a.last_apply_at)
|
|
: undefined,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<DetailPanel.Section>
|
|
<div className="@container flex flex-col gap-4">
|
|
<div className="grid gap-4 @4xl:grid-cols-3">
|
|
<div className="@4xl:col-span-2">
|
|
<AgentPolicyTrace
|
|
preview={previewQ.data}
|
|
isLoading={previewQ.isLoading}
|
|
/>
|
|
</div>
|
|
<AgentFactsPanel agent={a} />
|
|
</div>
|
|
|
|
<div ref={installRef}>
|
|
<AgentPolicySetsSortable agentId={agentId} />
|
|
</div>
|
|
|
|
<AgentEffectiveCidrs
|
|
preview={previewQ.data}
|
|
isLoading={previewQ.isLoading}
|
|
/>
|
|
|
|
{a.platform === 'linux' ? (
|
|
<div className="flex flex-col gap-3">
|
|
<CountedLineTabs
|
|
value={fwTab}
|
|
onValueChange={setFwTab}
|
|
tabs={[
|
|
{ id: 'host', label: 'Host firewall' },
|
|
{ id: 'acl', label: 'Port ACL' },
|
|
{ id: 'hits', label: 'Blocked' },
|
|
]}
|
|
>
|
|
<TabsContent value="host" className="mt-3">
|
|
<AgentHostFirewall agentId={agentId} />
|
|
</TabsContent>
|
|
<TabsContent value="acl" className="mt-3">
|
|
<AgentPortAcl agentId={agentId} />
|
|
</TabsContent>
|
|
<TabsContent
|
|
value="hits"
|
|
className="mt-3 flex flex-col gap-4"
|
|
>
|
|
<AgentBlockedPorts agentId={agentId} />
|
|
<AgentBlockedIps
|
|
agentId={agentId}
|
|
platform={a.platform}
|
|
/>
|
|
</TabsContent>
|
|
</CountedLineTabs>
|
|
</div>
|
|
) : (
|
|
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
|
)}
|
|
</div>
|
|
</DetailPanel.Section>
|
|
</DetailPanel>
|
|
</div>
|
|
|
|
<AgentOverrideSheet
|
|
agentId={agentId}
|
|
open={overrideOpen}
|
|
onOpenChange={setOverrideOpen}
|
|
/>
|
|
<AgentCloneSetsSheet
|
|
agentId={agentId}
|
|
open={cloneOpen}
|
|
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}
|
|
/>
|
|
</>
|
|
)
|
|
}
|