Compare commits
2
Commits
v1.2.0
..
f4259246d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4259246d0 | ||
|
|
269f94edcf |
No files matched your search
@@ -163,7 +163,7 @@ export function PortalShell({ children }: { children: ReactNode }) {
|
|||||||
<PortalSidebar />
|
<PortalSidebar />
|
||||||
<SidebarInset id="main-content" className="min-w-0">
|
<SidebarInset id="main-content" className="min-w-0">
|
||||||
<PortalHeader />
|
<PortalHeader />
|
||||||
<div className="mx-auto flex w-full min-w-0 max-w-[1480px] flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
|
<div className="flex w-full min-w-0 flex-1 flex-col gap-5 p-4 md:p-6">{children}</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@@ -6,17 +6,15 @@ interface PageHeaderProps {
|
|||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Page-level section header — etalon EvoBGP / Domains list. */
|
/** Заголовок страницы как на досках макета: название 20px, описание 14px muted, действия справа. */
|
||||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<header className="flex min-w-0 flex-wrap items-end justify-between gap-3">
|
||||||
<div className="flex flex-col gap-px">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
||||||
{description ? (
|
{description ? <p className="text-muted-foreground text-sm text-pretty">{description}</p> : null}
|
||||||
<p className="text-muted-foreground text-sm">{description}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
|
||||||
</div>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -432,7 +432,7 @@ export function PageTabsList({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
|
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
|
||||||
return (
|
return (
|
||||||
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm">
|
<TabsTrigger value={value} className="flex-none gap-1.5 px-4 text-sm">
|
||||||
{label}
|
{label}
|
||||||
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
|
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import type { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||||
|
|
||||||
|
type GridProps = ComponentProps<typeof DataGrid>
|
||||||
|
|
||||||
|
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<GridProps['tableLayout']> = {
|
||||||
|
rowBorder: true,
|
||||||
|
headerBackground: true,
|
||||||
|
headerBorder: true,
|
||||||
|
columnsResizable: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATA_GRID_CLASS_NAMES: NonNullable<GridProps['tableClassNames']> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
|
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. */
|
/** Denser stack for KPI / charts / queue. PageHeader lives outside via PageShell. */
|
||||||
const rootClassName =
|
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() {
|
function OpsDashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -58,22 +51,12 @@ export function OpsDashboard({
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
aria-label="Аналитика"
|
aria-label="Аналитика"
|
||||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
className="flex min-w-0 flex-col gap-5"
|
||||||
>
|
>
|
||||||
{charts}
|
{charts}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Требуют внимания">
|
<section aria-label="Требуют внимания">{queue}</section>
|
||||||
<Frame dense spacing="sm" className="w-full">
|
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Требуют внимания</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
Pending-агенты, ошибки apply и списки с last_error
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel>{queue}</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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<T extends object>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
getRowId,
|
||||||
|
onRowClick,
|
||||||
|
emptyMessage = 'Нет записей.',
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
columns: ColumnDef<T, unknown>[]
|
||||||
|
data: T[]
|
||||||
|
getRowId: (row: T) => string
|
||||||
|
onRowClick?: (row: T) => void
|
||||||
|
emptyMessage?: string
|
||||||
|
footer?: ReactNode
|
||||||
|
}) {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const state = useMemo(() => ({ sorting }), [sorting])
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getRowId,
|
||||||
|
state,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
recordCount={data.length}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
tableLayout={{ ...DATA_GRID_LAYOUT, dense: false, headerSticky: false, width: 'fixed' }}
|
||||||
|
tableClassNames={DATA_GRID_CLASS_NAMES}
|
||||||
|
>
|
||||||
|
<Frame dense spacing="sm" className="w-full min-w-0">
|
||||||
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
|
<FrameTitle className="text-balance">{title}</FrameTitle>
|
||||||
|
{description ? <FrameDescription className="text-xs text-pretty">{description}</FrameDescription> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="overflow-hidden p-0 shadow-none!">
|
||||||
|
<DataGridScrollArea>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGridScrollArea>
|
||||||
|
{footer ? <div className="text-muted-foreground border-t px-5 py-3 text-xs">{footer}</div> : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</DataGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
+126
-146
@@ -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 { useQuery } from '@tanstack/react-query'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import { BanIcon, CheckCircle2Icon, ListIcon, ServerIcon } from 'lucide-react'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import {
|
import {
|
||||||
ServerIcon,
|
|
||||||
BanIcon,
|
|
||||||
CheckCircle2Icon,
|
|
||||||
ListIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import {
|
|
||||||
PageHeader,
|
|
||||||
PageShell,
|
|
||||||
OpsDashboard,
|
OpsDashboard,
|
||||||
|
PageShell,
|
||||||
QuickActionGrid,
|
QuickActionGrid,
|
||||||
type KpiStatCard,
|
type KpiStatCard,
|
||||||
type QuickActionItem,
|
type QuickActionItem,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import {
|
import { SimpleGrid } from '@/components/reui-kit/simple-grid'
|
||||||
Frame,
|
import { PageHead } from '@/components/portal/bits'
|
||||||
FrameHeader,
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
FramePanel,
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
FrameTitle,
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
} from '@/components/reui/frame'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { dashboardQueryOptions, agentsQueryOptions, settingsQueryOptions } from '@/queries'
|
||||||
import {
|
|
||||||
Item,
|
|
||||||
ItemContent,
|
|
||||||
ItemGroup,
|
|
||||||
ItemTitle,
|
|
||||||
} from '@evofw/ui/components/item'
|
|
||||||
import {
|
|
||||||
dashboardQueryOptions,
|
|
||||||
agentsQueryOptions,
|
|
||||||
settingsQueryOptions,
|
|
||||||
} 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 { platformLabel } from '@/components/agents/agent-platform-icon'
|
||||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||||
|
import { formatNumber, formatRelativeTime } from '@/lib/format'
|
||||||
import type { Agent } from '@evofw/shared'
|
import type { Agent } from '@evofw/shared'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/')({
|
export const Route = createFileRoute('/_auth/')({
|
||||||
@@ -44,7 +30,77 @@ export const Route = createFileRoute('/_auth/')({
|
|||||||
|
|
||||||
const EMPTY_AGENTS: Agent[] = []
|
const EMPTY_AGENTS: Agent[] = []
|
||||||
|
|
||||||
|
type Attention = { id: string; agent: Agent; kind: 'pending' | 'error'; message: string; when: string | null | undefined }
|
||||||
|
|
||||||
|
const agentColumns: ColumnDef<Agent>[] = [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorFn: (a) => a.name,
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Агент" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DataGridPrimaryCell title={row.original.name} subtitle={platformLabel(row.original.platform)} />
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Агент' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorFn: (a) => a.status,
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
meta: { headerTitle: 'Статус' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dropped',
|
||||||
|
accessorFn: (a) => agentTrafficDropped(a),
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Заблокировано" />,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.status === 'approved' ? (
|
||||||
|
<span className="tabular-nums">↓{formatNumber(agentTrafficDropped(row.original))}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Заблокировано' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const attentionColumns: ColumnDef<Attention>[] = [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorFn: (r) => r.agent.name,
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Объект" />,
|
||||||
|
cell: ({ row }) => <DataGridPrimaryCell title={row.original.agent.name} subtitle="Агент" />,
|
||||||
|
meta: { headerTitle: 'Объект' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'message',
|
||||||
|
accessorFn: (r) => r.message,
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Сообщение" />,
|
||||||
|
cell: ({ row }) => <span className="text-sm">{row.original.message}</span>,
|
||||||
|
meta: { headerTitle: 'Сообщение' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorFn: (r) => r.kind,
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.kind === 'error' ? (
|
||||||
|
<StatusBadge status="error" label="Ошибка" />
|
||||||
|
) : (
|
||||||
|
<StatusBadge status="pending" label="Ожидает" />
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Статус' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'when',
|
||||||
|
accessorFn: (r) => r.when ?? '',
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Когда" />,
|
||||||
|
cell: ({ row }) => <span className="text-muted-foreground text-xs">{formatRelativeTime(row.original.when)}</span>,
|
||||||
|
meta: { headerTitle: 'Когда' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const dash = useQuery(dashboardQueryOptions())
|
const dash = useQuery(dashboardQueryOptions())
|
||||||
const agents = useQuery(agentsQueryOptions())
|
const agents = useQuery(agentsQueryOptions())
|
||||||
const settingsQ = useQuery(settingsQueryOptions())
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
@@ -53,15 +109,18 @@ function DashboardPage() {
|
|||||||
const items = agents.data?.items ?? EMPTY_AGENTS
|
const items = agents.data?.items ?? EMPTY_AGENTS
|
||||||
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
|
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 previewAgents = useMemo(() => items.slice(0, 8), [items])
|
||||||
|
const attention = useMemo<Attention[]>(
|
||||||
|
() => [
|
||||||
|
...items
|
||||||
|
.filter((a) => a.last_apply_error)
|
||||||
|
.map<Attention>((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<Attention>((a) => ({ id: `pend-${a.id}`, agent: a, kind: 'pending', message: 'Ожидает одобрения', when: a.created_at })),
|
||||||
|
],
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
|
||||||
const kpiCards: KpiStatCard[] = useMemo(
|
const kpiCards: KpiStatCard[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -81,8 +140,8 @@ function DashboardPage() {
|
|||||||
hint: 'сумма counters',
|
hint: 'сумма counters',
|
||||||
to: '/stats',
|
to: '/stats',
|
||||||
icon: <BanIcon aria-hidden />,
|
icon: <BanIcon aria-hidden />,
|
||||||
iconClassName: 'text-warning',
|
iconClassName: 'text-destructive',
|
||||||
variant: 'warning',
|
variant: 'destructive',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'accepted',
|
id: 'accepted',
|
||||||
@@ -97,6 +156,7 @@ function DashboardPage() {
|
|||||||
id: 'lists',
|
id: 'lists',
|
||||||
label: 'Списки IP',
|
label: 'Списки IP',
|
||||||
value: d?.lists_total ?? '—',
|
value: d?.lists_total ?? '—',
|
||||||
|
hint: 'ручные, JSON и EvoBGP',
|
||||||
to: '/lists',
|
to: '/lists',
|
||||||
icon: <ListIcon aria-hidden />,
|
icon: <ListIcon aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
@@ -107,133 +167,53 @@ function DashboardPage() {
|
|||||||
|
|
||||||
const quickActions: QuickActionItem[] = useMemo(
|
const quickActions: QuickActionItem[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{ id: 'agents', title: 'Агенты', description: 'Подключение и одобрение агентов', to: '/agents', icon: <ServerIcon aria-hidden />, iconClassName: 'text-info' },
|
||||||
id: 'agents',
|
{ id: 'lists', title: 'Списки', description: 'Блок-листы и источники', to: '/lists', icon: <ListIcon aria-hidden />, iconClassName: 'text-primary' },
|
||||||
title: 'Агенты',
|
{ id: 'rules', title: 'Правила', description: 'Политика блокировки', to: '/rules', icon: <BanIcon aria-hidden />, iconClassName: 'text-warning' },
|
||||||
description: 'Подключение и одобрение агентов',
|
|
||||||
to: '/agents',
|
|
||||||
icon: <ServerIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-info',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lists',
|
|
||||||
title: 'Списки',
|
|
||||||
description: 'Блок-листы и источники',
|
|
||||||
to: '/lists',
|
|
||||||
icon: <ListIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rules',
|
|
||||||
title: 'Правила',
|
|
||||||
description: 'Политика блокировки',
|
|
||||||
to: '/rules',
|
|
||||||
icon: <BanIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-warning',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHead
|
||||||
title="Панель управления"
|
title="Панель управления"
|
||||||
description="Обзор агентов и пакетной статистики"
|
sub="Обзор агентов и пакетной статистики"
|
||||||
actions={<UpdatedAtLabel updatedAt={dash.dataUpdatedAt} />}
|
actions={<UpdatedAtLabel updatedAt={dash.dataUpdatedAt} />}
|
||||||
/>
|
/>
|
||||||
<OpsDashboard
|
<OpsDashboard
|
||||||
isLoading={dash.isLoading}
|
isLoading={dash.isLoading}
|
||||||
kpiCards={kpiCards}
|
kpiCards={kpiCards}
|
||||||
afterKpi={
|
afterKpi={showQuickActions ? <QuickActionGrid actions={quickActions} /> : undefined}
|
||||||
showQuickActions ? (
|
|
||||||
<QuickActionGrid actions={quickActions} />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
charts={
|
charts={
|
||||||
<>
|
<>
|
||||||
<AgentsFleetChart />
|
<AgentsFleetChart />
|
||||||
<Frame dense spacing="sm">
|
<SimpleGrid
|
||||||
<FrameHeader>
|
title="Агенты"
|
||||||
<FrameTitle>Агенты</FrameTitle>
|
description="Первые 8 · клик открывает карточку агента"
|
||||||
</FrameHeader>
|
columns={agentColumns}
|
||||||
<FramePanel>
|
data={previewAgents}
|
||||||
{previewAgents.length === 0 ? (
|
getRowId={(a) => a.id}
|
||||||
<p className="text-muted-foreground text-sm">Нет агентов</p>
|
onRowClick={(a) => void navigate({ to: '/agents', search: { agent: a.id, view: 'cards' } })}
|
||||||
) : (
|
emptyMessage="Нет агентов"
|
||||||
<ItemGroup className="gap-2">
|
/>
|
||||||
{previewAgents.map((a) => (
|
|
||||||
<Item key={a.id} variant="outline" size="sm">
|
|
||||||
<ItemContent className="flex flex-row items-center justify-between gap-2">
|
|
||||||
<ItemTitle className="truncate font-medium">
|
|
||||||
<Link
|
|
||||||
to="/agents"
|
|
||||||
search={{ agent: a.id, view: 'cards' }}
|
|
||||||
className="hover:underline"
|
|
||||||
>
|
|
||||||
{a.name}
|
|
||||||
</Link>
|
|
||||||
</ItemTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
a.status === 'approved'
|
|
||||||
? 'success-light'
|
|
||||||
: a.status === 'pending'
|
|
||||||
? 'warning-light'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{a.status}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
|
||||||
↓{agentTrafficDropped(a)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
))}
|
|
||||||
</ItemGroup>
|
|
||||||
)}
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
queue={
|
queue={
|
||||||
<div className="flex flex-col gap-2">
|
<SimpleGrid
|
||||||
{pending.length === 0 && applyErrors.length === 0 ? (
|
title="Требует внимания"
|
||||||
<p className="text-muted-foreground text-sm">
|
description="Ожидают одобрения и ошибки применения политики"
|
||||||
Нет проблем, требующих внимания
|
actions={
|
||||||
</p>
|
<Button variant="outline" render={<Link to="/agents" search={{ view: "cards" }} />} nativeButton={false}>
|
||||||
) : null}
|
Открыть агентов
|
||||||
{pending.map((a) => (
|
</Button>
|
||||||
<div
|
}
|
||||||
key={a.id}
|
columns={attentionColumns}
|
||||||
className="flex items-center justify-between gap-2 text-sm"
|
data={attention}
|
||||||
>
|
getRowId={(r) => r.id}
|
||||||
<span>
|
onRowClick={(r) => void navigate({ to: '/agents', search: { agent: r.agent.id, view: 'cards' } })}
|
||||||
Pending: <strong>{a.name}</strong>
|
emptyMessage="Нет проблем, требующих внимания"
|
||||||
</span>
|
/>
|
||||||
<Link
|
|
||||||
to="/agents"
|
|
||||||
search={{ agent: a.id, view: 'cards' }}
|
|
||||||
className="underline-offset-4 hover:underline"
|
|
||||||
>
|
|
||||||
Открыть
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{applyErrors.map((a) => (
|
|
||||||
<div key={`err-${a.id}`} className="text-sm">
|
|
||||||
<span className="text-destructive font-medium">{a.name}</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{' '}
|
|
||||||
— {a.last_apply_error}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} 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 { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||||
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
|
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
|
||||||
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||||
@@ -133,14 +135,16 @@ function StatsPage() {
|
|||||||
{
|
{
|
||||||
id: 'd',
|
id: 'd',
|
||||||
label: 'Заблокировано (сумма)',
|
label: 'Заблокировано (сумма)',
|
||||||
|
hint: 'пакетов · сумма пиков',
|
||||||
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-destructive',
|
||||||
variant: 'warning',
|
variant: 'destructive',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'a',
|
id: 'a',
|
||||||
label: 'Пропущено (сумма)',
|
label: 'Пропущено (сумма)',
|
||||||
|
hint: 'пакетов · сумма пиков',
|
||||||
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',
|
||||||
@@ -148,6 +152,7 @@ function StatsPage() {
|
|||||||
{
|
{
|
||||||
id: 'o',
|
id: 'o',
|
||||||
label: 'Агентов онлайн',
|
label: 'Агентов онлайн',
|
||||||
|
hint: `из ${dash.data?.agents_approved ?? 0} одобренных`,
|
||||||
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',
|
||||||
@@ -225,65 +230,77 @@ function StatsPage() {
|
|||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Статистика"
|
title="Статистика"
|
||||||
description="История apply-report counters"
|
description="История apply-report counters · заблокированный и пропущенный трафик агентов"
|
||||||
actions={<UpdatedAtLabel updatedAt={stats.dataUpdatedAt} />}
|
actions={<UpdatedAtLabel updatedAt={stats.dataUpdatedAt} />}
|
||||||
/>
|
/>
|
||||||
|
<SignalAlert tone="in" title="Как считаются значения.">
|
||||||
|
Счётчики агентов смешанной семантики (накопительные и сбросные): суммирование сэмплов дало бы двойной счёт,
|
||||||
|
поэтому за час берём максимум наблюдавшегося уровня.
|
||||||
|
</SignalAlert>
|
||||||
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
|
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
|
||||||
|
<Tabs defaultValue="activity" className="min-w-0 gap-4">
|
||||||
<Frame dense spacing="sm">
|
<PageTabsList>
|
||||||
<FrameHeader>
|
<PageTab value="activity" label="Активность" />
|
||||||
<FrameTitle>Активность по часам (пик за час)</FrameTitle>
|
<PageTab value="samples" label="Samples" count={rows.length} />
|
||||||
</FrameHeader>
|
</PageTabsList>
|
||||||
<FramePanel>
|
<TabsContent value="activity" className="min-w-0">
|
||||||
{series.length === 0 ? (
|
<Frame dense spacing="sm">
|
||||||
<p className="text-muted-foreground text-sm">
|
<FrameHeader>
|
||||||
Нет данных — дождитесь apply-report от агентов.
|
<FrameTitle>Активность по часам (пик за час)</FrameTitle>
|
||||||
</p>
|
</FrameHeader>
|
||||||
) : (
|
<FramePanel>
|
||||||
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
|
{series.length === 0 ? (
|
||||||
<AreaChart data={series}>
|
<p className="text-muted-foreground text-sm">
|
||||||
<CartesianGrid vertical={false} />
|
Нет данных — дождитесь apply-report от агентов.
|
||||||
<XAxis dataKey="t" tickLine={false} axisLine={false} />
|
</p>
|
||||||
<YAxis tickLine={false} axisLine={false} />
|
) : (
|
||||||
<ChartTooltip content={<ChartTooltipContent />} />
|
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
|
||||||
<Area
|
<AreaChart data={series}>
|
||||||
dataKey="dropped"
|
<CartesianGrid vertical={false} />
|
||||||
type="monotone"
|
<XAxis dataKey="t" tickLine={false} axisLine={false} />
|
||||||
fill="var(--color-dropped)"
|
<YAxis tickLine={false} axisLine={false} />
|
||||||
stroke="var(--color-dropped)"
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
fillOpacity={0.3}
|
<Area
|
||||||
/>
|
dataKey="dropped"
|
||||||
<Area
|
type="monotone"
|
||||||
dataKey="accepted"
|
fill="var(--color-dropped)"
|
||||||
type="monotone"
|
stroke="var(--color-dropped)"
|
||||||
fill="var(--color-accepted)"
|
fillOpacity={0.3}
|
||||||
stroke="var(--color-accepted)"
|
/>
|
||||||
fillOpacity={0.2}
|
<Area
|
||||||
/>
|
dataKey="accepted"
|
||||||
</AreaChart>
|
type="monotone"
|
||||||
</ChartContainer>
|
fill="var(--color-accepted)"
|
||||||
)}
|
stroke="var(--color-accepted)"
|
||||||
</FramePanel>
|
fillOpacity={0.2}
|
||||||
</Frame>
|
/>
|
||||||
|
</AreaChart>
|
||||||
<ResourcePage
|
</ChartContainer>
|
||||||
title="Samples"
|
)}
|
||||||
hideHeader
|
</FramePanel>
|
||||||
data={rows}
|
</Frame>
|
||||||
columns={columns}
|
</TabsContent>
|
||||||
getRowId={(r) => r.id}
|
<TabsContent value="samples" className="min-w-0">
|
||||||
filterFields={filterFields}
|
<ResourcePage
|
||||||
filters={filters}
|
title="Samples"
|
||||||
onFiltersChange={setStatsFilters}
|
hideHeader
|
||||||
onClearFilters={() => setStatsFilters([])}
|
data={rows}
|
||||||
getFilterFieldValue={getFilterFieldValue}
|
columns={columns}
|
||||||
isLoading={stats.isLoading}
|
getRowId={(r) => r.id}
|
||||||
pageSize={20}
|
filterFields={filterFields}
|
||||||
emptyState={{
|
filters={filters}
|
||||||
title: 'Нет данных',
|
onFiltersChange={setStatsFilters}
|
||||||
description: 'Агенты ещё не отправили apply-report.',
|
onClearFilters={() => setStatsFilters([])}
|
||||||
}}
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
/>
|
isLoading={stats.isLoading}
|
||||||
|
pageSize={20}
|
||||||
|
emptyState={{
|
||||||
|
title: 'Нет данных',
|
||||||
|
description: 'Агенты ещё не отправили apply-report.',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -8,37 +8,37 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.973 0.004 247.858);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.141 0.006 264);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.141 0.006 264);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.141 0.006 264);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.958 0.005 247.858);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.958 0.005 247.858);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.52 0.01 264);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.942 0.016 264.376);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.904 0.006 264);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.904 0.006 264);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.488 0.243 264.376);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.546 0.215 264.376);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.527 0.154 150.069);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.625 0.162 41.5);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.591 0.224 296.4);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.577 0.245 27.325);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.152 0.01 264.376);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.92 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.24 0.012 264.376);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.95 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(1 0 0 / 8%);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||||
--destructive-foreground: var(--color-red-800);
|
--destructive-foreground: var(--color-red-800);
|
||||||
--success: var(--color-emerald-500);
|
--success: var(--color-emerald-500);
|
||||||
--success-foreground: var(--color-emerald-900);
|
--success-foreground: var(--color-emerald-900);
|
||||||
@@ -49,41 +49,43 @@
|
|||||||
--invert: var(--color-zinc-900);
|
--invert: var(--color-zinc-900);
|
||||||
--invert-foreground: var(--color-zinc-50);
|
--invert-foreground: var(--color-zinc-50);
|
||||||
--focus: var(--color-blue-500);
|
--focus: var(--color-blue-500);
|
||||||
|
--chart-rx: oklch(0.527 0.154 150.069);
|
||||||
|
--chart-tx: oklch(0.546 0.215 264.376);
|
||||||
--focus-foreground: var(--color-blue-900);
|
--focus-foreground: var(--color-blue-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.133 0.011 264.376);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.191 0.011 264.376);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.191 0.011 264.376);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.922 0 0);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.252 0.01 264.376);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.252 0.01 264.376);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.648 0.008 264.376);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.285 0.022 264.376);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.488 0.243 264.376);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.637 0.222 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 151.328);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.713 0.176 47.6);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.714 0.173 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.704 0.191 22.216);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.191 0.011 264.376);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.262 0.014 264.376);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||||
--destructive-foreground: var(--color-red-600);
|
--destructive-foreground: var(--color-red-600);
|
||||||
--success: var(--color-emerald-500);
|
--success: var(--color-emerald-500);
|
||||||
--success-foreground: var(--color-emerald-600);
|
--success-foreground: var(--color-emerald-600);
|
||||||
|
|||||||
Reference in new issue
Block a user