feat(web): align ops screens with ReUI PRO kit and Frame surface

OpsDashboard+afterKpi на dashboard; FrameDataGrid вместо DataGridCard; KpiStatGrid вместо SectionCards; SettingsShell без Separator. Preview: dashboard-1, stats-12, data-grid-filtering-2, settings-16.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-31 12:15:59 +07:00
co-authored by Cursor
parent 53ce80c9ff
commit c3369059af
20 changed files with 216 additions and 198 deletions
@@ -4,7 +4,7 @@ import { Plus, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
import { DataGridCard } from '@/components/data-grid-shell'
import { FrameDataGrid } from '@/components/reui-kit'
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
import { QueryState } from '@/components/query-state'
@@ -47,7 +47,7 @@ export function AccessApiKeysCard({
return (
<>
<DataGridCard
<FrameDataGrid
title="API-ключи"
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
actions={
@@ -85,7 +85,7 @@ export function AccessApiKeysCard({
/>
)}
</QueryState>
</DataGridCard>
</FrameDataGrid>
<ApiKeyCreateDialog
open={createOpen}
@@ -136,6 +136,25 @@ function buildKpis({
]
}
export function buildDashboardKpiCards({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
return buildKpis({ modules, peers, speakers, jobs, loading }).map((card) => ({
...card,
id: card.id ?? String(card.label),
}))
}
export function DashboardKpiGrid({
modules,
peers,
+15 -11
View File
@@ -5,10 +5,10 @@ import { cn } from '@evobgp/ui/lib/utils'
import { DataGridToolbar } from '@/components/data-grid-toolbar'
import {
PanelCard,
panelCardContentFlushClassName,
panelCardFooterClassName,
} from '@/components/panel-card'
import { FrameDataGrid } from '@/components/reui-kit/frame-data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
@@ -65,25 +65,29 @@ export function DataGridShell<TData extends object>({
)
}
interface DataGridCardProps {
title?: string
description?: string
/** @deprecated Prefer FrameDataGrid from @/components/reui-kit */
export function DataGridCard({
title,
description,
actions,
children,
className,
}: {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
children: ReactNode
className?: string
}
export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) {
}) {
return (
<PanelCard
<FrameDataGrid
title={title}
description={description}
actions={actions}
className={className}
contentClassName={panelCardContentFlushClassName}
>
{children}
</PanelCard>
<div className={panelCardContentFlushClassName}>{children}</div>
</FrameDataGrid>
)
}
@@ -4,7 +4,8 @@ import { useNavigate } from '@tanstack/react-router'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridCard, DataGridSection } from '@/components/data-grid-shell'
import { FrameDataGrid } from '@/components/reui-kit'
import { DataGridSection } from '@/components/data-grid-shell'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
@@ -112,7 +113,7 @@ export function LookupMatchesGrid({
})
return (
<DataGridCard
<FrameDataGrid
title="Совпадения"
description="Entries и snapshots · клик по строке открывает модуль"
>
@@ -128,6 +129,6 @@ export function LookupMatchesGrid({
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
}
/>
</DataGridCard>
</FrameDataGrid>
)
}
@@ -5,7 +5,7 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridCard } from '@/components/data-grid-shell'
import { FrameDataGrid } from '@/components/reui-kit'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
@@ -128,7 +128,7 @@ export function ModuleEntriesSection({
return (
<>
<DataGridCard
<FrameDataGrid
title={meta.title}
description={meta.description}
actions={
@@ -166,7 +166,7 @@ export function ModuleEntriesSection({
/>
)}
</QueryState>
</DataGridCard>
</FrameDataGrid>
{mod.type === 'DOMAINS' ? (
<ModuleDomainEntryDialog
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import { DataGridCard } from '@/components/data-grid-shell'
import { FrameDataGrid } from '@/components/reui-kit'
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
@@ -51,7 +51,7 @@ export function OperationsJobsCard({
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
return (
<DataGridCard title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
<FrameDataGrid title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
<div className="px-5 pt-3">
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
<TabsList variant="line" className="w-full justify-start gap-6">
@@ -81,6 +81,6 @@ export function OperationsJobsCard({
/>
)}
</QueryState>
</DataGridCard>
</FrameDataGrid>
)
}
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
/**
* Frame shell for list/grid sections (replaces DataGridCard Card-named API).
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/docs/components/base/frame
*/
export function FrameDataGrid({
title,
description,
actions,
children,
className,
}: {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
children: ReactNode
className?: string
}) {
const hasHeader = Boolean(title || description || actions)
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3 border-b">
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
) : null}
<div className="p-0">{children}</div>
</FramePanel>
</Frame>
)
}
@@ -22,5 +22,6 @@ export {
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { FrameDataGrid } from './frame-data-grid'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
@@ -16,10 +16,13 @@ interface OpsDashboardProps {
title?: string
description?: string
kpiCards: OpsKpiCard[]
/** Slot after KPI (QuickActionGrid). Preview: stats-12 · card-12 */
afterKpi?: ReactNode
charts: ReactNode
queue: ReactNode
queueTitle?: string
queueDescription?: string
headerActions?: ReactNode
isLoading?: boolean
}
@@ -43,14 +46,21 @@ function OpsDashboardSkeleton() {
)
}
/**
* Ops dashboard: KPI → optional Quick Actions → charts → attention queue.
* @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/stats-12
*/
export function OpsDashboard({
title = 'Панель управления',
description = 'Обзор модулей, сети, задач и состояния плоскости управления',
kpiCards,
afterKpi,
charts,
queue,
queueTitle = 'Требуют внимания',
queueDescription = 'Проблемы BGP, сбои задач и офлайн-спикеры',
headerActions,
isLoading = false,
}: OpsDashboardProps) {
if (isLoading) {
@@ -59,12 +69,14 @@ export function OpsDashboard({
return (
<div className={rootClassName}>
<PageHeader title={title} description={description} />
<PageHeader title={title} description={description} actions={headerActions} />
<section aria-label="Ключевые метрики">
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
</section>
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
<section
aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
@@ -6,7 +6,6 @@ import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
import { cn } from '@evobgp/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Separator } from '@evobgp/ui/components/separator'
export interface SettingsTabConfig {
id: string
@@ -37,6 +36,11 @@ interface SettingsShellProps {
tabs?: SettingsTabConfig[]
}
/**
* Settings shell — Frame surface, no hairline Separator under header.
* @see https://reui.io/preview/base/settings-16
* @see https://reui.io/preview/base/settings-3
*/
export function SettingsShell({
title = 'Настройки',
description = 'Подключение UI и параметры плоскости управления',
@@ -48,10 +52,7 @@ export function SettingsShell({
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<div className="flex flex-col gap-3">
<PageHeader title={title} description={description} />
<Separator />
</div>
<PageHeader title={title} description={description} />
<div
className={cn(
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import { DataGridCard } from '@/components/data-grid-shell'
import { FrameDataGrid } from '@/components/reui-kit'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
@@ -45,7 +45,7 @@ export function ScheduleJobsCard({
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
return (
<DataGridCard title="Задачи" description="Последние задачи из API">
<FrameDataGrid title="Задачи" description="Последние задачи из API">
<div className="px-5 pt-3">
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
<TabsList variant="line" className="w-full justify-start gap-6">
@@ -72,6 +72,6 @@ export function ScheduleJobsCard({
/>
)}
</QueryState>
</DataGridCard>
</FrameDataGrid>
)
}
-61
View File
@@ -1,61 +0,0 @@
import type { ReactElement, ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
export interface SectionCardItem {
label: ReactNode
value: string | number | ReactElement
hint?: ReactNode
icon?: ReactNode
badge?: ReactNode
variant?: 'default' | 'warning' | 'destructive'
active?: boolean
onClick?: () => void
}
const VARIANT_ICON_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: 'text-muted-foreground',
warning: 'text-warning',
destructive: 'text-destructive',
}
function hintToFooter(hint: ReactNode) {
if (typeof hint === 'string') {
return (
<Badge variant="outline" size="sm">
{hint}
</Badge>
)
}
return hint
}
function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
const variant = item.variant ?? 'default'
const footer =
item.badge ?? (item.hint ? hintToFooter(item.hint) : undefined)
return {
id: typeof item.label === 'string' ? item.label : `section-${index}`,
icon: item.icon,
iconClassName: VARIANT_ICON_CLASS[variant],
value: item.value,
label: item.label,
variant,
footer,
active: item.active,
onClick: item.onClick,
}
}
/** KPI row for route sections — ReUI stats-12 / dashboard PRO pattern. */
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
return (
<KpiStatGrid
items={items.map(toKpiStatItem)}
className={className}
aria-label="Показатели"
/>
)
}