Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa475848fd | ||
|
|
5d767e4341 |
No files matched your search
@@ -0,0 +1,300 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { XIcon } from 'lucide-react'
|
|
||||||
import { AgentDetailView } from '@/components/agents/agent-detail-view'
|
|
||||||
import { agentQueryOptions } from '@/queries'
|
|
||||||
import { Button } from '@evofw/ui/components/button'
|
|
||||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetClose,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@evofw/ui/components/sheet'
|
|
||||||
import { cn } from '@evofw/ui/lib/utils'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wide agent detail Sheet — inventory-9 / CRM-4 shell + SA3 body.
|
|
||||||
* Preview: https://reui.io/preview/base/solution-inventory-9
|
|
||||||
* · https://reui.io/preview/base/solution-crm-4
|
|
||||||
* · https://reui.io/preview/base/solution-agents-3
|
|
||||||
* · https://reui.io/preview/base/components/c-sheet-1
|
|
||||||
*/
|
|
||||||
|
|
||||||
type AgentDetailSheetProps = {
|
|
||||||
agentId: string | null
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onDelete?: (id: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentDetailSheet({
|
|
||||||
agentId,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onDelete,
|
|
||||||
}: AgentDetailSheetProps) {
|
|
||||||
const agentQ = useQuery({
|
|
||||||
...agentQueryOptions(agentId ?? ''),
|
|
||||||
enabled: open && Boolean(agentId),
|
|
||||||
})
|
|
||||||
|
|
||||||
const title = agentQ.data?.name ?? 'Агент'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent
|
|
||||||
side="right"
|
|
||||||
showCloseButton={false}
|
|
||||||
className={cn(
|
|
||||||
// Override default data-[side=right]:sm:max-w-sm from @evofw/ui sheet
|
|
||||||
'sm:max-w-none!',
|
|
||||||
'inset-y-2 right-2 left-auto flex h-[calc(100svh-1rem)] max-w-none',
|
|
||||||
'w-[calc(100vw-1rem)] md:w-[calc(100vw-var(--sidebar-width,240px)-1rem)]',
|
|
||||||
'flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SheetHeader className="shrink-0 gap-0 border-b p-0">
|
|
||||||
<div className="flex min-h-11 items-center justify-between gap-2 px-4">
|
|
||||||
<SheetTitle className="truncate text-sm font-medium leading-5">
|
|
||||||
{title}
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetClose
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="Закрыть"
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<XIcon aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<SheetDescription className="sr-only">
|
|
||||||
Политика, apply counters и identity агента
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
{agentId && open ? (
|
|
||||||
<AgentDetailView agentId={agentId} onDelete={onDelete} />
|
|
||||||
) : null}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter className="bg-muted/40 shrink-0 border-t px-4 py-3">
|
|
||||||
<SheetClose
|
|
||||||
render={
|
|
||||||
<Button type="button" variant="outline" className="w-full">
|
|
||||||
Закрыть
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
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-recent'] })
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
Утвердить
|
|
||||||
</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" />
|
|
||||||
Установка
|
|
||||||
</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" />
|
|
||||||
Команда установки
|
|
||||||
</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>Ошибка применения политики</AlertTitle>
|
|
||||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<DetailPanel.Metrics
|
|
||||||
cards={[
|
|
||||||
{
|
|
||||||
id: 'traffic',
|
|
||||||
icon: <ActivityIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-warning',
|
|
||||||
label: 'Трафик',
|
|
||||||
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: 'Ядро',
|
|
||||||
description: a.last_apply_kernel_method ?? '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'apply',
|
|
||||||
icon: <ClockIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-primary',
|
|
||||||
label: 'Последнее применение',
|
|
||||||
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: 'Хост-фаервол' },
|
|
||||||
{ id: 'acl', label: 'Правила портов' },
|
|
||||||
{ id: 'hits', label: 'Заблокированное' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import {
|
import {
|
||||||
KpiStatGrid,
|
KpiStatGrid,
|
||||||
type KpiStatCard,
|
type KpiStatCard,
|
||||||
@@ -33,7 +26,7 @@ interface DetailPanelProps {
|
|||||||
|
|
||||||
function DetailPanelRoot({ children, className }: DetailPanelProps) {
|
function DetailPanelRoot({ children, className }: DetailPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>
|
<div className={cn('flex flex-col gap-5', className)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -53,24 +46,16 @@ function DetailPanelHeader({
|
|||||||
children,
|
children,
|
||||||
}: DetailPanelHeaderProps) {
|
}: DetailPanelHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<>
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
<header className="flex min-w-0 flex-wrap items-end justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<FrameTitle>{title}</FrameTitle>
|
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
||||||
{description ? (
|
{description ? <p className="text-muted-foreground text-sm text-pretty">{description}</p> : null}
|
||||||
<FrameDescription>{description}</FrameDescription>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{actions ? (
|
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
</header>
|
||||||
{actions}
|
{children}
|
||||||
</div>
|
</>
|
||||||
) : null}
|
|
||||||
</FrameHeader>
|
|
||||||
{children ? (
|
|
||||||
<FramePanel className="flex flex-col gap-4">{children}</FramePanel>
|
|
||||||
) : null}
|
|
||||||
</Frame>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { AgentDetailPage } from '@/components/agents/agent-detail-page'
|
||||||
/**
|
|
||||||
* Legacy deep-link → list + Sheet (`?agent=`).
|
|
||||||
* Full UI: AgentDetailSheet on /agents (SA3 / inventory-9).
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
/** Страница агента по доске FwAgentDetail. */
|
||||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||||
beforeLoad: ({ params }) => {
|
component: AgentRoute,
|
||||||
throw redirect({
|
|
||||||
to: '/agents',
|
|
||||||
search: { agent: params.id, view: 'cards' },
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function AgentRoute() {
|
||||||
|
const { id } = Route.useParams()
|
||||||
|
return <AgentDetailPage agentId={id} />
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import type { RowSelectionState } from '@tanstack/react-table'
|
import type { RowSelectionState } from '@tanstack/react-table'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||||
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
|
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 { AgentFleetDataGrid } from '@/components/agents/agent-fleet-data-grid'
|
||||||
import { AgentsFleetChrome } from '@/components/agents/agents-fleet-chrome'
|
import { AgentsFleetChrome } from '@/components/agents/agents-fleet-chrome'
|
||||||
import {
|
import {
|
||||||
@@ -141,6 +140,12 @@ function AgentsPage() {
|
|||||||
const filters = Route.useSearch().f ?? EMPTY_FILTERS
|
const filters = Route.useSearch().f ?? EMPTY_FILTERS
|
||||||
|
|
||||||
const detailOpen = Boolean(detailAgentId)
|
const detailOpen = Boolean(detailAgentId)
|
||||||
|
// Карточка агента — отдельная страница (доска FwAgentDetail); ссылки ?agent= ведут на неё.
|
||||||
|
useEffect(() => {
|
||||||
|
if (detailAgentId) {
|
||||||
|
void navigate({ to: '/agents/$id', params: { id: detailAgentId }, replace: true })
|
||||||
|
}
|
||||||
|
}, [detailAgentId, navigate])
|
||||||
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
|
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
|
||||||
|
|
||||||
const setSearch = useCallback(
|
const setSearch = useCallback(
|
||||||
@@ -781,15 +786,6 @@ function AgentsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AgentDetailSheet
|
|
||||||
agentId={detailAgentId ?? null}
|
|
||||||
open={detailOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setSearch({ agent: '' })
|
|
||||||
}}
|
|
||||||
onDelete={handleDeleteAgent}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteAgentId !== null}
|
open={deleteAgentId !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { toast } from 'sonner'
|
|||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
CircleAlertIcon,
|
CircleAlertIcon,
|
||||||
|
ClockIcon,
|
||||||
HashIcon,
|
HashIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { formatDateTime, formatRelativeTime } from '@/lib/format'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
@@ -471,6 +473,14 @@ function ListDetailPage() {
|
|||||||
),
|
),
|
||||||
hint: 'materialized',
|
hint: 'materialized',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'updated',
|
||||||
|
icon: <ClockIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-muted-foreground',
|
||||||
|
label: 'Обновлён',
|
||||||
|
description: formatRelativeTime(detail.refreshed_at ?? detail.updated_at),
|
||||||
|
hint: formatDateTime(detail.refreshed_at ?? detail.updated_at),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user