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'
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>
)
}
+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',
})
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)
}
+3
View File
@@ -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,
})
+80 -22
View File
@@ -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}
+2
View File
@@ -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}
+66 -23
View File
@@ -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.',
}}
/>