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 {
Activity,
Bell,
ChevronRight,
RefreshCw,
Server,
Wallet,
} from 'lucide-react'
import { Activity, ArrowRight } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Badge } from '@/components/reui/badge'
import { Button } from '@cfdm/ui/components/button'
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 { Progress } from '@cfdm/ui/components/progress'
import { api } from '@/lib/api-client'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { snapshotQueryOptions } from '@/queries/snapshot'
type MonitorMetric = {
type Tone = 'er' | 'wn' | 'ok'
/** Одна проверка монитора: строка в списке проблем (если не ok) и плитка с полосой. */
type MonitorCheck = {
id: string
label: string
value: string
unit: string
percent: number
icon: ReactNode
tone: 'success' | 'warning' | 'destructive' | 'info'
alert: boolean
tone: Tone
title: string
text: string
to: '/sync-journal' | '/settings/notifications' | '/accounts' | '/vps' | '/dashboard'
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
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>
)
}
const TONE_TEXT: Record<Tone, string> = { er: 'text-destructive', wn: 'text-warning', ok: 'text-success' }
const TONE_BG: Record<Tone, string> = { er: 'bg-destructive', wn: 'bg-warning', ok: 'bg-success' }
type SyncStatusRow = {
accountId?: string
@@ -127,7 +79,7 @@ export function countRecentFailedNotifications(
}).length
}
/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */
/** Центр состояния в шапке — та же разметка, что «Внимание» в макете и EvoBGP. */
export function SystemMonitorPopover() {
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
const snapQ = useQuery({ ...snapshotQueryOptions(), refetchInterval: 30_000 })
@@ -144,84 +96,87 @@ export function SystemMonitorPopover() {
const stats = statsQ.data
const vps = snapQ.data?.vps ?? []
const downCount = vps.filter((v) => {
const status = (v as { lastHealthStatus?: string }).lastHealthStatus
return status === 'down'
}).length
const monitored = vps.filter((v) => (v as { monitoringEnabled?: boolean }).monitoringEnabled).length
const downCount = vps.filter((v) => (v as { lastHealthStatus?: string }).lastHealthStatus === 'down').length
const issuesCount = stats?.issuesCount ?? 0
const runwayDays = stats?.minRunwayDays
const runwayLow = runwayDays != null && runwayDays < 14
const lowBalance = (stats?.lowBalanceAccountCount ?? 0) > 0
const staleSync =
(stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
const staleSync = (stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
const failedSyncCount = countCurrentSyncFailures(syncQ.data ?? [])
const recentSyncFailed = failedSyncCount > 0
const syncAlert = staleSync || recentSyncFailed
const failedNotifications = countRecentFailedNotifications(notifyQ.data ?? [])
const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data)
const updatedAt = Math.max(statsQ.dataUpdatedAt, snapQ.dataUpdatedAt)
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'sync',
label: 'Синк',
value: recentSyncFailed ? String(failedSyncCount) : syncAlert ? '!' : 'OK',
unit: '',
percent: syncAlert ? 35 : 100,
icon: <RefreshCw aria-hidden />,
tone: syncAlert ? (recentSyncFailed ? 'destructive' : 'warning') : 'success',
alert: syncAlert,
},
{
id: 'notifications',
label: 'Уведомления',
value: String(failedNotifications),
unit: 'шт.',
percent: Math.min(100, failedNotifications * 15),
icon: <Bell aria-hidden />,
tone: failedNotifications > 0 ? 'warning' : 'success',
alert: failedNotifications > 0,
},
{
id: 'runway',
label: 'Запас дней',
value: runwayDays != null ? String(runwayDays) : '—',
unit: runwayDays != null ? 'дн' : '',
percent:
runwayDays == null
? 0
: Math.min(100, Math.round((runwayDays / 30) * 100)),
icon: <Wallet aria-hidden />,
tone: runwayLow || lowBalance ? 'warning' : 'success',
alert: runwayLow || lowBalance,
},
{
id: 'vps-down',
label: 'VPS down',
value: String(downCount),
unit: 'шт.',
percent: Math.min(100, downCount * 25),
icon: <Server aria-hidden />,
tone: downCount > 0 ? 'destructive' : 'success',
alert: downCount > 0,
},
],
[
downCount,
failedNotifications,
failedSyncCount,
lowBalance,
recentSyncFailed,
runwayDays,
runwayLow,
syncAlert,
],
)
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 checks: MonitorCheck[] = [
{
id: 'sync',
label: 'Синк',
value: failedSyncCount > 0 ? String(failedSyncCount) : staleSync ? 'устарел' : 'OK',
percent: failedSyncCount > 0 || staleSync ? 35 : 100,
tone: failedSyncCount > 0 ? 'er' : staleSync ? 'wn' : 'ok',
title: failedSyncCount > 0 ? 'Ошибка синхронизации' : 'Синк давно не выполнялся',
text:
failedSyncCount > 0
? `${failedSyncCount} акк. с ошибкой последнего синка`
: 'Нет успешного синка за 24 ч',
to: '/sync-journal',
},
{
id: 'notifications',
label: 'Уведомления',
value: `${failedNotifications} шт.`,
percent: Math.min(100, failedNotifications * 15),
tone: failedNotifications > 0 ? 'wn' : 'ok',
title: 'Уведомления не доставлены',
text: `${failedNotifications} за 24 ч — проверьте Telegram / webhook`,
to: '/settings/notifications',
},
{
id: 'runway',
label: 'Запас дней',
value: runwayDays != null ? `${runwayDays} дн` : '—',
percent: runwayDays == null ? 0 : Math.min(100, Math.round((runwayDays / 30) * 100)),
tone: runwayLow || lowBalance ? 'wn' : 'ok',
title: lowBalance ? 'Низкий баланс аккаунта' : 'Мало денег у хостера',
text: runwayDays != null ? `Хватит примерно на ${runwayDays} дн` : 'Баланс ниже порога',
to: '/accounts',
},
{
id: 'vps-down',
label: 'VPS down',
value: monitored ? `${downCount} из ${monitored}` : String(downCount),
percent: monitored ? Math.round(((monitored - downCount) / monitored) * 100) : 100,
tone: downCount > 0 ? 'er' : 'ok',
title: 'VPS недоступны',
text: `${downCount} не отвечают на uptime-проверку`,
to: '/vps',
},
]
const problems: Omit<MonitorCheck, 'label' | 'value' | 'percent'>[] = [
...(apiOk
? []
: [{ id: 'api', tone: 'er' as const, title: 'API недоступно', text: 'Нет ответа от сервера', to: '/dashboard' as const }]),
...checks.filter((c) => c.tone !== 'ok'),
...(issuesCount > 0
? [
{
id: 'inventory',
tone: 'wn' as const,
title: 'Проблемы инвентаря',
text: `${issuesCount} категор. на дашборде`,
to: '/dashboard' as const,
},
]
: []),
]
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 (
<Popover>
@@ -229,78 +184,106 @@ export function SystemMonitorPopover() {
render={
<Button
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
aria-hidden
className={cn(
errorCount > 0 ? 'text-destructive' : warningCount > 0 ? 'text-warning' : 'text-muted-foreground',
)}
/>
<span className="hidden sm:inline">{statusLabel}</span>
{errorCount > 0 ? (
<Badge variant="destructive-light" size="sm" className="tabular-nums">
{errorCount}
</Badge>
) : null}
{warningCount > 0 ? (
<Badge variant="warning-light" size="sm" className="tabular-nums">
{warningCount}
</Badge>
) : null}
{!spiking ? (
<Badge variant="success-light" size="sm">
OK
</Badge>
<Activity className={cn('size-4', alert ? 'text-warning' : 'text-success')} aria-hidden />
<span
className={cn('hidden text-sm font-medium min-[1000px]:inline', alert ? 'text-warning' : 'text-success')}
>
{alert ? 'Внимание' : 'Норма'}
</span>
{alert ? (
<>
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
{errors}
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
{warnings}
</span>
<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">
{problems.length}
</span>
<span
className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden"
aria-hidden
/>
</>
) : null}
</PopoverTrigger>
<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">Монитор VPS Tracker</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
</span>
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="grid leading-tight">
<b className="text-sm font-semibold">Монитор VPS Tracker</b>
<span className="text-muted-foreground text-xs">
{updatedAt ? `обновлено ${new Date(updatedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}` : 'загрузка…'}
</span>
</div>
<Badge
variant={status === 'er' ? 'destructive-light' : status === 'wn' ? 'warning-light' : 'success-light'}
size="sm"
>
{statusLabel}
</Badge>
</div>
<div className="grid grid-cols-2">
{metrics.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && 'border-border border-l',
i >= 2 && 'border-border border-t',
)}
<div className="grid grid-cols-3 gap-1.5">
{[
{ n: errors, l: 'сбой', t: 'er' as const },
{ n: warnings, l: 'предупреждения', t: 'wn' as const },
{ n: okCount, l: 'в норме', t: 'ok' as const },
].map((x) => (
<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 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
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>
</PopoverContent>
</Popover>
@@ -231,38 +231,39 @@ interface KpiStatGridProps {
'aria-label'?: string
}
/** Ячейка полосы KPI: плоская (разделители даёт `gap-px` на фоне border), padding как в макете 14×18. */
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
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) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<div className={cellClass}>
<Link to={item.to} search={item.search} className="block focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
</div>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
<div
className={cellClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
</div>
)
}
return (
<FramePanel className={panelClass}>
<div className={cellClass}>
<KpiStatCardBody item={item} />
</FramePanel>
</div>
)
}
@@ -312,17 +313,29 @@ export function KpiStatGrid({
)
}
// Макет (TplVps `cap3 knN`) и EvoBGP: одна панель, ячейки через 1px-разделитель, не больше 4 колонок.
return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
<Frame spacing="sm" className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', stripCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</FramePanel>
</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 {
if (item.id) return item.id
if (typeof item.label === 'string') return item.label