Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1639ba40f3 | ||
|
|
26a96bc824 | ||
|
|
4fc5c96e63 |
@@ -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>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ export {
|
||||
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">
|
||||
|
||||
@@ -17,8 +17,10 @@ export {
|
||||
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'
|
||||
|
||||
@@ -2,15 +2,15 @@ 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 — stats-12 visual (icon tile + value + label + footer).
|
||||
* CFDM-compatible: id, label, value, hint?, to?, search?, onSelect?, selected?
|
||||
* EvoBGP: icon?, iconClassName?, footer?, active?, onClick?
|
||||
*
|
||||
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export type KpiStatItem = {
|
||||
@@ -26,6 +26,7 @@ export type KpiStatItem = {
|
||||
active?: boolean
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
variant?: KpiStatVariant
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
@@ -40,7 +41,13 @@ export type KpiStatCard = KpiStatCardData
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
function kpiStatGridClassName(count: number): string {
|
||||
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'
|
||||
@@ -65,19 +72,29 @@ 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 =
|
||||
item.footer ??
|
||||
(item.hint ? (
|
||||
<span className="text-muted-foreground text-xs leading-snug">{item.hint}</span>
|
||||
) : null)
|
||||
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 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
'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,
|
||||
)}
|
||||
>
|
||||
@@ -87,19 +104,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
||||
<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 className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
</div>
|
||||
|
||||
{footer ? <div className="mt-auto w-full">{footer}</div> : null}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Single KPI tile (ReUI stats-12 / dashboard PRO pattern). */
|
||||
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,
|
||||
@@ -110,26 +147,14 @@ export function KpiStatCardTile({
|
||||
className?: string
|
||||
}) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const clickable = Boolean(item.to || onActivate)
|
||||
const selected = isSelected(item)
|
||||
|
||||
const panelClass = cn(
|
||||
'flex h-full flex-col items-start gap-6',
|
||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/30',
|
||||
selected && 'ring-1 ring-primary/30',
|
||||
className,
|
||||
)
|
||||
const panelClass = panelClassName(item, className)
|
||||
|
||||
let panel: ReactNode
|
||||
|
||||
if (item.to) {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link
|
||||
to={item.to}
|
||||
search={item.search}
|
||||
className="flex h-full w-full flex-col items-start gap-6 focus-visible:outline-none"
|
||||
>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
@@ -176,22 +201,22 @@ export function KpiStatCard({
|
||||
|
||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||
return (
|
||||
<section className="@container w-full" aria-label="Загрузка показателей">
|
||||
<div className={cn('grid gap-5', kpiStatGridClassName(count))}>
|
||||
<Frame className="@container w-full">
|
||||
<div className={cn('grid gap-2', kpiCols(count))}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<Frame key={index} className="h-full">
|
||||
<FramePanel className="flex h-full flex-col items-start gap-6">
|
||||
<Skeleton className="size-10.5 rounded-md" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<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="mt-auto h-5 w-32 rounded-full" />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<Skeleton className="h-7 w-16" />
|
||||
</div>
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,10 +230,50 @@ interface KpiStatGridProps {
|
||||
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,
|
||||
@@ -239,18 +304,26 @@ export function KpiStatGrid({
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid gap-5', kpiStatGridClassName(list.length || 1))}>
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile
|
||||
key={kpiStatItemKey(item, index)}
|
||||
item={item}
|
||||
embedded={embedded}
|
||||
/>
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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:
|
||||
|
||||
@@ -18,7 +18,7 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
|
||||
| Зона | 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 | `stats-12` (primary); `card-35` compact strip | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-35 |
|
||||
| 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 |
|
||||
@@ -31,12 +31,26 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
||||
| `KpiStatGrid` | stats-12 KPI tiles |
|
||||
| `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"`
|
||||
|
||||
Reference in New Issue
Block a user