From 148ef8efb711979cb66fc5aa46a0f2e016a8e82f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 7 Nov 2026 02:47:38 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=C2=AB=D0=92=D0=BD=D0=B8=D0=BC=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=C2=BB=20=D0=B8=20KPI=20=D0=BA=20=D0=BC=D0=B0=D0=BA?= =?UTF-8?q?=D0=B5=D1=82=D1=83=20=D0=B8=20EvoBGP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - монитор в шапке: пилюля «Внимание ●N ●M», в попапе сводка сбой / предупреждения / в норме, список проблем со ссылками, плитки метрик с полосами и время обновления - KPI одной полосой в Frame с разделителями 1px, как доски макета Co-Authored-By: Claude Opus 5.5 --- .../layout/system-monitor-popover.tsx | 359 +++++++++--------- .../src/components/reui-kit/kpi-stat-grid.tsx | 43 ++- 2 files changed, 199 insertions(+), 203 deletions(-) diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx index 239d39d..94c7285 100644 --- a/apps/web/src/components/layout/system-monitor-popover.tsx +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -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 ( -
-
-
- - - {metric.icon} - - - {metric.label} -
- - {metric.value} - {metric.unit} - -
- -
- ) -} +const TONE_TEXT: Record = { er: 'text-destructive', wn: 'text-warning', ok: 'text-success' } +const TONE_BG: Record = { 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( - () => [ - { - id: 'sync', - label: 'Синк', - value: recentSyncFailed ? String(failedSyncCount) : syncAlert ? '!' : 'OK', - unit: '', - percent: syncAlert ? 35 : 100, - icon: , - tone: syncAlert ? (recentSyncFailed ? 'destructive' : 'warning') : 'success', - alert: syncAlert, - }, - { - id: 'notifications', - label: 'Уведомления', - value: String(failedNotifications), - unit: 'шт.', - percent: Math.min(100, failedNotifications * 15), - icon: , - 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: , - 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: , - 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[] = [ + ...(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 ( @@ -229,78 +184,106 @@ export function SystemMonitorPopover() { render={