fix(web): KPI в DetailPanel через KpiStatGrid hybrid (stats-12)
Отдельные карточки с icon tile, крупным value и Badge hint — как на дашборде. Списки, агенты, правила. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
@@ -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 ?? '—',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user