From 13c079c9697fb572ad1c908542e09c8d562ef223 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 17 Jul 2026 20:34:05 +0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=20=D0=B8=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA=20=D0=B2?= =?UTF-8?q?=D0=BD=D0=B5=D1=88=D0=BD=D0=B5=D0=B3=D0=BE=20=D0=B2=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен новый маршрут для страницы настроек внешнего вида в интерфейсе. Обновлены компоненты навигации и заголовка для поддержки нового маршрута. Включены изменения в документацию и интерфейсы для отображения новых элементов управления. Также добавлена поддержка отображения быстрого доступа к разделам в панели управления. --- apps/web/src/components/app-sidebar.tsx | 2 +- .../web/src/components/layout/site-header.tsx | 9 +- .../layout/system-monitor-popover.tsx | 255 ++++++++++++++++++ apps/web/src/components/reui-kit/index.ts | 1 + .../src/components/reui-kit/ops-dashboard.tsx | 5 + .../components/reui-kit/quick-action-grid.tsx | 113 ++++++++ .../components/reui-kit/settings-shell.tsx | 8 +- apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/_auth/index.tsx | 62 +++++ .../src/routes/_auth/settings/appearance.tsx | 80 ++++++ apps/web/src/routes/_auth/settings/index.tsx | 2 +- docs/ui-design-contract.md | 16 +- .../db/migrations/015_show_quick_actions.sql | 2 + packages/db/src/schema.ts | 5 + packages/db/src/settings-repo.ts | 8 + .../shared/src/integration-vps-tracker.ts | 1 + 16 files changed, 584 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 apps/web/src/routes/_auth/settings/appearance.tsx create mode 100644 packages/db/migrations/015_show_quick_actions.sql diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index bee101d..bcdaf50 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -31,7 +31,7 @@ const infrastructureNav = [ const operationsNav = [ { to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false }, { to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false }, - { to: '/settings/integrations', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' }, + { to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' }, ] as const function isNavActive( diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index 2ea6386..c576922 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -9,6 +9,7 @@ import { BreadcrumbSeparator, } from '@cfdm/ui/components/breadcrumb' import { ModeToggle } from '@/components/mode-toggle' +import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { AppsMenu } from '@/components/layout/apps-menu' import { SearchMenu } from '@/components/layout/search-menu' import { SidebarTrigger } from '@cfdm/ui/components/sidebar' @@ -23,6 +24,7 @@ const routeTitles: Record = { '/groups': 'Группы доменов', '/services': 'Сервисы', '/certificates': 'Сертификаты', + '/settings/appearance': 'Внешний вид', '/settings/integrations': 'Интеграции', } @@ -60,10 +62,12 @@ function getBreadcrumbs( if (pathname.startsWith('/settings')) { return [ - { label: 'Настройки', href: '/settings/integrations' }, + { label: 'Настройки', href: '/settings/appearance' }, ...(pathname === '/settings/integrations' ? [{ label: 'Интеграции', href: pathname }] - : []), + : pathname === '/settings/appearance' + ? [{ label: 'Внешний вид', href: pathname }] + : []), ] } @@ -127,6 +131,7 @@ export function SiteHeader() {
+
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..e30bc74 --- /dev/null +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -0,0 +1,255 @@ +import { useMemo, type CSSProperties, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + Activity, + HeartPulse, + ListChecks, + Server, + ShieldCheck, +} 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 { + certSummaryQueryOptions, + domainsListQueryOptions, + notificationLogQueryOptions, + serviceGroupsQueryOptions, +} from '@/queries' + +type MonitorMetric = { + id: string + label: string + value: string + unit: string + percent: number + icon: ReactNode + tone: 'success' | 'warning' | 'destructive' | 'info' + alert: boolean +} + +type ReadyResponse = { + status: string + database?: boolean + cloudflare?: 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 countByStatus(summary: [string, number][] | undefined, statuses: string[]) { + if (!summary) return 0 + return summary + .filter(([status]) => statuses.includes(status)) + .reduce((sum, [, count]) => sum + count, 0) +} + +/** Live system monitor popover (app-shell pattern, CFDM API data). */ +export function SystemMonitorPopover() { + const readyQ = useQuery({ + queryKey: ['ready'], + queryFn: async (): Promise => { + const res = await fetch('/ready') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() as Promise + }, + refetchInterval: 30_000, + }) + const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: 30_000 }) + const certsQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: 30_000 }) + const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: 30_000 }) + const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: 30_000 }) + + const readyOk = readyQ.data?.status === 'ready' && readyQ.isSuccess + const readyDegraded = readyQ.data?.status === 'degraded' || readyQ.isError + + let healthDown = 0 + let healthDegraded = 0 + for (const group of servicesQ.data?.groups ?? []) { + for (const service of group.services) { + if (service.health_status === 'down') healthDown += 1 + if (service.health_status === 'degraded') healthDegraded += 1 + } + } + for (const service of servicesQ.data?.ungrouped ?? []) { + if (service.health_status === 'down') healthDown += 1 + if (service.health_status === 'degraded') healthDegraded += 1 + } + const healthAlert = healthDown > 0 + const healthWarn = healthDegraded > 0 + + const certWarnings = countByStatus(certsQ.data, ['warning', 'expired', 'error']) + const failedJobs = (notifyQ.data ?? []).filter((n) => + /fail|error/i.test(`${n.kind} ${n.title} ${n.message}`), + ).length + const ungroupedCount = (domainsQ.data ?? []).filter((d) => d.group_id == null).length + const attentionCount = healthDown + healthDegraded + + const metrics = useMemo( + () => [ + { + id: 'ready', + label: 'Ready', + value: readyOk ? 'OK' : readyDegraded ? '!' : '—', + unit: '', + percent: readyOk ? 100 : readyDegraded ? 40 : 0, + icon: , + tone: readyOk ? 'success' : 'destructive', + alert: !readyOk, + }, + { + id: 'health', + label: 'Health-check', + value: String(healthDown + healthDegraded), + unit: 'шт.', + percent: Math.min(100, (healthDown + healthDegraded) * 20), + icon: , + tone: healthAlert ? 'destructive' : healthWarn ? 'warning' : 'success', + alert: healthAlert || healthWarn, + }, + { + id: 'certs', + label: 'Сертификаты', + value: String(certWarnings), + unit: 'шт.', + percent: Math.min(100, certWarnings * 20), + icon: , + tone: certWarnings > 0 ? 'warning' : 'success', + alert: certWarnings > 0, + }, + { + id: 'jobs', + label: 'Ошибки', + value: String(failedJobs), + unit: 'шт.', + percent: Math.min(100, failedJobs * 15), + icon: , + tone: failedJobs > 0 ? 'warning' : 'success', + alert: failedJobs > 0, + }, + ], + [ + certWarnings, + failedJobs, + healthAlert, + healthDegraded, + healthDown, + healthWarn, + readyDegraded, + readyOk, + ], + ) + + const spiking = metrics.some((m) => m.alert) + + return ( + + + } + > + + + {spiking ? ( + + ) : null} + + Система + + {spiking ? 'Внимание' : 'Норма'} + + + + +
+ Монитор CFDM + + {new Date().toLocaleTimeString('ru-RU')} + +
+
+ {metrics.map((metric, i) => ( +
= 2 && 'border-border border-t', + )} + > + +
+ ))} +
+
+ Без группы:{' '} + {ungroupedCount} + {' · '} + Внимание:{' '} + {attentionCount} +
+
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 13cf689..466bd24 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -2,6 +2,7 @@ export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics' export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page' export { KpiStatGrid, type KpiStatCard, type KpiStatVariant, type OpsKpiCard } from './kpi-stat-grid' +export { QuickActionGrid, type QuickActionItem } from './quick-action-grid' export { OpsDashboard } from './ops-dashboard' export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board' export { DetailPanel, type DetailMetricCard } from './detail-panel' diff --git a/apps/web/src/components/reui-kit/ops-dashboard.tsx b/apps/web/src/components/reui-kit/ops-dashboard.tsx index c53fcc1..d434ee2 100644 --- a/apps/web/src/components/reui-kit/ops-dashboard.tsx +++ b/apps/web/src/components/reui-kit/ops-dashboard.tsx @@ -16,6 +16,8 @@ interface OpsDashboardProps { title?: string description?: string kpiCards: OpsKpiCard[] + /** Optional slot under KPI (e.g. QuickActionGrid) */ + afterKpi?: ReactNode charts: ReactNode queue: ReactNode isLoading?: boolean @@ -45,6 +47,7 @@ export function OpsDashboard({ title = 'Панель управления', description = 'Обзор доменов, групп, сервисов и сертификатов Cloudflare', kpiCards, + afterKpi, charts, queue, isLoading = false, @@ -61,6 +64,8 @@ export function OpsDashboard({ + {afterKpi} +
+ 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/components/reui-kit/settings-shell.tsx b/apps/web/src/components/reui-kit/settings-shell.tsx index 519280a..e953eba 100644 --- a/apps/web/src/components/reui-kit/settings-shell.tsx +++ b/apps/web/src/components/reui-kit/settings-shell.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { Link, Outlet, useRouterState } from '@tanstack/react-router' -import { SettingsIcon } from 'lucide-react' +import { PaletteIcon, SettingsIcon } from 'lucide-react' import { useIsMobile } from '@cfdm/ui/hooks/use-mobile' import { cn } from '@cfdm/ui/lib/utils' @@ -16,6 +16,12 @@ export interface SettingsTabConfig { } const DEFAULT_TABS: SettingsTabConfig[] = [ + { + id: 'appearance', + to: '/settings/appearance', + label: 'Внешний вид', + icon: