feat(web): привести «Внимание» и KPI к макету и EvoBGP

- монитор в шапке: пилюля «Внимание ●N ●M», в попапе сводка сбой /
  предупреждения / в норме, список проблем со ссылками, плитки метрик
  с полосами и время обновления
- KPI одной полосой в Frame с разделителями 1px, как доски макета

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-07 02:47:38 +07:00
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