fix(web): KPI в DetailPanel через KpiStatGrid hybrid (stats-12)
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m36s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Отдельные карточки с icon tile, крупным value и Badge hint — как на дашборде. Списки, агенты, правила.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 23:44:44 +07:00
co-authored by Cursor
parent 35051babb5
commit af4c0303cb
4 changed files with 57 additions and 25 deletions
@@ -6,13 +6,23 @@ import {
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import {
KpiStatGrid,
type KpiStatCard,
type KpiStatVariant,
} from '@/components/reui-kit/kpi-stat-grid'
import { cn } from '@evofw/ui/lib/utils' import { cn } from '@evofw/ui/lib/utils'
export interface DetailMetricCard { export interface DetailMetricCard {
id: string id: string
icon: ReactNode icon: ReactNode
label: string label: string
/** Primary numeric / text value (shown large). */
description: string description: string
/** Outline badge on the right (hybrid KPI). */
hint?: string
iconClassName?: string
variant?: KpiStatVariant
footer?: ReactNode footer?: ReactNode
} }
@@ -22,7 +32,11 @@ interface DetailPanelProps {
} }
function DetailPanelRoot({ children, className }: DetailPanelProps) { function DetailPanelRoot({ children, className }: DetailPanelProps) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div> return (
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>
{children}
</div>
)
} }
interface DetailPanelHeaderProps { interface DetailPanelHeaderProps {
@@ -53,34 +67,29 @@ function DetailPanelHeader({
</div> </div>
) : null} ) : null}
</FrameHeader> </FrameHeader>
{children ? <FramePanel className="flex flex-col gap-4">{children}</FramePanel> : null} {children ? (
<FramePanel className="flex flex-col gap-4">{children}</FramePanel>
) : null}
</Frame> </Frame>
) )
} }
/** /**
* Compact KPI strip — stats-7 pattern (single Frame, divided cells). * Hybrid KPI cards — KpiStatGrid / stats-12.
* Preview: https://reui.io/preview/base/stats-7 * Preview: https://reui.io/preview/base/stats-12
*/ */
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) { function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
return ( const kpiCards: KpiStatCard[] = cards.map((card) => ({
<Frame dense spacing="sm" className="w-full"> id: card.id,
<FramePanel className="grid grid-cols-1 divide-y p-0 sm:grid-cols-3 sm:divide-x sm:divide-y-0"> label: card.label,
{cards.map((card) => ( value: card.description,
<div key={card.id} className="flex flex-col gap-1.5 px-4 py-3"> icon: card.icon,
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium [&_svg]:size-3.5"> iconClassName: card.iconClassName,
{card.icon} hint: card.hint,
<span>{card.label}</span> variant: card.variant,
</div> footer: card.footer,
<p className="text-foreground text-xl font-semibold tracking-tight tabular-nums"> }))
{card.description} return <KpiStatGrid cards={kpiCards} />
</p>
{card.footer}
</div>
))}
</FramePanel>
</Frame>
)
} }
function DetailPanelSection({ function DetailPanelSection({
+7
View File
@@ -226,24 +226,31 @@ function AgentDetailPage() {
{ {
id: 'dropped', id: 'dropped',
icon: <BanIcon aria-hidden />, icon: <BanIcon aria-hidden />,
iconClassName: 'text-warning',
label: 'Dropped', label: 'Dropped',
description: String(a.last_apply_packets_dropped ?? 0), description: String(a.last_apply_packets_dropped ?? 0),
hint: 'сумма counters',
variant: 'warning',
}, },
{ {
id: 'accepted', id: 'accepted',
icon: <CheckCircle2Icon aria-hidden />, icon: <CheckCircle2Icon aria-hidden />,
iconClassName: 'text-success',
label: 'Accepted', label: 'Accepted',
description: String(a.last_apply_packets_accepted ?? 0), description: String(a.last_apply_packets_accepted ?? 0),
hint: 'сумма counters',
}, },
{ {
id: 'kernel', id: 'kernel',
icon: <CpuIcon aria-hidden />, icon: <CpuIcon aria-hidden />,
iconClassName: 'text-info',
label: 'Kernel', label: 'Kernel',
description: a.last_apply_kernel_method ?? '—', description: a.last_apply_kernel_method ?? '—',
}, },
{ {
id: 'apply', id: 'apply',
icon: <ClockIcon aria-hidden />, icon: <ClockIcon aria-hidden />,
iconClassName: 'text-primary',
label: 'Last apply', label: 'Last apply',
description: a.last_apply_at ?? '—', description: a.last_apply_at ?? '—',
}, },
+14 -3
View File
@@ -82,7 +82,7 @@ type ListItem = {
* Lists — master-detail (ReUI PRO composition). * Lists — master-detail (ReUI PRO composition).
* Catalog: https://reui.io/preview/base/list-9 * Catalog: https://reui.io/preview/base/list-9
* Entries: https://reui.io/preview/base/data-grid-filtering-2 * Entries: https://reui.io/preview/base/data-grid-filtering-2
* KPI: https://reui.io/preview/base/stats-7 * KPI: https://reui.io/preview/base/stats-12 (KpiStatGrid hybrid)
* Sheet: https://reui.io/preview/base/sheet-1 · sheet-8 * Sheet: https://reui.io/preview/base/sheet-1 · sheet-8
* Empty: https://reui.io/preview/base/empty-state-12 * Empty: https://reui.io/preview/base/empty-state-12
*/ */
@@ -461,27 +461,38 @@ function ListsPage() {
{ {
id: 'count', id: 'count',
icon: <HashIcon aria-hidden />, icon: <HashIcon aria-hidden />,
iconClassName: 'text-info',
label: 'Записей', label: 'Записей',
description: String(entryItems.length), description: String(entryItems.length),
hint: manual ? 'ручной список' : 'внешний источник',
}, },
{ {
id: 'type', id: 'type',
icon: <TagIcon aria-hidden />, icon: <TagIcon aria-hidden />,
iconClassName: 'text-primary',
label: 'Источник', label: 'Источник',
description: description:
detail.type === 'json_url'
? 'JSON'
: detail.type === 'evobgp_community'
? 'EvoBGP'
: 'Ручной',
hint:
detail.type === 'json_url' detail.type === 'json_url'
? 'JSON по URL' ? 'JSON по URL'
: detail.type === 'evobgp_community' : detail.type === 'evobgp_community'
? 'EvoBGP community' ? 'community prefixes'
: 'Ручной', : 'IP / CIDR / домен / список',
}, },
{ {
id: 'cidrs', id: 'cidrs',
icon: <RefreshCwIcon aria-hidden />, icon: <RefreshCwIcon aria-hidden />,
iconClassName: 'text-success',
label: 'CIDR в политике', label: 'CIDR в политике',
description: String( description: String(
detail.entry_count ?? detail.entries.length, detail.entry_count ?? detail.entries.length,
), ),
hint: 'materialized',
}, },
]} ]}
/> />
@@ -410,20 +410,25 @@ function PolicySetDetailPage() {
{ {
id: 'rules', id: 'rules',
icon: <ShieldIcon aria-hidden />, icon: <ShieldIcon aria-hidden />,
iconClassName: 'text-info',
label: 'Правила', label: 'Правила',
description: String(set.rules_count ?? rules.length), description: String(set.rules_count ?? rules.length),
}, },
{ {
id: 'agents', id: 'agents',
icon: <UsersIcon aria-hidden />, icon: <UsersIcon aria-hidden />,
iconClassName: 'text-primary',
label: 'Агенты', label: 'Агенты',
description: String(set.agents_count ?? assignedIds.length), description: String(set.agents_count ?? assignedIds.length),
hint: 'назначено',
}, },
{ {
id: 'status', id: 'status',
icon: <ListIcon aria-hidden />, icon: <ListIcon aria-hidden />,
iconClassName: set.enabled ? 'text-success' : 'text-muted-foreground',
label: 'Статус', label: 'Статус',
description: set.enabled ? 'Включён' : 'Выключен', description: set.enabled ? 'Включён' : 'Выключен',
hint: set.enabled ? 'active' : 'disabled',
}, },
]} ]}
/> />