Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa475848fd | ||
|
|
5d767e4341 | ||
|
|
f4259246d0 | ||
|
|
269f94edcf |
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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export function PortalShell({ children }: { children: ReactNode }) {
|
||||
<PortalSidebar />
|
||||
<SidebarInset id="main-content" className="min-w-0">
|
||||
<PortalHeader />
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1480px] flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
|
||||
<div className="flex w-full min-w-0 flex-1 flex-col gap-5 p-4 md:p-6">{children}</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -6,17 +6,15 @@ interface PageHeaderProps {
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/** Page-level section header — etalon EvoBGP / Domains list. */
|
||||
/** Заголовок страницы как на досках макета: название 20px, описание 14px muted, действия справа. */
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
<header className="flex min-w-0 flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
||||
{description ? <p className="text-muted-foreground text-sm text-pretty">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -432,7 +432,7 @@ export function PageTabsList({ children }: { children: ReactNode }) {
|
||||
|
||||
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
|
||||
return (
|
||||
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm">
|
||||
<TabsTrigger value={value} className="flex-none gap-1.5 px-4 text-sm">
|
||||
{label}
|
||||
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
|
||||
type GridProps = ComponentProps<typeof DataGrid>
|
||||
|
||||
export const DATA_GRID_CELL_PAD = 'py-3'
|
||||
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
|
||||
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
|
||||
|
||||
export const DATA_GRID_LAYOUT: NonNullable<GridProps['tableLayout']> = {
|
||||
rowBorder: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
columnsResizable: false,
|
||||
}
|
||||
|
||||
export const DATA_GRID_CLASS_NAMES: NonNullable<GridProps['tableClassNames']> = {
|
||||
headerRow: 'border-b border-border',
|
||||
bodyRow: 'group/row',
|
||||
}
|
||||
|
||||
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
|
||||
|
||||
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
|
||||
export function cellPad(index: number, count: number) {
|
||||
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
|
||||
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
|
||||
return DATA_GRID_CELL_PAD
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
KpiStatGrid,
|
||||
type KpiStatCard,
|
||||
@@ -33,7 +26,7 @@ interface DetailPanelProps {
|
||||
|
||||
function DetailPanelRoot({ children, className }: DetailPanelProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>
|
||||
<div className={cn('flex flex-col gap-5', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -53,24 +46,16 @@ function DetailPanelHeader({
|
||||
children,
|
||||
}: DetailPanelHeaderProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
{description ? (
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
) : null}
|
||||
<>
|
||||
<header className="flex min-w-0 flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
||||
{description ? <p className="text-muted-foreground text-sm text-pretty">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
{children ? (
|
||||
<FramePanel className="flex flex-col gap-4">{children}</FramePanel>
|
||||
) : null}
|
||||
</Frame>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
|
||||
|
||||
@@ -22,7 +15,7 @@ interface OpsDashboardProps {
|
||||
|
||||
/** Denser stack for KPI / charts / queue. PageHeader lives outside via PageShell. */
|
||||
const rootClassName =
|
||||
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
||||
'text-foreground @container flex w-full flex-col gap-5'
|
||||
|
||||
function OpsDashboardSkeleton() {
|
||||
return (
|
||||
@@ -58,22 +51,12 @@ export function OpsDashboard({
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
className="flex min-w-0 flex-col gap-5"
|
||||
>
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label="Требуют внимания">
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Требуют внимания</FrameTitle>
|
||||
<FrameDescription>
|
||||
Pending-агенты, ошибки apply и списки с last_error
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>{queue}</FramePanel>
|
||||
</Frame>
|
||||
</section>
|
||||
<section aria-label="Требуют внимания">{queue}</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { DATA_GRID_CLASS_NAMES, DATA_GRID_LAYOUT } from '@/components/portal/data-page-layout'
|
||||
|
||||
/**
|
||||
* Блок-таблица доски: Frame с заголовком, описанием и действиями, ниже таблица в раскладке MikrotikManager.
|
||||
* Без поиска и пагинации — для коротких срезов («Первые 8», «Требует внимания»).
|
||||
*/
|
||||
export function SimpleGrid<T extends object>({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
columns,
|
||||
data,
|
||||
getRowId,
|
||||
onRowClick,
|
||||
emptyMessage = 'Нет записей.',
|
||||
footer,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
columns: ColumnDef<T, unknown>[]
|
||||
data: T[]
|
||||
getRowId: (row: T) => string
|
||||
onRowClick?: (row: T) => void
|
||||
emptyMessage?: string
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const state = useMemo(() => ({ sorting }), [sorting])
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getRowId,
|
||||
state,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
emptyMessage={emptyMessage}
|
||||
onRowClick={onRowClick}
|
||||
tableLayout={{ ...DATA_GRID_LAYOUT, dense: false, headerSticky: false, width: 'fixed' }}
|
||||
tableClassNames={DATA_GRID_CLASS_NAMES}
|
||||
>
|
||||
<Frame dense spacing="sm" className="w-full min-w-0">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle className="text-balance">{title}</FrameTitle>
|
||||
{description ? <FrameDescription className="text-xs text-pretty">{description}</FrameDescription> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
|
||||
</FrameHeader>
|
||||
<FramePanel className="overflow-hidden p-0 shadow-none!">
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
{footer ? <div className="text-muted-foreground border-t px-5 py-3 text-xs">{footer}</div> : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
/**
|
||||
* Legacy deep-link → list + Sheet (`?agent=`).
|
||||
* Full UI: AgentDetailSheet on /agents (SA3 / inventory-9).
|
||||
*/
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AgentDetailPage } from '@/components/agents/agent-detail-page'
|
||||
|
||||
/** Страница агента по доске FwAgentDetail. */
|
||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/agents',
|
||||
search: { agent: params.id, view: 'cards' },
|
||||
})
|
||||
},
|
||||
component: AgentRoute,
|
||||
})
|
||||
|
||||
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 type { RowSelectionState } from '@tanstack/react-table'
|
||||
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 {
|
||||
Check,
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
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 {
|
||||
@@ -141,6 +140,12 @@ function AgentsPage() {
|
||||
const filters = Route.useSearch().f ?? EMPTY_FILTERS
|
||||
|
||||
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 setSearch = useCallback(
|
||||
@@ -781,15 +786,6 @@ function AgentsPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AgentDetailSheet
|
||||
agentId={detailAgentId ?? null}
|
||||
open={detailOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSearch({ agent: '' })
|
||||
}}
|
||||
onDelete={handleDeleteAgent}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteAgentId !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
+126
-146
@@ -1,41 +1,27 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { BanIcon, CheckCircle2Icon, ListIcon, ServerIcon } from 'lucide-react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
ServerIcon,
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
ListIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
PageHeader,
|
||||
PageShell,
|
||||
OpsDashboard,
|
||||
PageShell,
|
||||
QuickActionGrid,
|
||||
type KpiStatCard,
|
||||
type QuickActionItem,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemTitle,
|
||||
} from '@evofw/ui/components/item'
|
||||
import {
|
||||
dashboardQueryOptions,
|
||||
agentsQueryOptions,
|
||||
settingsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { SimpleGrid } from '@/components/reui-kit/simple-grid'
|
||||
import { PageHead } from '@/components/portal/bits'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { dashboardQueryOptions, agentsQueryOptions, settingsQueryOptions } from '@/queries'
|
||||
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
|
||||
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
|
||||
import { platformLabel } from '@/components/agents/agent-platform-icon'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import { formatNumber, formatRelativeTime } from '@/lib/format'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
export const Route = createFileRoute('/_auth/')({
|
||||
@@ -44,7 +30,77 @@ export const Route = createFileRoute('/_auth/')({
|
||||
|
||||
const EMPTY_AGENTS: Agent[] = []
|
||||
|
||||
type Attention = { id: string; agent: Agent; kind: 'pending' | 'error'; message: string; when: string | null | undefined }
|
||||
|
||||
const agentColumns: ColumnDef<Agent>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (a) => a.name,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Агент" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.name} subtitle={platformLabel(row.original.platform)} />
|
||||
),
|
||||
meta: { headerTitle: 'Агент' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorFn: (a) => a.status,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'dropped',
|
||||
accessorFn: (a) => agentTrafficDropped(a),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Заблокировано" />,
|
||||
cell: ({ row }) =>
|
||||
row.original.status === 'approved' ? (
|
||||
<span className="tabular-nums">↓{formatNumber(agentTrafficDropped(row.original))}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
meta: { headerTitle: 'Заблокировано' },
|
||||
},
|
||||
]
|
||||
|
||||
const attentionColumns: ColumnDef<Attention>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (r) => r.agent.name,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Объект" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.agent.name} subtitle="Агент" />,
|
||||
meta: { headerTitle: 'Объект' },
|
||||
},
|
||||
{
|
||||
id: 'message',
|
||||
accessorFn: (r) => r.message,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Сообщение" />,
|
||||
cell: ({ row }) => <span className="text-sm">{row.original.message}</span>,
|
||||
meta: { headerTitle: 'Сообщение' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorFn: (r) => r.kind,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) =>
|
||||
row.original.kind === 'error' ? (
|
||||
<StatusBadge status="error" label="Ошибка" />
|
||||
) : (
|
||||
<StatusBadge status="pending" label="Ожидает" />
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'when',
|
||||
accessorFn: (r) => r.when ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Когда" />,
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{formatRelativeTime(row.original.when)}</span>,
|
||||
meta: { headerTitle: 'Когда' },
|
||||
},
|
||||
]
|
||||
|
||||
function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const dash = useQuery(dashboardQueryOptions())
|
||||
const agents = useQuery(agentsQueryOptions())
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
@@ -53,15 +109,18 @@ function DashboardPage() {
|
||||
const items = agents.data?.items ?? EMPTY_AGENTS
|
||||
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
|
||||
|
||||
const pending = useMemo(
|
||||
() => items.filter((a) => a.status === 'pending'),
|
||||
[items],
|
||||
)
|
||||
const applyErrors = useMemo(
|
||||
() => items.filter((a) => a.last_apply_error),
|
||||
[items],
|
||||
)
|
||||
const previewAgents = useMemo(() => items.slice(0, 8), [items])
|
||||
const attention = useMemo<Attention[]>(
|
||||
() => [
|
||||
...items
|
||||
.filter((a) => a.last_apply_error)
|
||||
.map<Attention>((a) => ({ id: `err-${a.id}`, agent: a, kind: 'error', message: a.last_apply_error ?? '', when: a.last_apply_at })),
|
||||
...items
|
||||
.filter((a) => a.status === 'pending')
|
||||
.map<Attention>((a) => ({ id: `pend-${a.id}`, agent: a, kind: 'pending', message: 'Ожидает одобрения', when: a.created_at })),
|
||||
],
|
||||
[items],
|
||||
)
|
||||
|
||||
const kpiCards: KpiStatCard[] = useMemo(
|
||||
() => [
|
||||
@@ -81,8 +140,8 @@ function DashboardPage() {
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
variant: 'warning',
|
||||
iconClassName: 'text-destructive',
|
||||
variant: 'destructive',
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
@@ -97,6 +156,7 @@ function DashboardPage() {
|
||||
id: 'lists',
|
||||
label: 'Списки IP',
|
||||
value: d?.lists_total ?? '—',
|
||||
hint: 'ручные, JSON и EvoBGP',
|
||||
to: '/lists',
|
||||
icon: <ListIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
@@ -107,133 +167,53 @@ function DashboardPage() {
|
||||
|
||||
const quickActions: QuickActionItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Агенты',
|
||||
description: 'Подключение и одобрение агентов',
|
||||
to: '/agents',
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
id: 'lists',
|
||||
title: 'Списки',
|
||||
description: 'Блок-листы и источники',
|
||||
to: '/lists',
|
||||
icon: <ListIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
{
|
||||
id: 'rules',
|
||||
title: 'Правила',
|
||||
description: 'Политика блокировки',
|
||||
to: '/rules',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
},
|
||||
{ id: 'agents', title: 'Агенты', description: 'Подключение и одобрение агентов', to: '/agents', icon: <ServerIcon aria-hidden />, iconClassName: 'text-info' },
|
||||
{ id: 'lists', title: 'Списки', description: 'Блок-листы и источники', to: '/lists', icon: <ListIcon aria-hidden />, iconClassName: 'text-primary' },
|
||||
{ id: 'rules', title: 'Правила', description: 'Политика блокировки', to: '/rules', icon: <BanIcon aria-hidden />, iconClassName: 'text-warning' },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
<PageHead
|
||||
title="Панель управления"
|
||||
description="Обзор агентов и пакетной статистики"
|
||||
sub="Обзор агентов и пакетной статистики"
|
||||
actions={<UpdatedAtLabel updatedAt={dash.dataUpdatedAt} />}
|
||||
/>
|
||||
<OpsDashboard
|
||||
isLoading={dash.isLoading}
|
||||
kpiCards={kpiCards}
|
||||
afterKpi={
|
||||
showQuickActions ? (
|
||||
<QuickActionGrid actions={quickActions} />
|
||||
) : undefined
|
||||
}
|
||||
afterKpi={showQuickActions ? <QuickActionGrid actions={quickActions} /> : undefined}
|
||||
charts={
|
||||
<>
|
||||
<AgentsFleetChart />
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Агенты</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{previewAgents.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет агентов</p>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{previewAgents.map((a) => (
|
||||
<Item key={a.id} variant="outline" size="sm">
|
||||
<ItemContent className="flex flex-row items-center justify-between gap-2">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
<Link
|
||||
to="/agents"
|
||||
search={{ agent: a.id, view: 'cards' }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{a.name}
|
||||
</Link>
|
||||
</ItemTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
a.status === 'approved'
|
||||
? 'success-light'
|
||||
: a.status === 'pending'
|
||||
? 'warning-light'
|
||||
: 'secondary'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{a.status}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
↓{agentTrafficDropped(a)}
|
||||
</span>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<SimpleGrid
|
||||
title="Агенты"
|
||||
description="Первые 8 · клик открывает карточку агента"
|
||||
columns={agentColumns}
|
||||
data={previewAgents}
|
||||
getRowId={(a) => a.id}
|
||||
onRowClick={(a) => void navigate({ to: '/agents', search: { agent: a.id, view: 'cards' } })}
|
||||
emptyMessage="Нет агентов"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
queue={
|
||||
<div className="flex flex-col gap-2">
|
||||
{pending.length === 0 && applyErrors.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет проблем, требующих внимания
|
||||
</p>
|
||||
) : null}
|
||||
{pending.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span>
|
||||
Pending: <strong>{a.name}</strong>
|
||||
</span>
|
||||
<Link
|
||||
to="/agents"
|
||||
search={{ agent: a.id, view: 'cards' }}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
Открыть
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
{applyErrors.map((a) => (
|
||||
<div key={`err-${a.id}`} className="text-sm">
|
||||
<span className="text-destructive font-medium">{a.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
— {a.last_apply_error}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SimpleGrid
|
||||
title="Требует внимания"
|
||||
description="Ожидают одобрения и ошибки применения политики"
|
||||
actions={
|
||||
<Button variant="outline" render={<Link to="/agents" search={{ view: "cards" }} />} nativeButton={false}>
|
||||
Открыть агентов
|
||||
</Button>
|
||||
}
|
||||
columns={attentionColumns}
|
||||
data={attention}
|
||||
getRowId={(r) => r.id}
|
||||
onRowClick={(r) => void navigate({ to: '/agents', search: { agent: r.agent.id, view: 'cards' } })}
|
||||
emptyMessage="Нет проблем, требующих внимания"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageShell>
|
||||
|
||||
@@ -4,11 +4,13 @@ import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CircleAlertIcon,
|
||||
ClockIcon,
|
||||
HashIcon,
|
||||
RefreshCwIcon,
|
||||
TagIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/format'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
@@ -471,6 +473,14 @@ function ListDetailPage() {
|
||||
),
|
||||
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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { PageTab, PageTabsList, SignalAlert } from '@/components/portal/bits'
|
||||
import { Tabs, TabsContent } from '@evofw/ui/components/tabs'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
|
||||
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||
@@ -133,14 +135,16 @@ function StatsPage() {
|
||||
{
|
||||
id: 'd',
|
||||
label: 'Заблокировано (сумма)',
|
||||
hint: 'пакетов · сумма пиков',
|
||||
value: dash.data?.packets_dropped ?? 0,
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
variant: 'warning',
|
||||
iconClassName: 'text-destructive',
|
||||
variant: 'destructive',
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
label: 'Пропущено (сумма)',
|
||||
hint: 'пакетов · сумма пиков',
|
||||
value: dash.data?.packets_accepted ?? 0,
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
@@ -148,6 +152,7 @@ function StatsPage() {
|
||||
{
|
||||
id: 'o',
|
||||
label: 'Агентов онлайн',
|
||||
hint: `из ${dash.data?.agents_approved ?? 0} одобренных`,
|
||||
value: dash.data?.agents_online ?? 0,
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -225,65 +230,77 @@ function StatsPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Статистика"
|
||||
description="История apply-report counters"
|
||||
description="История apply-report counters · заблокированный и пропущенный трафик агентов"
|
||||
actions={<UpdatedAtLabel updatedAt={stats.dataUpdatedAt} />}
|
||||
/>
|
||||
<SignalAlert tone="in" title="Как считаются значения.">
|
||||
Счётчики агентов смешанной семантики (накопительные и сбросные): суммирование сэмплов дало бы двойной счёт,
|
||||
поэтому за час берём максимум наблюдавшегося уровня.
|
||||
</SignalAlert>
|
||||
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Активность по часам (пик за час)</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{series.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет данных — дождитесь apply-report от агентов.
|
||||
</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
|
||||
<AreaChart data={series}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="t" tickLine={false} axisLine={false} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
dataKey="dropped"
|
||||
type="monotone"
|
||||
fill="var(--color-dropped)"
|
||||
stroke="var(--color-dropped)"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
<Area
|
||||
dataKey="accepted"
|
||||
type="monotone"
|
||||
fill="var(--color-accepted)"
|
||||
stroke="var(--color-accepted)"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<ResourcePage
|
||||
title="Samples"
|
||||
hideHeader
|
||||
data={rows}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setStatsFilters}
|
||||
onClearFilters={() => setStatsFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
isLoading={stats.isLoading}
|
||||
pageSize={20}
|
||||
emptyState={{
|
||||
title: 'Нет данных',
|
||||
description: 'Агенты ещё не отправили apply-report.',
|
||||
}}
|
||||
/>
|
||||
<Tabs defaultValue="activity" className="min-w-0 gap-4">
|
||||
<PageTabsList>
|
||||
<PageTab value="activity" label="Активность" />
|
||||
<PageTab value="samples" label="Samples" count={rows.length} />
|
||||
</PageTabsList>
|
||||
<TabsContent value="activity" className="min-w-0">
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Активность по часам (пик за час)</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{series.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет данных — дождитесь apply-report от агентов.
|
||||
</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
|
||||
<AreaChart data={series}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="t" tickLine={false} axisLine={false} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
dataKey="dropped"
|
||||
type="monotone"
|
||||
fill="var(--color-dropped)"
|
||||
stroke="var(--color-dropped)"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
<Area
|
||||
dataKey="accepted"
|
||||
type="monotone"
|
||||
fill="var(--color-accepted)"
|
||||
stroke="var(--color-accepted)"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TabsContent>
|
||||
<TabsContent value="samples" className="min-w-0">
|
||||
<ResourcePage
|
||||
title="Samples"
|
||||
hideHeader
|
||||
data={rows}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setStatsFilters}
|
||||
onClearFilters={() => setStatsFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
isLoading={stats.isLoading}
|
||||
pageSize={20}
|
||||
emptyState={{
|
||||
title: 'Нет данных',
|
||||
description: 'Агенты ещё не отправили apply-report.',
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -8,37 +8,37 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--background: oklch(0.973 0.004 247.858);
|
||||
--foreground: oklch(0.141 0.006 264);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.141 0.006 264);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.141 0.006 264);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary: oklch(0.958 0.005 247.858);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--muted: oklch(0.958 0.005 247.858);
|
||||
--muted-foreground: oklch(0.52 0.01 264);
|
||||
--accent: oklch(0.942 0.016 264.376);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--border: oklch(0.904 0.006 264);
|
||||
--input: oklch(0.904 0.006 264);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.546 0.215 264.376);
|
||||
--chart-2: oklch(0.527 0.154 150.069);
|
||||
--chart-3: oklch(0.625 0.162 41.5);
|
||||
--chart-4: oklch(0.591 0.224 296.4);
|
||||
--chart-5: oklch(0.577 0.245 27.325);
|
||||
--sidebar: oklch(0.152 0.01 264.376);
|
||||
--sidebar-foreground: oklch(0.92 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-accent: oklch(0.24 0.012 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.95 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
--destructive-foreground: var(--color-red-800);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-900);
|
||||
@@ -49,41 +49,43 @@
|
||||
--invert: var(--color-zinc-900);
|
||||
--invert-foreground: var(--color-zinc-50);
|
||||
--focus: var(--color-blue-500);
|
||||
--chart-rx: oklch(0.527 0.154 150.069);
|
||||
--chart-tx: oklch(0.546 0.215 264.376);
|
||||
--focus-foreground: var(--color-blue-900);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--background: oklch(0.133 0.011 264.376);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card: oklch(0.191 0.011 264.376);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover: oklch(0.191 0.011 264.376);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary: oklch(0.252 0.01 264.376);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--muted: oklch(0.252 0.01 264.376);
|
||||
--muted-foreground: oklch(0.648 0.008 264.376);
|
||||
--accent: oklch(0.285 0.022 264.376);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.637 0.222 264.376);
|
||||
--chart-2: oklch(0.696 0.17 151.328);
|
||||
--chart-3: oklch(0.713 0.176 47.6);
|
||||
--chart-4: oklch(0.714 0.173 303.9);
|
||||
--chart-5: oklch(0.704 0.191 22.216);
|
||||
--sidebar: oklch(0.191 0.011 264.376);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent: oklch(0.262 0.014 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
--destructive-foreground: var(--color-red-600);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-600);
|
||||
|
||||
Reference in new issue
Block a user