From d52c633d90e7b91966c741543e1c6f84d255bfb4 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 17 Jul 2026 20:32:59 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=BA=D1=83=20=D0=B1=D1=8B=D1=81=D1=82=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=D0=B5=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Внедрён компонент QuickActionGrid для отображения часто используемых разделов на дашборде, управляемый настройкой showQuickActions. - Добавлен новый параметр showQuickActions в схему настроек для управления видимостью быстрого доступа. - Обновлён интерфейс настроек для включения переключателя отображения быстрого доступа. - Добавлен компонент SystemMonitorPopover в заголовок приложения для мониторинга системы. Co-authored-by: Cursor --- apps/web/src/components/layout/app-shell.tsx | 2 + .../layout/system-monitor-popover.tsx | 247 ++++++++++++++++++ apps/web/src/components/reui-kit/index.ts | 4 + .../components/reui-kit/quick-action-grid.tsx | 113 ++++++++ apps/web/src/lib/schemas.ts | 1 + apps/web/src/routes/_auth/dashboard.tsx | 67 ++++- apps/web/src/routes/_auth/settings/index.tsx | 30 +++ apps/web/src/types/entities.ts | 1 + docs/ui-design-contract.md | 16 +- packages/db/src/repositories/settings.ts | 17 +- packages/db/src/runtime-migrate.ts | 1 + packages/db/src/schema/index.ts | 1 + packages/shared/src/contracts/settings.ts | 1 + packages/ui/src/components/progress.tsx | 81 ++++++ packages/ui/src/components/switch.tsx | 32 +++ 15 files changed, 608 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/layout/system-monitor-popover.tsx create mode 100644 apps/web/src/components/reui-kit/quick-action-grid.tsx create mode 100644 packages/ui/src/components/progress.tsx create mode 100644 packages/ui/src/components/switch.tsx diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index bb35fa7..37833f3 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -45,6 +45,7 @@ import { useQuery } from '@tanstack/react-query' import { useState, type ReactNode } from 'react' import { ModeToggle } from '@/components/mode-toggle' +import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { AppSwitcher } from '@/components/app-switcher' import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search' import { dashboardStatsQueryOptions } from '@/queries/dashboard' @@ -230,6 +231,7 @@ export function AppShell({ children }: { children: ReactNode }) { {stats.issuesCount} проблем ) : null} + diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx new file mode 100644 index 0000000..c51f7d1 --- /dev/null +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -0,0 +1,247 @@ +import { useMemo, type CSSProperties, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + Activity, + ListChecks, + RefreshCw, + Server, + Wallet, +} from 'lucide-react' + +import { Badge } from '@/components/reui/badge' +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 = { + id: string + label: string + value: string + unit: string + percent: number + icon: ReactNode + tone: 'success' | 'warning' | 'destructive' | 'info' + alert: boolean +} + +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} + +
+ +
+ ) +} + +function isStaleSync(lastAt: string | null | undefined): boolean { + if (!lastAt) return true + const ts = new Date(lastAt).getTime() + if (Number.isNaN(ts)) return true + return Date.now() - ts > 24 * 60 * 60 * 1000 +} + +/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */ +export function SystemMonitorPopover() { + const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 }) + const snapQ = useQuery({ ...snapshotQueryOptions(), refetchInterval: 30_000 }) + const syncQ = useQuery({ + queryKey: ['sync', 'status'], + queryFn: () => api.fetchSyncStatus() as Promise>, + refetchInterval: 30_000, + }) + const notifyQ = useQuery({ + queryKey: ['notifications', 'log', 'monitor'], + queryFn: () => api.fetchNotificationLog(20), + refetchInterval: 30_000, + }) + + 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 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 recentSyncFailed = (syncQ.data ?? []).some( + (row) => + String(row.status ?? '').toLowerCase() === 'failed' || + String(row.status ?? '').toLowerCase() === 'error' || + row.ok === false, + ) + const syncAlert = staleSync || recentSyncFailed + const failedNotifications = (notifyQ.data ?? []).filter( + (n) => String(n.status ?? '').toLowerCase() === 'failed', + ).length + const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data) + + const metrics = useMemo( + () => [ + { + id: 'sync', + label: 'Синк', + value: syncAlert ? '!' : 'OK', + unit: '', + percent: syncAlert ? 35 : 100, + icon: , + tone: syncAlert ? (recentSyncFailed ? 'destructive' : 'warning') : 'success', + alert: syncAlert, + }, + { + id: 'inventory', + label: 'Инвентарь', + value: String(issuesCount), + unit: 'шт.', + percent: Math.min(100, issuesCount * 15), + icon: , + tone: issuesCount > 0 ? 'warning' : 'success', + alert: issuesCount > 0, + }, + { + id: 'runway', + label: 'Runway', + 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, issuesCount, lowBalance, recentSyncFailed, runwayDays, runwayLow, syncAlert], + ) + + const spiking = metrics.some((m) => m.alert) || !apiOk || failedNotifications > 0 + + return ( + + + } + > + + + {spiking ? ( + + ) : null} + + Система + + {spiking ? 'Внимание' : 'Норма'} + + + + +
+ Монитор VPS Tracker + + {new Date().toLocaleTimeString('ru-RU')} + +
+
+ {metrics.map((metric, i) => ( +
= 2 && 'border-border border-t', + )} + > + +
+ ))} +
+
+ API:{' '} + {apiOk ? 'OK' : '—'} + {' · '} + Уведомлений с ошибкой:{' '} + {failedNotifications} + {stats?.lastGlobalSyncAt ? ( + <> + {' · '} + Синк:{' '} + + {new Date(stats.lastGlobalSyncAt).toLocaleString('ru-RU')} + + + ) : null} +
+
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index ea31935..02a5915 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -6,6 +6,10 @@ export { type KpiStatVariant, type OpsKpiCard, } from './kpi-stat-grid' +export { + QuickActionGrid, + type QuickActionItem, +} from './quick-action-grid' export { OpsDashboard } from './ops-dashboard' export { DetailPanel, type DetailMetricCard } from './detail-panel' export { SettingsShell, type SettingsTabConfig } from './settings-shell' diff --git a/apps/web/src/components/reui-kit/quick-action-grid.tsx b/apps/web/src/components/reui-kit/quick-action-grid.tsx new file mode 100644 index 0000000..8221473 --- /dev/null +++ b/apps/web/src/components/reui-kit/quick-action-grid.tsx @@ -0,0 +1,113 @@ +import type { ReactNode } from 'react' +import { Link } from '@tanstack/react-router' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Badge } from '@/components/reui/badge' +import { Item, ItemMedia } from '@cfdm/ui/components/item' +import { cn } from '@cfdm/ui/lib/utils' + +export interface QuickActionItem { + id: string + title: string + description: string + to: string + search?: Record + icon?: ReactNode + iconClassName?: string +} + +interface QuickActionGridProps { + actions: QuickActionItem[] + title?: string + description?: string + className?: string +} + +const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current' + +function kpiCols(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 @3xl:grid-cols-3' + if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4' + if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5' + if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6' + return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4' +} + +function QuickActionBody({ action }: { action: QuickActionItem }) { + return ( +
+ {action.icon ? ( + + + {action.icon} + + + ) : null} + +
+
+ {action.title} + + Перейти + +
+

+ {action.description} +

+
+
+ ) +} + +/** + * KPI-like quick actions strip (horizontal Frame tiles). + * Preview: https://reui.io/preview/base/stats-12 + */ +export function QuickActionGrid({ + actions, + title = 'Быстрые действия', + description, + className, +}: QuickActionGridProps) { + if (actions.length === 0) return null + + return ( + + {(title || description) && ( + + {title ? {title} : null} + {description ? {description} : null} + + )} +
+ {actions.map((action) => ( + + + + + + ))} +
+ + ) +} diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index 0fc8446..0695dc2 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -112,6 +112,7 @@ export const settingsSchema = z.object({ (fields) => new Set(fields.map((f) => f.key)).size === fields.length, 'Ключи кастомных полей должны быть уникальными', ), + showQuickActions: z.boolean().optional().default(true), }) export { diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index 2419617..1545076 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -13,11 +13,15 @@ import { CoinsIcon, ClockIcon, DownloadIcon, + CreditCardIcon, + ChartColumnBigIcon, + ChartBarIcon, + PlugIcon, } from 'lucide-react' import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot' import { dashboardStatsQueryOptions } from '@/queries/dashboard' -import { OpsDashboard, type KpiStatCard } from '@/components/reui-kit' +import { OpsDashboard, QuickActionGrid, type KpiStatCard, type QuickActionItem } from '@/components/reui-kit' import { QueryState } from '@/components/query-state' import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card' import type { DataGridColumn } from '@/components/data-grid-types' @@ -45,6 +49,57 @@ import type { Vps } from '@/types/entities' const DASHBOARD_TAB_TRIGGER_CLASS = 'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none after:hidden data-active:border-foreground data-active:bg-transparent data-active:shadow-none dark:data-active:border-foreground dark:data-active:bg-transparent' +const DASHBOARD_QUICK_ACTIONS: QuickActionItem[] = [ + { + id: 'vps', + title: 'VPS', + description: 'Список серверов, оплата и здоровье.', + to: '/vps', + icon: , + iconClassName: 'text-primary', + }, + { + id: 'accounts', + title: 'Аккаунты', + description: 'Аккаунты хостеров и баланс API.', + to: '/accounts', + icon: , + iconClassName: 'text-success', + }, + { + id: 'payments', + title: 'Платежи', + description: 'Пополнения и оплаты VPS.', + to: '/payments', + icon: , + iconClassName: 'text-info', + }, + { + id: 'reports', + title: 'Отчёты', + description: 'Сводки по расходам и балансам.', + to: '/reports', + icon: , + iconClassName: 'text-warning', + }, + { + id: 'resources', + title: 'Ресурсы', + description: 'CPU, RAM и диск по инвентарю.', + to: '/resources', + icon: , + iconClassName: 'text-focus', + }, + { + id: 'integrations', + title: 'Интеграции', + description: 'App switcher и внешние связки.', + to: '/settings/integrations', + icon: , + iconClassName: 'text-muted-foreground', + }, +] + export const Route = createFileRoute('/_auth/dashboard')({ loader: ({ context: { queryClient } }) => Promise.all([ @@ -401,10 +456,14 @@ function DashboardPage() { } /> + {snap.settings?.[0]?.showQuickActions !== false ? ( + + ) : null} +
-