feat(web): live-данные — polling, онлайн-индикаторы, относительное время, actionable-очередь ошибок apply, часовая агрегация статистики

This commit is contained in:
Denozordec
2026-09-25 01:17:33 +07:00
parent 98e4dcdefb
commit 0b426fa7ae
11 changed files with 272 additions and 69 deletions
@@ -21,10 +21,8 @@ import {
} from '@/components/reui/alert' } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { import { AgentPlatformIcon, platformLabel } from '@/components/agents/agent-platform-icon'
AgentPlatformIcon, import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
platformLabel,
} from '@/components/agents/agent-platform-icon'
import { import {
agentTrafficAccepted, agentTrafficAccepted,
agentTrafficDropped, agentTrafficDropped,
@@ -48,6 +46,7 @@ import {
agentQueryOptions, agentQueryOptions,
} from '@/queries' } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { formatDateTime, formatRelativeTime } from '@/lib/format'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton' import { Skeleton } from '@evofw/ui/components/skeleton'
@@ -138,8 +137,19 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
<Alert variant="destructive"> <Alert variant="destructive">
<CircleAlertIcon /> <CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle> <AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription> <AlertDescription className="flex flex-col gap-2">
<span>
{agentQ.error?.message ?? 'Не удалось загрузить агента'} {agentQ.error?.message ?? 'Не удалось загрузить агента'}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => void agentQ.refetch()}
>
Повторить
</Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>
@@ -149,8 +159,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
const headerDesc = [ const headerDesc = [
a.hostname, a.hostname,
platformLabel(a.platform), platformLabel(a.platform),
`gen ${a.policy_generation}`, `поколение ${a.policy_generation}`,
a.default_action === 'drop' ? 'default Drop' : 'default Accept', a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
] ]
.filter(Boolean) .filter(Boolean)
.join(' · ') .join(' · ')
@@ -165,7 +175,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
actions={ actions={
<> <>
<AgentPlatformIcon platform={a.platform} /> <AgentPlatformIcon platform={a.platform} />
<span className="flex items-center gap-1.5">
<AgentOnlineDot agent={a} />
<StatusBadge status={a.status} /> <StatusBadge status={a.status} />
</span>
<Badge <Badge
variant={ variant={
a.default_action === 'drop' a.default_action === 'drop'
@@ -304,7 +317,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
icon: <ClockIcon aria-hidden />, icon: <ClockIcon aria-hidden />,
iconClassName: 'text-primary', iconClassName: 'text-primary',
label: 'Last apply', label: 'Last apply',
description: a.last_apply_at ?? '—', description: formatDateTime(a.last_apply_at),
hint: a.last_apply_at
? formatRelativeTime(a.last_apply_at)
: undefined,
}, },
]} ]}
/> />
@@ -20,13 +20,15 @@ import {
agentTrafficAccepted, agentTrafficAccepted,
agentTrafficDropped, agentTrafficDropped,
} from '@/components/agents/agent-traffic' } from '@/components/agents/agent-traffic'
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
import { formatRelativeTime } from '@/lib/format'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@evofw/ui/components/tooltip' } from '@evofw/ui/components/tooltip'
import { formatPackets, formatShortDateTime } from '@/lib/format' import { formatPackets } from '@/lib/format'
/** /**
* Fleet triage DataGrid — firewall ops density. * Fleet triage DataGrid — firewall ops density.
@@ -237,25 +239,27 @@ export function AgentFleetDataGrid({
}, },
{ {
accessorKey: 'last_seen_at', accessorKey: 'last_seen_at',
size: 140, size: 150,
minSize: 120, minSize: 130,
maxSize: 180, maxSize: 190,
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Seen" /> <DataGridColumnHeader column={column} title="Активность" />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const a = row.original const a = row.original
const short = formatShortDateTime(a.last_seen_at) if (!a.last_seen_at) {
if (!a.last_seen_at || short === '—') {
return <DataGridMutedCell>—</DataGridMutedCell> return <DataGridMutedCell>—</DataGridMutedCell>
} }
return ( return (
<div className="flex min-w-0 items-center gap-2">
<AgentOnlineDot agent={a} />
<DataGridPrimaryCell <DataGridPrimaryCell
accent="default" accent="default"
title={short} title={formatRelativeTime(a.last_seen_at)}
subtitle={a.last_seen_ip ?? undefined} subtitle={a.last_seen_ip ?? undefined}
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal [&>span:first-child]:tabular-nums" className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal"
/> />
</div>
) )
}, },
}, },
@@ -0,0 +1,27 @@
import type { Agent } from '@evofw/shared'
import { isAgentOnline } from '@/lib/agent-online'
import { cn } from '@evofw/ui/lib/utils'
/** Индикатор «на связи» (last_seen < 5 мин) — тот же порог, что в API. */
export function AgentOnlineDot({
agent,
className,
}: {
agent: Pick<Agent, 'last_seen_at'> | null | undefined
className?: string
}) {
const online = isAgentOnline(agent)
const label = online ? 'На связи' : 'Не на связи'
return (
<span
role="img"
aria-label={label}
title={label}
className={cn(
'inline-block size-2 shrink-0 rounded-full',
online ? 'bg-success' : 'bg-muted-foreground/30',
className,
)}
/>
)
}
@@ -78,6 +78,8 @@ export interface ResourcePageProps<T extends object> {
onRetry?: () => void onRetry?: () => void
primaryAction?: ReactNode primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode } emptyState?: { title: string; description?: string; action?: ReactNode }
/** Текст для пустого результата после фильтров/поиска (по вкладкам). */
filteredEmptyMessage?: string
pageSize?: number pageSize?: number
enableRowSelection?: boolean enableRowSelection?: boolean
/** Controlled selection — page owns state, so bulk-действия видят выбор. */ /** Controlled selection — page owns state, so bulk-действия видят выбор. */
@@ -142,6 +144,7 @@ export function ResourcePage<T extends object>({
onRetry, onRetry,
primaryAction, primaryAction,
emptyState, emptyState,
filteredEmptyMessage,
pageSize = 10, pageSize = 10,
enableRowSelection = false, enableRowSelection = false,
rowSelection: rowSelectionProp, rowSelection: rowSelectionProp,
@@ -385,7 +388,8 @@ export function ResourcePage<T extends object>({
) )
} }
const emptyMessage = 'Нет записей по выбранным фильтрам.' const emptyMessage =
filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.'
return ( return (
<div className="w-full"> <div className="w-full">
@@ -0,0 +1,11 @@
import { formatTime } from '@/lib/format'
/** «Обновлено в HH:mm:ss» — подпись свежести данных для polling-страниц. */
export function UpdatedAtLabel({ updatedAt }: { updatedAt?: number }) {
if (!updatedAt) return null
return (
<span className="text-muted-foreground text-xs tabular-nums">
Обновлено в {formatTime(new Date(updatedAt))}
</span>
)
}
+16
View File
@@ -0,0 +1,16 @@
import type { Agent } from '@evofw/shared'
/**
* Порог «на связи» — тот же, что в API /dashboard: агент считается
* онлайн, если last_seen_at свежее 5 минут.
*/
export const AGENT_ONLINE_WINDOW_MS = 5 * 60_000
export function isAgentOnline(
agent: Pick<Agent, 'last_seen_at'> | null | undefined,
): boolean {
if (!agent?.last_seen_at) return false
const t = Date.parse(agent.last_seen_at)
if (Number.isNaN(t)) return false
return Date.now() - t < AGENT_ONLINE_WINDOW_MS
}
+19
View File
@@ -34,6 +34,8 @@ const timeFmt = new Intl.DateTimeFormat('ru-RU', {
second: '2-digit', second: '2-digit',
}) })
const relativeFmt = new Intl.RelativeTimeFormat('ru-RU', { numeric: 'auto' })
/** Compact packet counter: 1,2K / 3,4M */ /** Compact packet counter: 1,2K / 3,4M */
export function formatPackets(n: number | undefined | null): string { export function formatPackets(n: number | undefined | null): string {
if (n === undefined || n === null) return '—' if (n === undefined || n === null) return '—'
@@ -72,3 +74,20 @@ export function formatStampDateTime(iso: string | null | undefined): string {
export function formatTime(d: Date): string { export function formatTime(d: Date): string {
return timeFmt.format(d) return timeFmt.format(d)
} }
/** «2 минуты назад» / «5 часов назад»; старше 7 дней — полная дата. */
export function formatRelativeTime(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
const diffMs = t - Date.now()
const absMin = Math.round(Math.abs(diffMs) / 60_000)
if (absMin < 1) return 'только что'
const sign = diffMs < 0 ? -1 : 1
if (absMin < 60) return relativeFmt.format(sign * absMin, 'minute')
const absHours = Math.round(absMin / 60)
if (absHours < 24) return relativeFmt.format(sign * absHours, 'hour')
const absDays = Math.round(absHours / 24)
if (absDays < 7) return relativeFmt.format(sign * absDays, 'day')
return formatDateTime(iso)
}
+3
View File
@@ -12,12 +12,14 @@ export const dashboardQueryOptions = () =>
queryOptions({ queryOptions({
queryKey: ['dashboard'], queryKey: ['dashboard'],
queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'), queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'),
refetchInterval: 30_000,
}) })
export const agentsQueryOptions = () => export const agentsQueryOptions = () =>
queryOptions({ queryOptions({
queryKey: ['agents'], queryKey: ['agents'],
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'), queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
refetchInterval: 60_000,
}) })
export const agentQueryOptions = (id: string) => export const agentQueryOptions = (id: string) =>
@@ -272,4 +274,5 @@ export const recentStatsQueryOptions = () =>
recorded_at: string recorded_at: string
}[] }[]
}>('/api/v1/stats/recent'), }>('/api/v1/stats/recent'),
refetchInterval: 60_000,
}) })
+80 -22
View File
@@ -29,14 +29,10 @@ import {
Frame, Frame,
FrameDescription, FrameDescription,
FrameHeader, FrameHeader,
FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
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 { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
@@ -48,8 +44,11 @@ import {
} from '@/components/agents/agents-fleet-kpis' } from '@/components/agents/agents-fleet-kpis'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { UpdatedAtLabel } from '@/components/updated-at-label'
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
import { agentsQueryOptions, settingsQueryOptions } from '@/queries' import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { isAgentOnline } from '@/lib/agent-online'
import { useCan } from '@/lib/permissions' import { useCan } from '@/lib/permissions'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
@@ -332,7 +331,12 @@ function AgentsPage() {
if (card.id === 'pending') setActiveTab('pending') if (card.id === 'pending') setActiveTab('pending')
else if (card.id === 'invited') setActiveTab('invited') else if (card.id === 'invited') setActiveTab('invited')
else if (card.id === 'approved') setActiveTab('approved') else if (card.id === 'approved') setActiveTab('approved')
else if (card.id === 'stale') setActiveTab('approved') else if (card.id === 'stale') {
setActiveTab('all')
setFilters([
{ id: 'stale', field: 'online', operator: 'is', values: ['offline'] },
])
}
}, },
})), })),
[counts], [counts],
@@ -351,10 +355,10 @@ function AgentsPage() {
label: 'Статус', label: 'Статус',
type: 'select', type: 'select',
options: [ options: [
{ value: 'invited', label: 'invited' }, { value: 'invited', label: 'Приглашён' },
{ value: 'approved', label: 'approved' }, { value: 'pending', label: 'Ожидает одобрения' },
{ value: 'pending', label: 'pending' }, { value: 'approved', label: 'Одобрен' },
{ value: 'revoked', label: 'revoked' }, { value: 'revoked', label: 'Отозван' },
], ],
}, },
{ {
@@ -362,8 +366,17 @@ function AgentsPage() {
label: 'Платформа', label: 'Платформа',
type: 'select', type: 'select',
options: [ options: [
{ value: 'linux', label: 'linux' }, { value: 'linux', label: 'Linux' },
{ value: 'mikrotik', label: 'mikrotik' }, { value: 'mikrotik', label: 'MikroTik' },
],
},
{
key: 'online',
label: 'На связи',
type: 'select',
options: [
{ value: 'online', label: 'На связи' },
{ value: 'offline', label: 'Не на связи' },
], ],
}, },
], ],
@@ -374,6 +387,7 @@ function AgentsPage() {
if (field === 'name') return item.name if (field === 'name') return item.name
if (field === 'status') return item.status if (field === 'status') return item.status
if (field === 'platform') return item.platform if (field === 'platform') return item.platform
if (field === 'online') return isAgentOnline(item) ? 'online' : 'offline'
return undefined return undefined
}, []) }, [])
@@ -480,6 +494,26 @@ function AgentsPage() {
[counts.pending], [counts.pending],
) )
const applyErrorAgents = useMemo(
() => items.filter((a) => a.last_apply_error),
[items],
)
const filteredEmptyMessage = useMemo(() => {
switch (activeTab) {
case 'pending':
return 'Нет агентов на одобрении — всё обработано.'
case 'invited':
return 'Нет приглашённых агентов.'
case 'approved':
return 'Нет одобренных агентов.'
case 'revoked':
return 'Нет отозванных агентов.'
default:
return undefined
}
}, [activeTab])
const handleSelectAgent = useCallback( const handleSelectAgent = useCallback(
(id: string) => { (id: string) => {
setSearch({ agent: id }) setSearch({ agent: id })
@@ -545,7 +579,12 @@ function AgentsPage() {
<PageHeader <PageHeader
title="Агенты" title="Агенты"
description="Ops console: invite, install, approve" description="Ops console: invite, install, approve"
actions={addButton} actions={
<>
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
{addButton}
</>
}
/> />
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} /> <KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
@@ -586,15 +625,33 @@ function AgentsPage() {
</Frame> </Frame>
) : null} ) : null}
{counts.applyErrors > 0 ? ( {applyErrorAgents.length > 0 ? (
<Alert variant="warning"> <Frame dense spacing="sm">
<CircleAlertIcon /> <FrameHeader className="flex-row items-start justify-between gap-3">
<AlertTitle>Ошибки apply</AlertTitle> <div className="flex min-w-0 flex-col gap-px">
<AlertDescription> <FrameTitle>Ошибки применения политики</FrameTitle>
У {counts.applyErrors} агент(ов) есть last_apply_error — откройте <FrameDescription>
карточку для деталей. У {applyErrorAgents.length} агент(ов) последняя попытка apply
</AlertDescription> завершилась ошибкой — откройте карточку для деталей.
</Alert> </FrameDescription>
</div>
</FrameHeader>
<FramePanel className="flex flex-wrap gap-2">
{applyErrorAgents.map((a) => (
<Button
key={a.id}
size="sm"
variant="outline"
className="text-destructive"
title={a.last_apply_error ?? undefined}
onClick={() => setSearch({ agent: a.id })}
>
<CircleAlertIcon className="size-3.5" aria-hidden />
{a.name}
</Button>
))}
</FramePanel>
</Frame>
) : null} ) : null}
{view === 'table' ? ( {view === 'table' ? (
@@ -617,6 +674,7 @@ function AgentsPage() {
error={agentsQ.error} error={agentsQ.error}
onRetry={() => void agentsQ.refetch()} onRetry={() => void agentsQ.refetch()}
emptyAction={addButton} emptyAction={addButton}
filteredEmptyMessage={filteredEmptyMessage}
toolbarExtra={viewToggle} toolbarExtra={viewToggle}
enableRowSelection={canWrite} enableRowSelection={canWrite}
rowSelection={rowSelection} rowSelection={rowSelection}
+2
View File
@@ -35,6 +35,7 @@ import {
} from '@/queries' } from '@/queries'
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart' import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
import { agentTrafficDropped } from '@/components/agents/agent-traffic' import { agentTrafficDropped } from '@/components/agents/agent-traffic'
import { UpdatedAtLabel } from '@/components/updated-at-label'
import type { Agent } from '@evofw/shared' import type { Agent } from '@evofw/shared'
export const Route = createFileRoute('/_auth/')({ export const Route = createFileRoute('/_auth/')({
@@ -139,6 +140,7 @@ function DashboardPage() {
<PageHeader <PageHeader
title="Панель управления" title="Панель управления"
description="Обзор агентов и пакетной статистики" description="Обзор агентов и пакетной статистики"
actions={<UpdatedAtLabel updatedAt={dash.dataUpdatedAt} />}
/> />
<OpsDashboard <OpsDashboard
isLoading={dash.isLoading} isLoading={dash.isLoading}
+66 -23
View File
@@ -21,7 +21,10 @@ import {
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { UpdatedAtLabel } from '@/components/updated-at-label'
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries' import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
import { formatNumber, formatStampDateTime } from '@/lib/format'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
@@ -43,8 +46,8 @@ export const Route = createFileRoute('/_auth/stats')({
}) })
const chartConfig = { const chartConfig = {
dropped: { label: 'Dropped', color: 'var(--chart-1)' }, dropped: { label: 'Заблокировано', color: 'var(--chart-1)' },
accepted: { label: 'Accepted', color: 'var(--chart-2)' }, accepted: { label: 'Пропущено', color: 'var(--chart-2)' },
} satisfies ChartConfig } satisfies ChartConfig
function StatsPage() { function StatsPage() {
@@ -52,14 +55,38 @@ function StatsPage() {
const stats = useQuery(recentStatsQueryOptions()) const stats = useQuery(recentStatsQueryOptions())
const [filters, setFilters] = useState<Filter[]>([]) const [filters, setFilters] = useState<Filter[]>([])
const series = [...(stats.data?.items ?? [])] /**
.reverse() * Часовая агрегация: пик счётчиков за час. Счётчики агентов смешанной
.slice(-40) * семантики (absolute/presence), поэтому суммирование сэмплов дало бы
.map((s) => ({ * двойной счёт — берём максимум наблюдавшегося уровня за час.
t: s.recorded_at.slice(11, 19), */
dropped: s.packets_dropped, const series = useMemo(() => {
accepted: s.packets_accepted, const byHour = new Map<
})) string,
{ t: string; label: string; dropped: number; accepted: number }
>()
for (const s of stats.data?.items ?? []) {
const d = new Date(s.recorded_at)
if (Number.isNaN(d.getTime())) continue
const key = `${s.recorded_at.slice(0, 13)}:00:00`
const entry = byHour.get(key) ?? {
t: key,
label: d.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
}),
dropped: 0,
accepted: 0,
}
entry.dropped = Math.max(entry.dropped, s.packets_dropped)
entry.accepted = Math.max(entry.accepted, s.packets_accepted)
byHour.set(key, entry)
}
return [...byHour.values()]
.sort((a, b) => a.t.localeCompare(b.t))
.slice(-48)
.map(({ t: _t, ...rest }) => rest)
}, [stats.data])
const rows: StatRow[] = useMemo( const rows: StatRow[] = useMemo(
() => () =>
@@ -76,7 +103,7 @@ function StatsPage() {
const kpiCards: KpiStatCard[] = [ const kpiCards: KpiStatCard[] = [
{ {
id: 'd', id: 'd',
label: 'Dropped (sum)', label: 'Заблокировано (сумма)',
value: dash.data?.packets_dropped ?? 0, value: dash.data?.packets_dropped ?? 0,
icon: <BanIcon aria-hidden />, icon: <BanIcon aria-hidden />,
iconClassName: 'text-warning', iconClassName: 'text-warning',
@@ -84,14 +111,14 @@ function StatsPage() {
}, },
{ {
id: 'a', id: 'a',
label: 'Accepted (sum)', label: 'Пропущено (сумма)',
value: dash.data?.packets_accepted ?? 0, value: dash.data?.packets_accepted ?? 0,
icon: <CheckCircle2Icon aria-hidden />, icon: <CheckCircle2Icon aria-hidden />,
iconClassName: 'text-success', iconClassName: 'text-success',
}, },
{ {
id: 'o', id: 'o',
label: 'Online agents', label: 'Агентов онлайн',
value: dash.data?.agents_online ?? 0, value: dash.data?.agents_online ?? 0,
icon: <ServerIcon aria-hidden />, icon: <ServerIcon aria-hidden />,
iconClassName: 'text-info', iconClassName: 'text-info',
@@ -102,7 +129,7 @@ function StatsPage() {
() => [ () => [
{ {
key: 'agent_id', key: 'agent_id',
label: 'Agent', label: 'Агент',
type: 'text', type: 'text',
placeholder: 'agent id…', placeholder: 'agent id…',
}, },
@@ -119,7 +146,9 @@ function StatsPage() {
() => [ () => [
{ {
accessorKey: 'agent_id', accessorKey: 'agent_id',
header: 'Agent', header: ({ column }) => (
<DataGridColumnHeader column={column} title="Агент" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-xs"> <span className="font-mono text-xs">
{row.original.agent_id.slice(0, 8)} {row.original.agent_id.slice(0, 8)}
@@ -128,23 +157,35 @@ function StatsPage() {
}, },
{ {
accessorKey: 'recorded_at', accessorKey: 'recorded_at',
header: 'Time', header: ({ column }) => (
<DataGridColumnHeader column={column} title="Время" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-xs">{row.original.recorded_at}</span> <span className="text-xs tabular-nums">
{formatStampDateTime(row.original.recorded_at)}
</span>
), ),
}, },
{ {
accessorKey: 'packets_dropped', accessorKey: 'packets_dropped',
header: 'Drop', header: ({ column }) => (
<DataGridColumnHeader column={column} title="Заблокировано" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="tabular-nums">{row.original.packets_dropped}</span> <span className="text-warning tabular-nums">
{formatNumber(row.original.packets_dropped)}
</span>
), ),
}, },
{ {
accessorKey: 'packets_accepted', accessorKey: 'packets_accepted',
header: 'Accept', header: ({ column }) => (
<DataGridColumnHeader column={column} title="Пропущено" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="tabular-nums">{row.original.packets_accepted}</span> <span className="text-success tabular-nums">
{formatNumber(row.original.packets_accepted)}
</span>
), ),
}, },
], ],
@@ -156,12 +197,13 @@ function StatsPage() {
<PageHeader <PageHeader
title="Статистика" title="Статистика"
description="История apply-report counters" description="История apply-report counters"
actions={<UpdatedAtLabel updatedAt={stats.dataUpdatedAt} />}
/> />
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} /> <KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
<Frame dense spacing="sm"> <Frame dense spacing="sm">
<FrameHeader> <FrameHeader>
<FrameTitle>Тренд (последние samples)</FrameTitle> <FrameTitle>Активность по часам (пик за час)</FrameTitle>
</FrameHeader> </FrameHeader>
<FramePanel> <FramePanel>
{series.length === 0 ? ( {series.length === 0 ? (
@@ -207,8 +249,9 @@ function StatsPage() {
onClearFilters={() => setFilters([])} onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue} getFilterFieldValue={getFilterFieldValue}
isLoading={stats.isLoading} isLoading={stats.isLoading}
pageSize={20}
emptyState={{ emptyState={{
title: 'Нет samples', title: 'Нет данных',
description: 'Агенты ещё не отправили apply-report.', description: 'Агенты ещё не отправили apply-report.',
}} }}
/> />