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