Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1639ba40f3 | ||
|
|
26a96bc824 | ||
|
|
4fc5c96e63 | ||
|
|
3859983ae5 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 43636,
|
||||
"pid": 51184,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1783570299043
|
||||
"startedAt": 1784215907952
|
||||
}
|
||||
|
||||
@@ -22,9 +22,12 @@ alwaysApply: false
|
||||
|------|------|------------|
|
||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
|
||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
||||
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, OpsDashboard, SettingsShell |
|
||||
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
||||
|
||||
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
|
||||
|
||||
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||
|
||||
## Правила
|
||||
@@ -45,8 +48,8 @@ alwaysApply: false
|
||||
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||
|
||||
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
|
||||
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
|
||||
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
|
||||
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
|
||||
|
||||
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||
|
||||
@@ -21,6 +21,7 @@ Thumbs.db
|
||||
|
||||
# Env / secrets (local only)
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
@@ -71,4 +71,6 @@ pnpm --filter @evobgp/web run build
|
||||
|
||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
|
||||
|
||||
UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
||||
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
|
||||
|
||||
UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"use client"
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@evobgp/ui/components/card"
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
interface SettingsCardProps {
|
||||
title: string
|
||||
@@ -22,6 +20,7 @@ interface SettingsCardProps {
|
||||
footerClassName?: string
|
||||
}
|
||||
|
||||
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
|
||||
export function SettingsCard({
|
||||
title,
|
||||
description,
|
||||
@@ -32,28 +31,23 @@ export function SettingsCard({
|
||||
footerClassName,
|
||||
}: SettingsCardProps) {
|
||||
return (
|
||||
<Card className={cn("gap-0 p-0", className)}>
|
||||
{/* Header */}
|
||||
<CardHeader className="gap-0 border-b px-5 py-3">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</FrameHeader>
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className={cn("p-0", contentClassName)}>
|
||||
{children}
|
||||
</CardContent>
|
||||
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
||||
|
||||
{footer ? (
|
||||
<CardFooter
|
||||
className={cn(
|
||||
"justify-end gap-2 border-t px-5 py-3",
|
||||
footerClassName
|
||||
)}
|
||||
>
|
||||
{footer}
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
{footer ? (
|
||||
<FrameFooter
|
||||
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
|
||||
>
|
||||
{footer}
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export interface CountedLineTab {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface CountedLineTabsProps {
|
||||
tabs: CountedLineTab[]
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
className?: string
|
||||
listClassName?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
|
||||
export function CountedLineTabs({
|
||||
tabs,
|
||||
value,
|
||||
onValueChange,
|
||||
className,
|
||||
listClassName,
|
||||
children,
|
||||
}: CountedLineTabsProps) {
|
||||
return (
|
||||
<Tabs value={value} onValueChange={onValueChange} className={className}>
|
||||
<TabsList variant="line" className={cn('gap-5', listClassName)}>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.count !== undefined ? (
|
||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
{tab.count}
|
||||
</span>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{children}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -122,6 +122,7 @@ function buildKpis({
|
||||
iconClassName: 'text-destructive',
|
||||
value: loading ? '—' : String(riskCount),
|
||||
label: 'Риски',
|
||||
variant: riskCount > 0 ? 'destructive' : 'default',
|
||||
footer: (
|
||||
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||
{loading
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
|
||||
export type QuickLinkCardProps = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
description: string
|
||||
to: string
|
||||
search?: Record<string, string>
|
||||
iconClass: string
|
||||
}
|
||||
|
||||
export function DashboardQuickLinkCard({
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
to,
|
||||
search,
|
||||
iconClass,
|
||||
}: QuickLinkCardProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
search={search}
|
||||
className="group block h-full rounded-[inherit] focus-visible:outline-none"
|
||||
aria-label={`${label}: ${description}`}
|
||||
>
|
||||
<Card
|
||||
size="sm"
|
||||
className={cn(
|
||||
'relative isolate h-full overflow-hidden transition-colors',
|
||||
'hover:border-foreground/20',
|
||||
'group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background',
|
||||
)}
|
||||
>
|
||||
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
|
||||
<CardContent className="relative z-10 flex h-full flex-col gap-7.5 p-5">
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background flex size-11 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5',
|
||||
iconClass,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="mt-auto flex flex-col gap-3">
|
||||
<span className="text-foreground block text-sm leading-tight font-medium">{label}</span>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
|
||||
<span className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 group-hover:underline">
|
||||
Перейти
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="size-2.5 shrink-0 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +1,67 @@
|
||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { DashboardQuickLinkCard } from '@/components/dashboard/dashboard-quick-link-card'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit'
|
||||
|
||||
type QuickLink = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
description: string
|
||||
to: string
|
||||
search?: Record<string, string>
|
||||
iconClass: string
|
||||
}
|
||||
|
||||
const LINKS: QuickLink[] = [
|
||||
const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
icon: <Plus aria-hidden />,
|
||||
label: 'Создать модуль',
|
||||
id: 'new-module',
|
||||
title: 'Создать модуль',
|
||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||
to: '/modules/new',
|
||||
iconClass: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
||||
icon: <Plus aria-hidden />,
|
||||
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
||||
},
|
||||
{
|
||||
icon: <Tags aria-hidden />,
|
||||
label: 'BGP-сообщества',
|
||||
id: 'communities',
|
||||
title: 'BGP-сообщества',
|
||||
description: 'Справочник communities для политик экспорта.',
|
||||
to: '/directories',
|
||||
iconClass: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
||||
},
|
||||
{
|
||||
icon: <Network aria-hidden />,
|
||||
label: 'Сеть',
|
||||
id: 'network',
|
||||
title: 'Сеть',
|
||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
||||
to: '/network',
|
||||
search: { tab: 'overview' },
|
||||
iconClass: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
|
||||
icon: <Network aria-hidden />,
|
||||
iconClassName: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
|
||||
},
|
||||
{
|
||||
icon: <Share2 aria-hidden />,
|
||||
label: 'Добавить пира',
|
||||
id: 'add-peer',
|
||||
title: 'Добавить пира',
|
||||
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
||||
to: '/network',
|
||||
search: { tab: 'peers' },
|
||||
iconClass: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
|
||||
},
|
||||
{
|
||||
icon: <Play aria-hidden />,
|
||||
label: 'Деплой',
|
||||
id: 'deploy',
|
||||
title: 'Деплой',
|
||||
description: 'Ревизии конфигурации и применение на нодах.',
|
||||
to: '/operations',
|
||||
search: { tab: 'revisions' },
|
||||
iconClass: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
||||
icon: <Play aria-hidden />,
|
||||
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
||||
},
|
||||
{
|
||||
icon: <Gauge aria-hidden />,
|
||||
label: 'Мониторинг',
|
||||
id: 'monitoring',
|
||||
title: 'Мониторинг',
|
||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||
to: '/monitoring',
|
||||
search: { tab: 'system' },
|
||||
iconClass: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
|
||||
icon: <Gauge aria-hidden />,
|
||||
iconClassName: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
|
||||
},
|
||||
]
|
||||
|
||||
export function DashboardQuickLinks() {
|
||||
return (
|
||||
<PanelCard
|
||||
title="Быстрые действия"
|
||||
<QuickActionGrid
|
||||
actions={ACTIONS}
|
||||
description="Частые переходы к настройке и деплою"
|
||||
className="@container w-full"
|
||||
contentClassName="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
{LINKS.map((link) => (
|
||||
<DashboardQuickLinkCard key={link.label} {...link} />
|
||||
))}
|
||||
</PanelCard>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import type { Table } from '@tanstack/react-table'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
||||
import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName, panelCardInsetClassName } from '@/components/panel-card'
|
||||
import {
|
||||
PanelCard,
|
||||
panelCardContentFlushClassName,
|
||||
panelCardFooterClassName,
|
||||
} from '@/components/panel-card'
|
||||
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'
|
||||
import { FrameFooter } from '@/components/reui/frame'
|
||||
import {
|
||||
DATA_GRID_MESSAGES_RU,
|
||||
DATA_GRID_PAGINATION_RU,
|
||||
@@ -52,9 +57,9 @@ export function DataGridShell<TData extends object>({
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
{showPagination ? (
|
||||
<div className={cn(panelCardFooterClassName, panelCardInsetClassName)}>
|
||||
<FrameFooter className={cn(panelCardFooterClassName)}>
|
||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
||||
</div>
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</DataGrid>
|
||||
)
|
||||
|
||||
@@ -16,12 +16,13 @@ interface DataGridToolbarProps {
|
||||
searchValue: string
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
/** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */
|
||||
filters?: ReactNode
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Search row for data grids (ReUI c-input-group-37 pattern). */
|
||||
/** Search + optional ReUI Filters row for Frame data grids. */
|
||||
export function DataGridToolbar({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
@@ -58,19 +59,19 @@ export function DataGridToolbar({
|
||||
filters
|
||||
) : (
|
||||
<InputGroupButton
|
||||
aria-label="Фильтры"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="pointer-events-none gap-1.5 opacity-0"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
disabled
|
||||
title="Передайте filters={<Filters … />} из @/components/reui/filters"
|
||||
>
|
||||
<ListFilterIcon className="size-3.5" />
|
||||
<ListFilterIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,126 +1,14 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react'
|
||||
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
|
||||
export type KpiStatItem = {
|
||||
id?: string
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
value: ReactNode
|
||||
label: ReactNode
|
||||
footer?: ReactNode
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS =
|
||||
'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
function kpiStatGridClassName(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
}
|
||||
|
||||
function handleCardKeyDown(onClick: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
/** Single KPI tile (ReUI stats-12 / dashboard PRO pattern). */
|
||||
export function KpiStatCard({
|
||||
item,
|
||||
embedded = false,
|
||||
className,
|
||||
}: {
|
||||
item: KpiStatItem
|
||||
embedded?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const clickable = Boolean(item.onClick)
|
||||
|
||||
const panel = (
|
||||
<FramePanel
|
||||
className={cn(
|
||||
'flex h-full flex-col items-start gap-6',
|
||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/30',
|
||||
item.active && 'ring-1 ring-primary/30',
|
||||
className,
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onKeyDown={clickable && item.onClick ? (e) => handleCardKeyDown(item.onClick!, e) : undefined}
|
||||
>
|
||||
{item.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{item.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">{item.value}</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
</div>
|
||||
|
||||
{item.footer ? <div className="mt-auto w-full">{item.footer}</div> : null}
|
||||
</FramePanel>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<Frame className="h-full ring-1 ring-foreground/10">
|
||||
{panel}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
return <Frame className="h-full">{panel}</Frame>
|
||||
}
|
||||
|
||||
export function KpiStatGrid({
|
||||
items,
|
||||
className,
|
||||
embedded = false,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
items: KpiStatItem[]
|
||||
className?: string
|
||||
embedded?: boolean
|
||||
'aria-label'?: string
|
||||
}) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid gap-5', kpiStatGridClassName(items.length))}>
|
||||
{items.map((item, index) => (
|
||||
<KpiStatCard
|
||||
key={item.id ?? (typeof item.label === 'string' ? item.label : `kpi-${index}`)}
|
||||
item={item}
|
||||
embedded={embedded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||
if (item.id) return item.id
|
||||
if (typeof item.label === 'string') return item.label
|
||||
return `kpi-${index}`
|
||||
}
|
||||
/**
|
||||
* Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable.
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export {
|
||||
KpiStatGrid,
|
||||
KpiStatCard,
|
||||
KpiStatCardTile,
|
||||
kpiStatItemKey,
|
||||
type KpiStatItem,
|
||||
type KpiStatCardData,
|
||||
type KpiStatVariant,
|
||||
type OpsKpiCard,
|
||||
} from '@/components/reui-kit/kpi-stat-grid'
|
||||
|
||||
@@ -181,7 +181,7 @@ export function SystemMonitorPopover() {
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="w-80 gap-0! space-y-0! p-0!">
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@evobgp/ui/components/card'
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
/** REUI c-data-grid-19: card shell with default spacing and flush grid body. */
|
||||
export const panelCardClassName = 'gap-0 has-data-[slot=card-footer]:pb-0'
|
||||
/**
|
||||
* Frame shell class constants (legacy Card names kept for DataGridShell / toolbar).
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export const panelCardClassName = 'w-full gap-0 p-0'
|
||||
export const panelCardHeaderClassName = 'border-b'
|
||||
export const panelCardContentFlushClassName = 'px-0'
|
||||
/** Horizontal inset for toolbar / pagination rows (matches edgeCell px-5). */
|
||||
/** Flush body inside FramePanel (toolbar / data-grid). */
|
||||
export const panelCardContentFlushClassName = 'p-0'
|
||||
/** Horizontal inset for toolbar / pagination rows. */
|
||||
export const panelCardInsetClassName = 'px-5 py-3'
|
||||
export const panelCardFooterClassName = 'border-t bg-transparent'
|
||||
|
||||
@@ -45,22 +48,45 @@ export function PanelCard({
|
||||
size = 'default',
|
||||
}: PanelCardProps) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
const spacing = size === 'sm' ? 'sm' : 'default'
|
||||
|
||||
return (
|
||||
<Card size={size} className={cn(panelCardClassName, className)}>
|
||||
{hasHeader ? (
|
||||
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
|
||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
{actions ? <CardAction>{actions}</CardAction> : null}
|
||||
</CardHeader>
|
||||
) : null}
|
||||
{children != null && children !== false ? (
|
||||
<CardContent className={contentClassName}>{children}</CardContent>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
<Frame
|
||||
dense
|
||||
spacing={spacing}
|
||||
className={cn(panelCardClassName, className)}
|
||||
>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
<FrameHeader
|
||||
className={cn(
|
||||
panelCardHeaderClassName,
|
||||
'flex-row items-start justify-between gap-3',
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
{children != null && children !== false ? (
|
||||
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
|
||||
{footer}
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export interface DetailMetricCard {
|
||||
id: string
|
||||
icon: ReactNode
|
||||
label: string
|
||||
description: string
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
interface DetailPanelProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DetailPanelRoot({ children, className }: DetailPanelProps) {
|
||||
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
}
|
||||
|
||||
interface DetailPanelHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
function DetailPanelHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: DetailPanelHeaderProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
{description ? (
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
{children ? <FramePanel className="flex flex-col gap-4">{children}</FramePanel> : null}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
|
||||
return (
|
||||
<div className="@container w-full">
|
||||
<div className="grid gap-4 @2xl:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Frame key={card.id} spacing="sm">
|
||||
<FrameHeader className="px-1! py-1!">
|
||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||
{card.icon}
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
{card.footer}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailPanelSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title?: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
{title ? (
|
||||
<header className="px-1">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export const DetailPanel = Object.assign(DetailPanelRoot, {
|
||||
Header: DetailPanelHeader,
|
||||
Metrics: DetailPanelMetrics,
|
||||
Section: DetailPanelSection,
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
|
||||
export function getActiveFilters(filters: Filter[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = filter
|
||||
if (!values || values.length === 0) return false
|
||||
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => value === null || value === undefined)) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function applyFiltersToData<T>(
|
||||
data: T[],
|
||||
filters: Filter[],
|
||||
getFieldValue: (item: T, field: string) => unknown,
|
||||
): T[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
|
||||
for (const filter of active) {
|
||||
const { field, operator, values } = filter
|
||||
result = result.filter((item) => {
|
||||
const raw = getFieldValue(item, field)
|
||||
const fieldValue = raw != null ? raw : ''
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return values.includes(fieldValue)
|
||||
case 'is_not':
|
||||
return !values.includes(fieldValue)
|
||||
case 'is_any_of':
|
||||
return values.some((value) => fieldValue === value)
|
||||
case 'is_not_any_of':
|
||||
return !values.some((value) => fieldValue === value)
|
||||
case 'contains': {
|
||||
const tokens = values
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
|
||||
)
|
||||
}
|
||||
case 'not_contains':
|
||||
return !values.some((value) =>
|
||||
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
|
||||
)
|
||||
case 'starts_with':
|
||||
return values.some((value) =>
|
||||
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
|
||||
)
|
||||
case 'ends_with':
|
||||
return values.some((value) =>
|
||||
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
|
||||
)
|
||||
case 'empty':
|
||||
return fieldValue === '' || fieldValue == null
|
||||
case 'not_empty':
|
||||
return fieldValue !== '' && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function renderSelectedCount(values: unknown[]) {
|
||||
if (values.length === 0) return 'Выберите…'
|
||||
if (values.length > 1) return `${values.length} выбрано`
|
||||
return null
|
||||
}
|
||||
|
||||
export function renderSingleSelectedLabel(
|
||||
values: unknown[],
|
||||
options: { value: string; label: string }[],
|
||||
) {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
const option = options.find((item) => item.value === values[0])
|
||||
return option?.label ?? String(values[0])
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export {
|
||||
applyFiltersToData,
|
||||
getActiveFilters,
|
||||
renderSelectedCount,
|
||||
renderSingleSelectedLabel,
|
||||
} from './filter-utils'
|
||||
export {
|
||||
ResourcePage,
|
||||
type ResourcePageProps,
|
||||
type ResourcePageTab,
|
||||
} from './resource-page'
|
||||
export {
|
||||
KpiStatGrid,
|
||||
KpiStatCard,
|
||||
KpiStatCardTile,
|
||||
kpiStatItemKey,
|
||||
type KpiStatItem,
|
||||
type KpiStatCardData,
|
||||
type KpiStatCard as KpiStatCardType,
|
||||
type KpiStatVariant,
|
||||
type OpsKpiCard,
|
||||
} from './kpi-stat-grid'
|
||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||
|
||||
/**
|
||||
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export type KpiStatItem = {
|
||||
id?: string
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
hint?: ReactNode
|
||||
to?: string
|
||||
search?: Record<string, unknown>
|
||||
onSelect?: () => void
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
active?: boolean
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
variant?: KpiStatVariant
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
/** CFDM-compatible card shape (id required). */
|
||||
export type KpiStatCardData = KpiStatItem & { id: string }
|
||||
|
||||
/** @deprecated Use KpiStatCardData */
|
||||
export type OpsKpiCard = KpiStatCardData
|
||||
|
||||
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
|
||||
export type KpiStatCard = KpiStatCardData
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||
default: 'text-foreground',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
}
|
||||
|
||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onActivate()
|
||||
}
|
||||
}
|
||||
|
||||
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
|
||||
return item.onClick ?? item.onSelect
|
||||
}
|
||||
|
||||
function isSelected(item: KpiStatItem): boolean {
|
||||
return Boolean(item.selected ?? item.active)
|
||||
}
|
||||
|
||||
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
if (item.footer) return item.footer
|
||||
if (typeof item.hint === 'string') {
|
||||
return (
|
||||
<Badge variant="outline" size="sm">
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (item.hint) return item.hint
|
||||
return null
|
||||
}
|
||||
|
||||
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const footer = resolveFooter(item)
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{item.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{item.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-2xl leading-none font-bold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function panelClassName(item: KpiStatItem, className?: string) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const clickable = Boolean(item.to || onActivate)
|
||||
const selected = isSelected(item)
|
||||
|
||||
return cn(
|
||||
'relative isolate flex h-full flex-col',
|
||||
clickable &&
|
||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||
className,
|
||||
)
|
||||
}
|
||||
|
||||
/** Single KPI tile — used for embedded / standalone contexts. */
|
||||
export function KpiStatCardTile({
|
||||
item,
|
||||
embedded = false,
|
||||
className,
|
||||
}: {
|
||||
item: KpiStatItem
|
||||
embedded?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item, className)
|
||||
|
||||
let panel: ReactNode
|
||||
|
||||
if (item.to) {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
} else if (onActivate) {
|
||||
panel = (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
} else {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||
}
|
||||
|
||||
return <Frame className="h-full">{panel}</Frame>
|
||||
}
|
||||
|
||||
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
|
||||
export function KpiStatCard({
|
||||
item,
|
||||
embedded = false,
|
||||
className,
|
||||
}: {
|
||||
item: KpiStatItem
|
||||
embedded?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
|
||||
}
|
||||
|
||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
<div className={cn('grid gap-2', kpiCols(count))}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<FramePanel key={index} className="flex items-start gap-3">
|
||||
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-7 w-16" />
|
||||
</div>
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
interface KpiStatGridProps {
|
||||
/** EvoBGP primary API */
|
||||
items?: KpiStatItem[]
|
||||
/** CFDM-compatible API */
|
||||
cards?: KpiStatCardData[]
|
||||
isLoading?: boolean
|
||||
emptyMessage?: ReactNode
|
||||
emptyIcon?: ReactNode
|
||||
className?: string
|
||||
skeletonCount?: number
|
||||
/** Wrap each tile in its own Frame (analytics panels). */
|
||||
embedded?: boolean
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item)
|
||||
|
||||
if (item.to) {
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (onActivate) {
|
||||
return (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function KpiStatGrid({
|
||||
items,
|
||||
cards,
|
||||
isLoading = false,
|
||||
emptyMessage,
|
||||
emptyIcon,
|
||||
className,
|
||||
skeletonCount = 4,
|
||||
embedded = false,
|
||||
'aria-label': ariaLabel,
|
||||
}: KpiStatGridProps) {
|
||||
if (isLoading) {
|
||||
return <KpiStatGridSkeleton count={skeletonCount} />
|
||||
}
|
||||
|
||||
const list = items ?? cards ?? []
|
||||
|
||||
if (list.length === 0 && (emptyMessage || emptyIcon)) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full', className)}>
|
||||
<FramePanel className="flex items-center gap-3 p-4">
|
||||
{emptyIcon}
|
||||
{emptyMessage ? (
|
||||
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||
if (item.id) return item.id
|
||||
if (typeof item.label === 'string') return item.label
|
||||
return `kpi-${index}`
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
||||
|
||||
export type OpsKpiCard = KpiStatCardData
|
||||
|
||||
interface OpsDashboardProps {
|
||||
title?: string
|
||||
description?: string
|
||||
kpiCards: OpsKpiCard[]
|
||||
charts: ReactNode
|
||||
queue: ReactNode
|
||||
queueTitle?: string
|
||||
queueDescription?: string
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const rootClassName =
|
||||
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
||||
|
||||
function OpsDashboardSkeleton() {
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
<header className="px-1">
|
||||
<Skeleton className="h-7 w-56" />
|
||||
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||
</header>
|
||||
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||
<div className="grid gap-2 @3xl:grid-cols-2">
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpsDashboard({
|
||||
title = 'Панель управления',
|
||||
description = 'Обзор модулей, сети, задач и состояния плоскости управления',
|
||||
kpiCards,
|
||||
charts,
|
||||
queue,
|
||||
queueTitle = 'Требуют внимания',
|
||||
queueDescription = 'Проблемы BGP, сбои задач и офлайн-спикеры',
|
||||
isLoading = false,
|
||||
}: OpsDashboardProps) {
|
||||
if (isLoading) {
|
||||
return <OpsDashboardSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
<PageHeader title={title} description={description} />
|
||||
|
||||
<section aria-label="Ключевые метрики">
|
||||
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label={queueTitle}>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>{queueTitle}</FrameTitle>
|
||||
<FrameDescription>{queueDescription}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>{queue}</FramePanel>
|
||||
</Frame>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export interface QuickActionItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
to: string
|
||||
search?: Record<string, unknown>
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
}
|
||||
|
||||
interface QuickActionGridProps {
|
||||
actions: QuickActionItem[]
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
}
|
||||
|
||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{action.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||
<Badge variant="outline" size="sm" className="shrink-0">
|
||||
Перейти
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function QuickActionGrid({
|
||||
actions,
|
||||
title = 'Быстрые действия',
|
||||
description,
|
||||
className,
|
||||
}: QuickActionGridProps) {
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
|
||||
{(title || description) && (
|
||||
<FrameHeader>
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</FrameHeader>
|
||||
)}
|
||||
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
||||
{actions.map((action) => (
|
||||
<FramePanel
|
||||
key={action.id}
|
||||
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
||||
>
|
||||
<Link
|
||||
to={action.to}
|
||||
search={action.search}
|
||||
className="focus-visible:outline-none"
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
>
|
||||
<QuickActionBody action={action} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
|
||||
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { applyFiltersToData } from './filter-utils'
|
||||
|
||||
export interface ResourcePageTab {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
export interface ResourcePageProps<T extends object> {
|
||||
title: string
|
||||
description?: string
|
||||
tabs?: ResourcePageTab[]
|
||||
activeTab?: string
|
||||
onTabChange?: (tabId: string) => void
|
||||
tabFilter?: (item: T, tabId: string) => boolean
|
||||
filterFields: FilterFieldConfig[]
|
||||
filters: Filter[]
|
||||
onFiltersChange: (filters: Filter[]) => void
|
||||
onClearFilters?: () => void
|
||||
getFilterFieldValue: (item: T, field: string) => unknown
|
||||
columns: ColumnDef<T, unknown>[]
|
||||
data: T[]
|
||||
getRowId: (row: T) => string
|
||||
isLoading?: boolean
|
||||
isError?: boolean
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
primaryAction?: ReactNode
|
||||
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||
pageSize?: number
|
||||
enableRowSelection?: boolean
|
||||
selectionToolbar?: (ctx: {
|
||||
selectedIds: string[]
|
||||
selectedCount: number
|
||||
clearSelection: () => void
|
||||
}) => ReactNode
|
||||
toolbarExtra?: ReactNode
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
function ResourcePageSkeleton() {
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="mt-1 h-4 w-72" />
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<Skeleton className="h-9 w-full max-w-md" />
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResourcePage<T extends object>({
|
||||
title,
|
||||
description,
|
||||
tabs,
|
||||
activeTab: controlledTab,
|
||||
onTabChange,
|
||||
tabFilter,
|
||||
filterFields,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onClearFilters,
|
||||
getFilterFieldValue,
|
||||
columns,
|
||||
data,
|
||||
getRowId,
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
primaryAction,
|
||||
emptyState,
|
||||
pageSize = 10,
|
||||
enableRowSelection = false,
|
||||
selectionToolbar,
|
||||
toolbarExtra,
|
||||
hideHeader = false,
|
||||
}: ResourcePageProps<T>) {
|
||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||
const activeTab = controlledTab ?? internalTab
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize,
|
||||
})
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
|
||||
)
|
||||
}, [])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||||
result = result.filter((item) => tabFilter(item, activeTab))
|
||||
}
|
||||
return result
|
||||
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
if (!tabs?.length || !tabFilter) return {}
|
||||
const base = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||
const counts: Record<string, number> = {}
|
||||
for (const tab of tabs) {
|
||||
counts[tab.id] =
|
||||
tab.id === 'all'
|
||||
? base.length
|
||||
: base.filter((item) => tabFilter(item, tab.id)).length
|
||||
}
|
||||
return counts
|
||||
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||
[rowSelection],
|
||||
)
|
||||
|
||||
const selectedCount = selectedIds.length
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
getRowId,
|
||||
state: { sorting, rowSelection, pagination },
|
||||
enableRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
})
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(value: string) => {
|
||||
if (onTabChange) onTabChange(value)
|
||||
else setInternalTab(value)
|
||||
resetPagination()
|
||||
},
|
||||
[onTabChange, resetPagination],
|
||||
)
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(next: Filter[]) => {
|
||||
onFiltersChange(next)
|
||||
resetPagination()
|
||||
},
|
||||
[onFiltersChange, resetPagination],
|
||||
)
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onClearFilters?.()
|
||||
resetPagination()
|
||||
}, [onClearFilters, resetPagination])
|
||||
|
||||
const countedTabs = useMemo(
|
||||
() =>
|
||||
(tabs ?? []).map((tab) => ({
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
count: tabCounts[tab.id] ?? tab.count ?? 0,
|
||||
})),
|
||||
[tabs, tabCounts],
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <ResourcePageSkeleton />
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
|
||||
{onRetry ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
Повторить
|
||||
</Button>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0 && emptyState) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
action={emptyState.action}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{selectionToolbar && selectedCount > 0
|
||||
? selectionToolbar({
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
clearSelection,
|
||||
})
|
||||
: null}
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
{!hideHeader ? (
|
||||
<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="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||
<span>{description}</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="tabular-nums">
|
||||
{filteredData.length} записей
|
||||
</span>
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{selectedCount} выбрано</span>
|
||||
</>
|
||||
) : null}
|
||||
</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{primaryAction ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{primaryAction}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
{countedTabs.length > 0 ? (
|
||||
<>
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<CountedLineTabs
|
||||
tabs={countedTabs}
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} выбрано
|
||||
</Badge>
|
||||
) : null}
|
||||
{onClearFilters ? (
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||
|
||||
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
|
||||
to: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */
|
||||
const DEFAULT_TABS: SettingsTabConfig[] = [
|
||||
{
|
||||
id: 'ui',
|
||||
to: '/settings',
|
||||
label: 'Настройки UI',
|
||||
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
id: 'tenant',
|
||||
to: '/tenant-settings',
|
||||
label: 'Настройки BIRD',
|
||||
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsShellProps {
|
||||
title?: string
|
||||
description?: string
|
||||
tabs?: SettingsTabConfig[]
|
||||
}
|
||||
|
||||
export function SettingsShell({
|
||||
title = 'Настройки',
|
||||
description = 'Подключение UI и параметры плоскости управления',
|
||||
tabs = DEFAULT_TABS,
|
||||
}: SettingsShellProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
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>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-5',
|
||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
||||
)}
|
||||
>
|
||||
{tabs.length > 1 ? (
|
||||
<nav
|
||||
aria-label="Разделы настроек"
|
||||
className={cn(
|
||||
'flex gap-1',
|
||||
isMobile
|
||||
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
||||
: 'w-44 shrink-0 flex-col',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = pathname.startsWith(tab.to)
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to={tab.to}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
isMobile && 'shrink-0',
|
||||
!isMobile && 'w-full',
|
||||
isActive
|
||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -32,15 +32,17 @@ function hintToFooter(hint: ReactNode) {
|
||||
}
|
||||
|
||||
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[item.variant ?? 'default'],
|
||||
iconClassName: VARIANT_ICON_CLASS[variant],
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
variant,
|
||||
footer,
|
||||
active: item.active,
|
||||
onClick: item.onClick,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Moon, Sun, SunMoon } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
@@ -9,6 +11,9 @@ import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@evobgp/ui/components/toggle-group'
|
||||
import { Switch } from '@evobgp/ui/components/switch'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import { settingsKeys, settingsQueryOptions } from '@/queries/settings'
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: 'light', label: 'Светлая', icon: Sun },
|
||||
@@ -16,8 +21,27 @@ const THEME_OPTIONS = [
|
||||
{ value: 'system', label: 'Система', icon: SunMoon },
|
||||
] as const
|
||||
|
||||
function parseShowQuickActions(value: unknown): boolean {
|
||||
if (value === false || value === 0 || value === 'false' || value === '0') return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function AppearanceSettingsTab() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const qc = useQueryClient()
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
|
||||
|
||||
const patchMut = useMutation({
|
||||
mutationFn: (payload: Record<string, boolean>) =>
|
||||
apiMutate('/v1/settings', 'PATCH', payload),
|
||||
onSuccess: () => {
|
||||
toast.success('Настройки интерфейса сохранены')
|
||||
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -63,6 +87,31 @@ export function AppearanceSettingsTab() {
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Дашборд"
|
||||
description="Блоки на экране «Обзор»"
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Быстрые действия"
|
||||
description="Показывать KPI-like плитки быстрых переходов под метриками."
|
||||
>
|
||||
<SettingRow
|
||||
title="Быстрые действия"
|
||||
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
|
||||
last
|
||||
>
|
||||
<Switch
|
||||
checked={showQuickActions}
|
||||
disabled={settingsQ.isLoading || patchMut.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
patchMut.mutate({ ui_show_quick_actions: checked })
|
||||
}
|
||||
aria-label="Показывать быстрые действия"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* EvoBGP UI surface contract (ReUI Pro).
|
||||
*
|
||||
* KPI tiles: **stats-12** pattern via `KpiStatGrid` / `SectionCards` (Frame + icon tile + badge).
|
||||
* Panels and data grids: shadcn Card via `PanelCard` / `DataGridCard`.
|
||||
* Ops / list / dashboard / detail / settings: **Frame** only.
|
||||
* KPI tiles: **stats-12** via `KpiStatGrid` / `reui-kit/KpiStatGrid`.
|
||||
* Lists: Frame + DataGrid (`ResourcePage` / `DataGridSection`), not Card shell.
|
||||
*
|
||||
* @see https://reui.io/blocks — stats-12, dashboard-1
|
||||
* @see `.agents/skills/shadcn-react/SKILL.md`
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
* @see https://reui.io/preview/base/dashboard-1
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see docs/ui-design-contract.md
|
||||
*/
|
||||
export const UI_SURFACE = 'card' as const
|
||||
export const UI_SURFACE = 'frame' as const
|
||||
|
||||
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
||||
export const kpiStatGridClassName = '@container w-full'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
@@ -27,13 +27,21 @@ import {
|
||||
overviewRevisionsQueryOptions,
|
||||
overviewSpeakersQueryOptions,
|
||||
} from '@/queries/overview'
|
||||
import { settingsQueryOptions } from '@/queries/settings'
|
||||
|
||||
export const Route = createFileRoute('/_auth/dashboard')({
|
||||
component: DashboardComponent,
|
||||
})
|
||||
|
||||
function parseShowQuickActions(value: unknown): boolean {
|
||||
if (value === false || value === 0 || value === 'false' || value === '0') return false
|
||||
return true
|
||||
}
|
||||
|
||||
function DashboardComponent() {
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
|
||||
|
||||
const results = useQueries({
|
||||
queries: [
|
||||
@@ -86,8 +94,6 @@ function DashboardComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<DashboardQuickLinks />
|
||||
|
||||
{initialLoading ? (
|
||||
<AnalyticsDashboardSkeleton />
|
||||
) : (
|
||||
@@ -99,6 +105,8 @@ function DashboardComponent() {
|
||||
jobs={jobs}
|
||||
/>
|
||||
|
||||
{showQuickActions ? <DashboardQuickLinks /> : null}
|
||||
|
||||
<div className={dashboardMainSidebarClassName}>
|
||||
<div className="xl:col-span-8">
|
||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1455,6 +1455,10 @@ components:
|
||||
description: UTC cron для автоочистки (по умолчанию `0 */6 * * *`).
|
||||
runtime_logs_auto_mode:
|
||||
$ref: "#/components/schemas/RuntimeLogCleanupMode"
|
||||
ui_show_quick_actions:
|
||||
type: boolean
|
||||
description: Показывать блок «Быстрые действия» на дашборде (UI preference).
|
||||
default: true
|
||||
additionalProperties: true
|
||||
|
||||
RevisionDiff:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# UI Design Contract (ops apps)
|
||||
|
||||
Единый контракт для vps-tracker, CFDM и EvoBGP. Surface: **ReUI Frame**. Kit API: `apps/web/src/components/reui-kit/`.
|
||||
|
||||
Карта: [llms.txt](https://reui.io/llms.txt) · [Styling](https://reui.io/docs/styling) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks)
|
||||
|
||||
## Surface
|
||||
|
||||
```ts
|
||||
// apps/web/src/lib/ui-surface.ts
|
||||
export const UI_SURFACE = 'frame' as const
|
||||
```
|
||||
|
||||
Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как shell. Не смешивать Card и Frame на одном ops-экране.
|
||||
|
||||
## Canonical PRO references
|
||||
|
||||
| Зона | Block | Preview |
|
||||
|------|-------|---------|
|
||||
| Shell | `app-shell-12` (+ cmdk/monitor где нужно) | https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7 |
|
||||
| KPI | horizontal compact hybrid (icon left + label/Badge + value ± variant; EvoBGP visual) | https://reui.io/preview/base/stats-12 |
|
||||
| Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 |
|
||||
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||
| Settings | `settings-16` + SettingRow (`settings-7`) | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-7 |
|
||||
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
|
||||
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
|
||||
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
|
||||
|
||||
## Kit API (`reui-kit/`)
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
||||
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
|
||||
| `QuickActionGrid` | KPI-like quick action tiles under KPI (gated by `ui_show_quick_actions`) |
|
||||
| `OpsDashboard` | KPI + charts + attention queue |
|
||||
| `SettingsShell` | settings nav + Outlet |
|
||||
| `DetailPanel` | detail Frame sections |
|
||||
| `filter-utils` | apply/clear ReUI Filters |
|
||||
|
||||
## Dashboard layout
|
||||
|
||||
| App | Section order |
|
||||
|-----|---------------|
|
||||
| EvoBGP / CFDM | KPI → **QuickActionGrid** → charts / rest |
|
||||
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
|
||||
|
||||
Gating: KV `ui_show_quick_actions` in `global_settings` via `PATCH /v1/settings` (default `true`).
|
||||
|
||||
## System monitor
|
||||
|
||||
`SystemMonitorPopover` in app-shell header next to `ModeToggle`. Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
|
||||
|
||||
## MCP workflow
|
||||
|
||||
1. MCP `user-reui` — `search` / `get_block` / `get_component` with `surface: "frame"`
|
||||
2. Cite `previewUrl` + `docsUrl`
|
||||
3. CLI from `apps/web`: `pnpm dlx shadcn@latest add @reui/...`
|
||||
4. Adapt into kit — do not hand-roll KPI/grid/settings rows
|
||||
5. `validate_usage` / `get_audit_checklist`
|
||||
|
||||
Primitives: MCP `plugin-shadcn-shadcn` + `@evobgp/ui`.
|
||||
|
||||
## Spacing
|
||||
|
||||
- Main: `gap-4 md:gap-6`, `px-4 md:px-6`
|
||||
- No `space-y-*` / `space-x-*` — use `flex` + `gap-*`
|
||||
- Max 1 primary CTA per screen
|
||||
- Semantic tokens only (`variant="success"|"info"|"warning"`) — no raw `bg-emerald-*`
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Card as ops list/dashboard shell
|
||||
- Hand-rolled data tables when ReUI DataGrid exists
|
||||
- Hand-rolled KPI grids when `KpiStatGrid` exists
|
||||
- Mixing Card and Frame surfaces on one ops screen
|
||||
@@ -14,6 +14,7 @@ Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
Reference in New Issue
Block a user