feat(web): привести «Внимание» и KPI к макету и EvoBGP
- монитор в шапке: пилюля «Внимание ●N ●M», в попапе сводка сбой / предупреждения / в норме, список проблем со ссылками, плитки метрик с полосами и время обновления - KPI одной полосой в Frame с разделителями 1px, как доски макета Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
06d767fde7
commit
148ef8efb7
2 files changed
+199
-203
No files matched your search
@@ -1,80 +1,32 @@
|
|||||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import { Activity, ArrowRight } from 'lucide-react'
|
||||||
Activity,
|
|
||||||
Bell,
|
|
||||||
ChevronRight,
|
|
||||||
RefreshCw,
|
|
||||||
Server,
|
|
||||||
Wallet,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
|
||||||
import { Progress } from '@cfdm/ui/components/progress'
|
|
||||||
|
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
|
|
||||||
type MonitorMetric = {
|
type Tone = 'er' | 'wn' | 'ok'
|
||||||
|
|
||||||
|
/** Одна проверка монитора: строка в списке проблем (если не ok) и плитка с полосой. */
|
||||||
|
type MonitorCheck = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
unit: string
|
|
||||||
percent: number
|
percent: number
|
||||||
icon: ReactNode
|
tone: Tone
|
||||||
tone: 'success' | 'warning' | 'destructive' | 'info'
|
title: string
|
||||||
alert: boolean
|
text: string
|
||||||
|
to: '/sync-journal' | '/settings/notifications' | '/accounts' | '/vps' | '/dashboard'
|
||||||
}
|
}
|
||||||
|
|
||||||
function toneColor(tone: MonitorMetric['tone']) {
|
const TONE_TEXT: Record<Tone, string> = { er: 'text-destructive', wn: 'text-warning', ok: 'text-success' }
|
||||||
switch (tone) {
|
const TONE_BG: Record<Tone, string> = { er: 'bg-destructive', wn: 'bg-warning', ok: 'bg-success' }
|
||||||
case 'success':
|
|
||||||
return 'var(--color-success)'
|
|
||||||
case 'warning':
|
|
||||||
return 'var(--color-warning)'
|
|
||||||
case 'destructive':
|
|
||||||
return 'var(--color-destructive)'
|
|
||||||
default:
|
|
||||||
return 'var(--color-info)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
|
||||||
const color = toneColor(metric.tone)
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 p-3">
|
|
||||||
<div className="flex items-center justify-between gap-1">
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<Item
|
|
||||||
className="flex size-5 shrink-0 items-center justify-center p-0"
|
|
||||||
style={{ backgroundColor: `${color}18` }}
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
|
|
||||||
{metric.icon}
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
|
||||||
{metric.value}
|
|
||||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={metric.percent}
|
|
||||||
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
|
||||||
style={{ '--bar-color': color } as CSSProperties}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SyncStatusRow = {
|
type SyncStatusRow = {
|
||||||
accountId?: string
|
accountId?: string
|
||||||
@@ -127,7 +79,7 @@ export function countRecentFailedNotifications(
|
|||||||
}).length
|
}).length
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */
|
/** Центр состояния в шапке — та же разметка, что «Внимание» в макете и EvoBGP. */
|
||||||
export function SystemMonitorPopover() {
|
export function SystemMonitorPopover() {
|
||||||
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
|
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
|
||||||
const snapQ = useQuery({ ...snapshotQueryOptions(), refetchInterval: 30_000 })
|
const snapQ = useQuery({ ...snapshotQueryOptions(), refetchInterval: 30_000 })
|
||||||
@@ -144,84 +96,87 @@ export function SystemMonitorPopover() {
|
|||||||
|
|
||||||
const stats = statsQ.data
|
const stats = statsQ.data
|
||||||
const vps = snapQ.data?.vps ?? []
|
const vps = snapQ.data?.vps ?? []
|
||||||
const downCount = vps.filter((v) => {
|
const monitored = vps.filter((v) => (v as { monitoringEnabled?: boolean }).monitoringEnabled).length
|
||||||
const status = (v as { lastHealthStatus?: string }).lastHealthStatus
|
const downCount = vps.filter((v) => (v as { lastHealthStatus?: string }).lastHealthStatus === 'down').length
|
||||||
return status === 'down'
|
|
||||||
}).length
|
|
||||||
const issuesCount = stats?.issuesCount ?? 0
|
const issuesCount = stats?.issuesCount ?? 0
|
||||||
const runwayDays = stats?.minRunwayDays
|
const runwayDays = stats?.minRunwayDays
|
||||||
const runwayLow = runwayDays != null && runwayDays < 14
|
const runwayLow = runwayDays != null && runwayDays < 14
|
||||||
const lowBalance = (stats?.lowBalanceAccountCount ?? 0) > 0
|
const lowBalance = (stats?.lowBalanceAccountCount ?? 0) > 0
|
||||||
const staleSync =
|
const staleSync = (stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
|
||||||
(stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
|
|
||||||
const failedSyncCount = countCurrentSyncFailures(syncQ.data ?? [])
|
const failedSyncCount = countCurrentSyncFailures(syncQ.data ?? [])
|
||||||
const recentSyncFailed = failedSyncCount > 0
|
|
||||||
const syncAlert = staleSync || recentSyncFailed
|
|
||||||
const failedNotifications = countRecentFailedNotifications(notifyQ.data ?? [])
|
const failedNotifications = countRecentFailedNotifications(notifyQ.data ?? [])
|
||||||
const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data)
|
const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data)
|
||||||
|
const updatedAt = Math.max(statsQ.dataUpdatedAt, snapQ.dataUpdatedAt)
|
||||||
|
|
||||||
const metrics = useMemo<MonitorMetric[]>(
|
const checks: MonitorCheck[] = [
|
||||||
() => [
|
{
|
||||||
{
|
id: 'sync',
|
||||||
id: 'sync',
|
label: 'Синк',
|
||||||
label: 'Синк',
|
value: failedSyncCount > 0 ? String(failedSyncCount) : staleSync ? 'устарел' : 'OK',
|
||||||
value: recentSyncFailed ? String(failedSyncCount) : syncAlert ? '!' : 'OK',
|
percent: failedSyncCount > 0 || staleSync ? 35 : 100,
|
||||||
unit: '',
|
tone: failedSyncCount > 0 ? 'er' : staleSync ? 'wn' : 'ok',
|
||||||
percent: syncAlert ? 35 : 100,
|
title: failedSyncCount > 0 ? 'Ошибка синхронизации' : 'Синк давно не выполнялся',
|
||||||
icon: <RefreshCw aria-hidden />,
|
text:
|
||||||
tone: syncAlert ? (recentSyncFailed ? 'destructive' : 'warning') : 'success',
|
failedSyncCount > 0
|
||||||
alert: syncAlert,
|
? `${failedSyncCount} акк. с ошибкой последнего синка`
|
||||||
},
|
: 'Нет успешного синка за 24 ч',
|
||||||
{
|
to: '/sync-journal',
|
||||||
id: 'notifications',
|
},
|
||||||
label: 'Уведомления',
|
{
|
||||||
value: String(failedNotifications),
|
id: 'notifications',
|
||||||
unit: 'шт.',
|
label: 'Уведомления',
|
||||||
percent: Math.min(100, failedNotifications * 15),
|
value: `${failedNotifications} шт.`,
|
||||||
icon: <Bell aria-hidden />,
|
percent: Math.min(100, failedNotifications * 15),
|
||||||
tone: failedNotifications > 0 ? 'warning' : 'success',
|
tone: failedNotifications > 0 ? 'wn' : 'ok',
|
||||||
alert: failedNotifications > 0,
|
title: 'Уведомления не доставлены',
|
||||||
},
|
text: `${failedNotifications} за 24 ч — проверьте Telegram / webhook`,
|
||||||
{
|
to: '/settings/notifications',
|
||||||
id: 'runway',
|
},
|
||||||
label: 'Запас дней',
|
{
|
||||||
value: runwayDays != null ? String(runwayDays) : '—',
|
id: 'runway',
|
||||||
unit: runwayDays != null ? 'дн' : '',
|
label: 'Запас дней',
|
||||||
percent:
|
value: runwayDays != null ? `${runwayDays} дн` : '—',
|
||||||
runwayDays == null
|
percent: runwayDays == null ? 0 : Math.min(100, Math.round((runwayDays / 30) * 100)),
|
||||||
? 0
|
tone: runwayLow || lowBalance ? 'wn' : 'ok',
|
||||||
: Math.min(100, Math.round((runwayDays / 30) * 100)),
|
title: lowBalance ? 'Низкий баланс аккаунта' : 'Мало денег у хостера',
|
||||||
icon: <Wallet aria-hidden />,
|
text: runwayDays != null ? `Хватит примерно на ${runwayDays} дн` : 'Баланс ниже порога',
|
||||||
tone: runwayLow || lowBalance ? 'warning' : 'success',
|
to: '/accounts',
|
||||||
alert: runwayLow || lowBalance,
|
},
|
||||||
},
|
{
|
||||||
{
|
id: 'vps-down',
|
||||||
id: 'vps-down',
|
label: 'VPS down',
|
||||||
label: 'VPS down',
|
value: monitored ? `${downCount} из ${monitored}` : String(downCount),
|
||||||
value: String(downCount),
|
percent: monitored ? Math.round(((monitored - downCount) / monitored) * 100) : 100,
|
||||||
unit: 'шт.',
|
tone: downCount > 0 ? 'er' : 'ok',
|
||||||
percent: Math.min(100, downCount * 25),
|
title: 'VPS недоступны',
|
||||||
icon: <Server aria-hidden />,
|
text: `${downCount} не отвечают на uptime-проверку`,
|
||||||
tone: downCount > 0 ? 'destructive' : 'success',
|
to: '/vps',
|
||||||
alert: downCount > 0,
|
},
|
||||||
},
|
]
|
||||||
],
|
const problems: Omit<MonitorCheck, 'label' | 'value' | 'percent'>[] = [
|
||||||
[
|
...(apiOk
|
||||||
downCount,
|
? []
|
||||||
failedNotifications,
|
: [{ id: 'api', tone: 'er' as const, title: 'API недоступно', text: 'Нет ответа от сервера', to: '/dashboard' as const }]),
|
||||||
failedSyncCount,
|
...checks.filter((c) => c.tone !== 'ok'),
|
||||||
lowBalance,
|
...(issuesCount > 0
|
||||||
recentSyncFailed,
|
? [
|
||||||
runwayDays,
|
{
|
||||||
runwayLow,
|
id: 'inventory',
|
||||||
syncAlert,
|
tone: 'wn' as const,
|
||||||
],
|
title: 'Проблемы инвентаря',
|
||||||
)
|
text: `${issuesCount} категор. на дашборде`,
|
||||||
|
to: '/dashboard' as const,
|
||||||
const errorCount = metrics.filter((m) => m.alert && m.tone === 'destructive').length + (apiOk ? 0 : 1)
|
},
|
||||||
const warningCount = metrics.filter((m) => m.alert && m.tone === 'warning').length
|
]
|
||||||
const spiking = errorCount + warningCount > 0
|
: []),
|
||||||
const statusLabel = spiking ? 'Внимание' : 'Норма'
|
]
|
||||||
|
const errors = problems.filter((p) => p.tone === 'er').length
|
||||||
|
const warnings = problems.length - errors
|
||||||
|
const okCount = checks.filter((c) => c.tone === 'ok').length
|
||||||
|
const status: Tone = errors > 0 ? 'er' : warnings > 0 ? 'wn' : 'ok'
|
||||||
|
const statusLabel = status === 'er' ? 'Сбой' : status === 'wn' ? 'Внимание' : 'Норма'
|
||||||
|
// Кнопка подсвечена, пока есть что смотреть; в норме — спокойный outline.
|
||||||
|
const alert = status !== 'ok'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -229,78 +184,106 @@ export function SystemMonitorPopover() {
|
|||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
aria-label={`Монитор VPS Tracker: ${statusLabel.toLowerCase()}, ошибок ${errorCount}, предупреждений ${warningCount}`}
|
type="button"
|
||||||
|
aria-label={`Монитор VPS Tracker: ${statusLabel.toLowerCase()}, ${errors} сбой, ${warnings} предупреждения`}
|
||||||
|
className={cn(
|
||||||
|
'relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0',
|
||||||
|
alert && 'border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Activity
|
<Activity className={cn('size-4', alert ? 'text-warning' : 'text-success')} aria-hidden />
|
||||||
aria-hidden
|
<span
|
||||||
className={cn(
|
className={cn('hidden text-sm font-medium min-[1000px]:inline', alert ? 'text-warning' : 'text-success')}
|
||||||
errorCount > 0 ? 'text-destructive' : warningCount > 0 ? 'text-warning' : 'text-muted-foreground',
|
>
|
||||||
)}
|
{alert ? 'Внимание' : 'Норма'}
|
||||||
/>
|
</span>
|
||||||
<span className="hidden sm:inline">{statusLabel}</span>
|
{alert ? (
|
||||||
{errorCount > 0 ? (
|
<>
|
||||||
<Badge variant="destructive-light" size="sm" className="tabular-nums">
|
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
|
||||||
{errorCount}
|
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
||||||
</Badge>
|
{errors}
|
||||||
) : null}
|
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
||||||
{warningCount > 0 ? (
|
{warnings}
|
||||||
<Badge variant="warning-light" size="sm" className="tabular-nums">
|
</span>
|
||||||
{warningCount}
|
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
||||||
</Badge>
|
{problems.length}
|
||||||
) : null}
|
</span>
|
||||||
{!spiking ? (
|
<span
|
||||||
<Badge variant="success-light" size="sm">
|
className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden"
|
||||||
OK
|
aria-hidden
|
||||||
</Badge>
|
/>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
||||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
||||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
<div className="grid leading-tight">
|
||||||
<span className="text-foreground text-xs font-medium">Монитор VPS Tracker</span>
|
<b className="text-sm font-semibold">Монитор VPS Tracker</b>
|
||||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
<span className="text-muted-foreground text-xs">
|
||||||
{new Date().toLocaleTimeString('ru-RU')}
|
{updatedAt ? `обновлено ${new Date(updatedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}` : 'загрузка…'}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={status === 'er' ? 'destructive-light' : status === 'wn' ? 'warning-light' : 'success-light'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
{metrics.map((metric, i) => (
|
{[
|
||||||
<div
|
{ n: errors, l: 'сбой', t: 'er' as const },
|
||||||
key={metric.id}
|
{ n: warnings, l: 'предупреждения', t: 'wn' as const },
|
||||||
className={cn(
|
{ n: okCount, l: 'в норме', t: 'ok' as const },
|
||||||
i % 2 === 1 && 'border-border border-l',
|
].map((x) => (
|
||||||
i >= 2 && 'border-border border-t',
|
<span
|
||||||
)}
|
key={x.l}
|
||||||
|
className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]"
|
||||||
>
|
>
|
||||||
<MetricCell metric={metric} />
|
<b className={cn('text-lg leading-tight font-semibold tabular-nums', TONE_TEXT[x.t])}>{x.n}</b>
|
||||||
|
{x.l}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{problems.length > 0 ? (
|
||||||
|
<ul className="flex flex-col gap-0.5">
|
||||||
|
{problems.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Link
|
||||||
|
to={p.to}
|
||||||
|
className="hover:bg-muted/55 focus-visible:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none"
|
||||||
|
>
|
||||||
|
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', TONE_BG[p.tone])} />
|
||||||
|
<span className="grid min-w-0 flex-1 leading-tight">
|
||||||
|
<b className="truncate text-[13px] font-medium">{p.title}</b>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">{p.text}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{checks.map((c) => (
|
||||||
|
<div key={c.id} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
|
||||||
|
<span className="text-muted-foreground text-xs leading-snug">{c.label}</span>
|
||||||
|
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', TONE_TEXT[c.tone])}>
|
||||||
|
{c.value}
|
||||||
|
</b>
|
||||||
|
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
|
||||||
|
<i className={cn('block h-full rounded-full', TONE_BG[c.tone])} style={{ width: `${c.percent}%` }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
|
||||||
API:{' '}
|
|
||||||
<span className={cn('font-medium', apiOk ? 'text-foreground' : 'text-destructive')}>
|
|
||||||
{apiOk ? 'OK' : '—'}
|
|
||||||
</span>
|
|
||||||
{' · '}
|
|
||||||
Инвентарь:{' '}
|
|
||||||
<span className="text-foreground font-medium tabular-nums">{issuesCount}</span>
|
|
||||||
{stats?.lastGlobalSyncAt ? (
|
|
||||||
<>
|
|
||||||
{' · '}
|
|
||||||
Синк:{' '}
|
|
||||||
<span className="text-foreground font-medium tabular-nums">
|
|
||||||
{new Date(stats.lastGlobalSyncAt).toLocaleString('ru-RU')}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Link
|
<Link
|
||||||
to="/sync-journal"
|
to="/sync-journal"
|
||||||
className="border-border text-muted-foreground hover:text-foreground flex items-center justify-between border-t px-3 py-2 text-xs transition-colors"
|
className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
Журнал синка
|
Журнал синка
|
||||||
<ChevronRight aria-hidden className="size-3.5" />
|
<ArrowRight className="size-3.5" aria-hidden />
|
||||||
</Link>
|
</Link>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -231,38 +231,39 @@ interface KpiStatGridProps {
|
|||||||
'aria-label'?: string
|
'aria-label'?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ячейка полосы KPI: плоская (разделители даёт `gap-px` на фоне border), padding как в макете 14×18. */
|
||||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||||
const onActivate = resolveActivate(item)
|
const onActivate = resolveActivate(item)
|
||||||
const panelClass = panelClassName(item)
|
const cellClass = cn('bg-card min-w-0 px-[18px] py-3.5', panelClassName(item))
|
||||||
|
|
||||||
if (item.to) {
|
if (item.to) {
|
||||||
return (
|
return (
|
||||||
<FramePanel className={panelClass}>
|
<div className={cellClass}>
|
||||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
<Link to={item.to} search={item.search} className="block focus-visible:outline-none">
|
||||||
<KpiStatCardBody item={item} />
|
<KpiStatCardBody item={item} />
|
||||||
</Link>
|
</Link>
|
||||||
</FramePanel>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onActivate) {
|
if (onActivate) {
|
||||||
return (
|
return (
|
||||||
<FramePanel
|
<div
|
||||||
className={panelClass}
|
className={cellClass}
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
>
|
>
|
||||||
<KpiStatCardBody item={item} />
|
<KpiStatCardBody item={item} />
|
||||||
</FramePanel>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FramePanel className={panelClass}>
|
<div className={cellClass}>
|
||||||
<KpiStatCardBody item={item} />
|
<KpiStatCardBody item={item} />
|
||||||
</FramePanel>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,17 +313,29 @@ export function KpiStatGrid({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Макет (TplVps `cap3 knN`) и EvoBGP: одна панель, ячейки через 1px-разделитель, не больше 4 колонок.
|
||||||
return (
|
return (
|
||||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
<Frame spacing="sm" className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
||||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
<FramePanel className="overflow-hidden p-0">
|
||||||
{list.map((item, index) => (
|
<div className={cn('bg-border grid gap-px', stripCols(list.length || 1))}>
|
||||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
{list.map((item, index) => (
|
||||||
))}
|
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripCols(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 @2xl:grid-cols-3'
|
||||||
|
if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-4'
|
||||||
|
// 5+ — по 3 в ряд, чтобы не оставалась пустая серая ячейка (6 = 3+3).
|
||||||
|
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
|
||||||
|
}
|
||||||
|
|
||||||
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||||
if (item.id) return item.id
|
if (item.id) return item.id
|
||||||
if (typeof item.label === 'string') return item.label
|
if (typeof item.label === 'string') return item.label
|
||||||
|
|||||||
Reference in new issue
Block a user