2 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 f4259246d0 feat(web): панель и статистика по доскам макета
quality / changes (push) Successful in 10s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 39s
quality / web (push) Successful in 58s
CD / quality (push) Successful in 1m59s
CD / publish (push) Successful in 1m46s
Заголовок страницы как на досках (20px), панель: быстрые действия, график и таблицы «Агенты» и «Требует внимания» в раскладке MikrotikManager (новый SimpleGrid). Статистика: пояснение, KPI с подсказками и линейные вкладки «Активность» и «Samples».

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 19:44:07 +07:00
DenozordecandClaude Sonnet 5.5 269f94edcf fix(web): ширина контента и цветовая гамма как в MikrotikManager
Контент на всю ширину с отступами p-6 и gap-5 вместо max-w 1480; токены светлой и тёмной тем (фон, карточки, muted, accent, border, ring, sidebar, chart) взяты из globals.css MikrotikManager.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 19:36:56 +07:00
9 changed files with 362 additions and 274 deletions

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>
+7 -9
View File
@@ -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>
) )
} }
+1 -1
View File
@@ -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
View File
@@ -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>
+75 -58
View File
@@ -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>
) )
} }
+41 -39
View File
@@ -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);