From f4259246d04dcfd786ba67c3d1bce3defc2bea38 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 19:44:07 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=B4=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=20=D0=BC=D0=B0=D0=BA=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заголовок страницы как на досках (20px), панель: быстрые действия, график и таблицы «Агенты» и «Требует внимания» в раскладке MikrotikManager (новый SimpleGrid). Статистика: пояснение, KPI с подсказками и линейные вкладки «Активность» и «Samples». Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/components/page-header.tsx | 16 +- apps/web/src/components/portal/bits.tsx | 2 +- .../src/components/portal/data-page-layout.ts | 29 ++ .../src/components/reui-kit/ops-dashboard.tsx | 23 +- .../src/components/reui-kit/simple-grid.tsx | 79 +++++ apps/web/src/routes/_auth/index.tsx | 272 ++++++++---------- apps/web/src/routes/_auth/stats.tsx | 133 +++++---- 7 files changed, 320 insertions(+), 234 deletions(-) create mode 100644 apps/web/src/components/portal/data-page-layout.ts create mode 100644 apps/web/src/components/reui-kit/simple-grid.tsx diff --git a/apps/web/src/components/page-header.tsx b/apps/web/src/components/page-header.tsx index 67b397d..0b19a96 100644 --- a/apps/web/src/components/page-header.tsx +++ b/apps/web/src/components/page-header.tsx @@ -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 ( -
-
-

{title}

- {description ? ( -

{description}

- ) : null} +
+
+

{title}

+ {description ?

{description}

: null}
- {actions ?
{actions}
: null} -
+ {actions ?
{actions}
: null} + ) } diff --git a/apps/web/src/components/portal/bits.tsx b/apps/web/src/components/portal/bits.tsx index 858765d..1037e27 100644 --- a/apps/web/src/components/portal/bits.tsx +++ b/apps/web/src/components/portal/bits.tsx @@ -432,7 +432,7 @@ export function PageTabsList({ children }: { children: ReactNode }) { export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) { return ( - + {label} {count !== undefined ? {count} : null} diff --git a/apps/web/src/components/portal/data-page-layout.ts b/apps/web/src/components/portal/data-page-layout.ts new file mode 100644 index 0000000..6981098 --- /dev/null +++ b/apps/web/src/components/portal/data-page-layout.ts @@ -0,0 +1,29 @@ +import type { ComponentProps } from 'react' +import type { DataGrid } from '@/components/reui/data-grid/data-grid' + +type GridProps = ComponentProps + +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 = { + rowBorder: true, + headerBackground: true, + headerBorder: true, + columnsResizable: false, +} + +export const DATA_GRID_CLASS_NAMES: NonNullable = { + 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 +} diff --git a/apps/web/src/components/reui-kit/ops-dashboard.tsx b/apps/web/src/components/reui-kit/ops-dashboard.tsx index 30c123b..8f2fda1 100644 --- a/apps/web/src/components/reui-kit/ops-dashboard.tsx +++ b/apps/web/src/components/reui-kit/ops-dashboard.tsx @@ -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({
{charts}
-
- - - Требуют внимания - - Pending-агенты, ошибки apply и списки с last_error - - - {queue} - -
+
{queue}
) } diff --git a/apps/web/src/components/reui-kit/simple-grid.tsx b/apps/web/src/components/reui-kit/simple-grid.tsx new file mode 100644 index 0000000..88431b8 --- /dev/null +++ b/apps/web/src/components/reui-kit/simple-grid.tsx @@ -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({ + title, + description, + actions, + columns, + data, + getRowId, + onRowClick, + emptyMessage = 'Нет записей.', + footer, +}: { + title: string + description?: string + actions?: ReactNode + columns: ColumnDef[] + data: T[] + getRowId: (row: T) => string + onRowClick?: (row: T) => void + emptyMessage?: string + footer?: ReactNode +}) { + const [sorting, setSorting] = useState([]) + const state = useMemo(() => ({ sorting }), [sorting]) + const table = useReactTable({ + data, + columns, + getRowId, + state, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + return ( + + + +
+ {title} + {description ? {description} : null} +
+ {actions ?
{actions}
: null} +
+ + + + + {footer ?
{footer}
: null} +
+ +
+ ) +} diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index 7de93e0..f6e835d 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -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[] = [ + { + id: 'name', + accessorFn: (a) => a.name, + header: ({ column }) => , + cell: ({ row }) => ( + + ), + meta: { headerTitle: 'Агент' }, + }, + { + id: 'status', + accessorFn: (a) => a.status, + header: ({ column }) => , + cell: ({ row }) => , + meta: { headerTitle: 'Статус' }, + }, + { + id: 'dropped', + accessorFn: (a) => agentTrafficDropped(a), + header: ({ column }) => , + cell: ({ row }) => + row.original.status === 'approved' ? ( + ↓{formatNumber(agentTrafficDropped(row.original))} + ) : ( + — + ), + meta: { headerTitle: 'Заблокировано' }, + }, +] + +const attentionColumns: ColumnDef[] = [ + { + id: 'name', + accessorFn: (r) => r.agent.name, + header: ({ column }) => , + cell: ({ row }) => , + meta: { headerTitle: 'Объект' }, + }, + { + id: 'message', + accessorFn: (r) => r.message, + header: ({ column }) => , + cell: ({ row }) => {row.original.message}, + meta: { headerTitle: 'Сообщение' }, + }, + { + id: 'status', + accessorFn: (r) => r.kind, + header: ({ column }) => , + cell: ({ row }) => + row.original.kind === 'error' ? ( + + ) : ( + + ), + meta: { headerTitle: 'Статус' }, + }, + { + id: 'when', + accessorFn: (r) => r.when ?? '', + header: ({ column }) => , + cell: ({ row }) => {formatRelativeTime(row.original.when)}, + 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( + () => [ + ...items + .filter((a) => a.last_apply_error) + .map((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((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: , - 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: , iconClassName: 'text-primary', @@ -107,133 +167,53 @@ function DashboardPage() { const quickActions: QuickActionItem[] = useMemo( () => [ - { - id: 'agents', - title: 'Агенты', - description: 'Подключение и одобрение агентов', - to: '/agents', - icon: , - iconClassName: 'text-info', - }, - { - id: 'lists', - title: 'Списки', - description: 'Блок-листы и источники', - to: '/lists', - icon: , - iconClassName: 'text-primary', - }, - { - id: 'rules', - title: 'Правила', - description: 'Политика блокировки', - to: '/rules', - icon: , - iconClassName: 'text-warning', - }, + { id: 'agents', title: 'Агенты', description: 'Подключение и одобрение агентов', to: '/agents', icon: , iconClassName: 'text-info' }, + { id: 'lists', title: 'Списки', description: 'Блок-листы и источники', to: '/lists', icon: , iconClassName: 'text-primary' }, + { id: 'rules', title: 'Правила', description: 'Политика блокировки', to: '/rules', icon: , iconClassName: 'text-warning' }, ], [], ) return ( - } /> - ) : undefined - } + afterKpi={showQuickActions ? : undefined} charts={ <> - - - Агенты - - - {previewAgents.length === 0 ? ( -

Нет агентов

- ) : ( - - {previewAgents.map((a) => ( - - - - - {a.name} - - -
- - {a.status} - - - ↓{agentTrafficDropped(a)} - -
-
-
- ))} -
- )} -
- + a.id} + onRowClick={(a) => void navigate({ to: '/agents', search: { agent: a.id, view: 'cards' } })} + emptyMessage="Нет агентов" + /> } queue={ -
- {pending.length === 0 && applyErrors.length === 0 ? ( -

- Нет проблем, требующих внимания -

- ) : null} - {pending.map((a) => ( -
- - Pending: {a.name} - - - Открыть - -
- ))} - {applyErrors.map((a) => ( -
- {a.name} - - {' '} - — {a.last_apply_error} - -
- ))} -
+ } nativeButton={false}> + Открыть агентов + + } + columns={attentionColumns} + data={attention} + getRowId={(r) => r.id} + onRowClick={(r) => void navigate({ to: '/agents', search: { agent: r.agent.id, view: 'cards' } })} + emptyMessage="Нет проблем, требующих внимания" + /> } />
diff --git a/apps/web/src/routes/_auth/stats.tsx b/apps/web/src/routes/_auth/stats.tsx index 2f76a7d..c0ce861 100644 --- a/apps/web/src/routes/_auth/stats.tsx +++ b/apps/web/src/routes/_auth/stats.tsx @@ -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: , - iconClassName: 'text-warning', - variant: 'warning', + iconClassName: 'text-destructive', + variant: 'destructive', }, { id: 'a', label: 'Пропущено (сумма)', + hint: 'пакетов · сумма пиков', value: dash.data?.packets_accepted ?? 0, icon: , 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: , iconClassName: 'text-info', @@ -225,65 +230,77 @@ function StatsPage() { } /> + + Счётчики агентов смешанной семантики (накопительные и сбросные): суммирование сэмплов дало бы двойной счёт, + поэтому за час берём максимум наблюдавшегося уровня. + - - - - Активность по часам (пик за час) - - - {series.length === 0 ? ( -

- Нет данных — дождитесь apply-report от агентов. -

- ) : ( - - - - - - } /> - - - - - )} -
- - - r.id} - filterFields={filterFields} - filters={filters} - onFiltersChange={setStatsFilters} - onClearFilters={() => setStatsFilters([])} - getFilterFieldValue={getFilterFieldValue} - isLoading={stats.isLoading} - pageSize={20} - emptyState={{ - title: 'Нет данных', - description: 'Агенты ещё не отправили apply-report.', - }} - /> + + + + + + + + + Активность по часам (пик за час) + + + {series.length === 0 ? ( +

+ Нет данных — дождитесь apply-report от агентов. +

+ ) : ( + + + + + + } /> + + + + + )} +
+ +
+ + r.id} + filterFields={filterFields} + filters={filters} + onFiltersChange={setStatsFilters} + onClearFilters={() => setStatsFilters([])} + getFilterFieldValue={getFilterFieldValue} + isLoading={stats.isLoading} + pageSize={20} + emptyState={{ + title: 'Нет данных', + description: 'Агенты ещё не отправили apply-report.', + }} + /> + +
) }