From 0b426fa7aee5d04b0c7ce24cd1994f1b57c74f4d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 25 Sep 2026 01:17:33 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20live-=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=E2=80=94=20polling,=20=D0=BE=D0=BD=D0=BB=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD-=D0=B8=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D1=8B,=20=D0=BE=D1=82=D0=BD=D0=BE=D1=81=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D0=B2=D1=80=D0=B5=D0=BC?= =?UTF-8?q?=D1=8F,=20actionable-=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D1=8C?= =?UTF-8?q?=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20apply,=20=D1=87=D0=B0?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=B0=D0=B3=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/agents/agent-detail-view.tsx | 36 +++++-- .../agents/agent-fleet-data-grid.tsx | 30 +++--- .../components/agents/agent-online-dot.tsx | 27 +++++ .../src/components/reui-kit/resource-page.tsx | 6 +- apps/web/src/components/updated-at-label.tsx | 11 ++ apps/web/src/lib/agent-online.ts | 16 +++ apps/web/src/lib/format.ts | 19 ++++ apps/web/src/queries/index.ts | 3 + apps/web/src/routes/_auth/agents/index.tsx | 102 ++++++++++++++---- apps/web/src/routes/_auth/index.tsx | 2 + apps/web/src/routes/_auth/stats.tsx | 89 +++++++++++---- 11 files changed, 272 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/components/agents/agent-online-dot.tsx create mode 100644 apps/web/src/components/updated-at-label.tsx create mode 100644 apps/web/src/lib/agent-online.ts diff --git a/apps/web/src/components/agents/agent-detail-view.tsx b/apps/web/src/components/agents/agent-detail-view.tsx index 7402976..c2c07f9 100644 --- a/apps/web/src/components/agents/agent-detail-view.tsx +++ b/apps/web/src/components/agents/agent-detail-view.tsx @@ -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) { Ошибка загрузки - - {agentQ.error?.message ?? 'Не удалось загрузить агента'} + + + {agentQ.error?.message ?? 'Не удалось загрузить агента'} + + @@ -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={ <> - + + + + , 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, }, ]} /> diff --git a/apps/web/src/components/agents/agent-fleet-data-grid.tsx b/apps/web/src/components/agents/agent-fleet-data-grid.tsx index f011dc9..1788097 100644 --- a/apps/web/src/components/agents/agent-fleet-data-grid.tsx +++ b/apps/web/src/components/agents/agent-fleet-data-grid.tsx @@ -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 }) => ( - + ), 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 — } return ( - +
+ + +
) }, }, diff --git a/apps/web/src/components/agents/agent-online-dot.tsx b/apps/web/src/components/agents/agent-online-dot.tsx new file mode 100644 index 0000000..2ca1214 --- /dev/null +++ b/apps/web/src/components/agents/agent-online-dot.tsx @@ -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 | null | undefined + className?: string +}) { + const online = isAgentOnline(agent) + const label = online ? 'На связи' : 'Не на связи' + return ( + + ) +} diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index 578ce9c..349bce7 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -78,6 +78,8 @@ export interface ResourcePageProps { 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({ onRetry, primaryAction, emptyState, + filteredEmptyMessage, pageSize = 10, enableRowSelection = false, rowSelection: rowSelectionProp, @@ -385,7 +388,8 @@ export function ResourcePage({ ) } - const emptyMessage = 'Нет записей по выбранным фильтрам.' + const emptyMessage = + filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.' return (
diff --git a/apps/web/src/components/updated-at-label.tsx b/apps/web/src/components/updated-at-label.tsx new file mode 100644 index 0000000..595f150 --- /dev/null +++ b/apps/web/src/components/updated-at-label.tsx @@ -0,0 +1,11 @@ +import { formatTime } from '@/lib/format' + +/** «Обновлено в HH:mm:ss» — подпись свежести данных для polling-страниц. */ +export function UpdatedAtLabel({ updatedAt }: { updatedAt?: number }) { + if (!updatedAt) return null + return ( + + Обновлено в {formatTime(new Date(updatedAt))} + + ) +} diff --git a/apps/web/src/lib/agent-online.ts b/apps/web/src/lib/agent-online.ts new file mode 100644 index 0000000..0f1b880 --- /dev/null +++ b/apps/web/src/lib/agent-online.ts @@ -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 | 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 +} diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index e1bac8d..e1a9ff1 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -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) +} diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index 4b1ab20..869bc82 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -12,12 +12,14 @@ export const dashboardQueryOptions = () => queryOptions({ queryKey: ['dashboard'], queryFn: () => apiFetch('/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, }) diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index ea4a8f1..d41d990 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -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() { + + {addButton} + + } /> @@ -586,15 +625,33 @@ function AgentsPage() { ) : null} - {counts.applyErrors > 0 ? ( - - - Ошибки apply - - У {counts.applyErrors} агент(ов) есть last_apply_error — откройте - карточку для деталей. - - + {applyErrorAgents.length > 0 ? ( + + +
+ Ошибки применения политики + + У {applyErrorAgents.length} агент(ов) последняя попытка apply + завершилась ошибкой — откройте карточку для деталей. + +
+
+ + {applyErrorAgents.map((a) => ( + + ))} + + ) : 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} diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index 46db007..6dcfb24 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -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() { } /> ([]) - 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: , iconClassName: 'text-warning', @@ -84,14 +111,14 @@ function StatsPage() { }, { id: 'a', - label: 'Accepted (sum)', + label: 'Пропущено (сумма)', value: dash.data?.packets_accepted ?? 0, icon: , iconClassName: 'text-success', }, { id: 'o', - label: 'Online agents', + label: 'Агентов онлайн', value: dash.data?.agents_online ?? 0, icon: , 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 }) => ( + + ), cell: ({ row }) => ( {row.original.agent_id.slice(0, 8)} @@ -128,23 +157,35 @@ function StatsPage() { }, { accessorKey: 'recorded_at', - header: 'Time', + header: ({ column }) => ( + + ), cell: ({ row }) => ( - {row.original.recorded_at} + + {formatStampDateTime(row.original.recorded_at)} + ), }, { accessorKey: 'packets_dropped', - header: 'Drop', + header: ({ column }) => ( + + ), cell: ({ row }) => ( - {row.original.packets_dropped} + + {formatNumber(row.original.packets_dropped)} + ), }, { accessorKey: 'packets_accepted', - header: 'Accept', + header: ({ column }) => ( + + ), cell: ({ row }) => ( - {row.original.packets_accepted} + + {formatNumber(row.original.packets_accepted)} + ), }, ], @@ -156,12 +197,13 @@ function StatsPage() { } /> - Тренд (последние samples) + Активность по часам (пик за час) {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.', }} />