From 781c88175c33715d27b233c01dc217c929ae9210 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 10 Jul 2026 01:10:34 +0700 Subject: [PATCH] feat(web): enhance integration settings and UI components - Updated AppSwitcherEditor to include item separators for better visual organization. - Improved VpsTrackerIntegrationCard with badges indicating synchronization status and token configuration. - Refactored SettingsShell to streamline layout and enhance accessibility with improved navigation. - Added detail fields to KPI cards in DashboardPage for clearer information presentation. - Introduced new components in the ReUI kit for better integration and user experience. Co-authored-by: Cursor --- .../integrations/app-switcher-editor.tsx | 94 ++++---- .../vps-tracker-integration-card.tsx | 141 ++++++------ .../reui-kit/dashboard-analytics.tsx | 184 +++++++++++++++ apps/web/src/components/reui-kit/index.ts | 1 + .../src/components/reui-kit/ops-dashboard.tsx | 8 +- .../components/reui-kit/settings-shell.tsx | 84 ++++--- apps/web/src/routes/_auth/index.tsx | 212 ++++++------------ .../routes/_auth/settings/integrations.tsx | 55 +++-- apps/web/src/routes/_auth/settings/route.tsx | 8 +- 9 files changed, 462 insertions(+), 325 deletions(-) create mode 100644 apps/web/src/components/reui-kit/dashboard-analytics.tsx diff --git a/apps/web/src/components/integrations/app-switcher-editor.tsx b/apps/web/src/components/integrations/app-switcher-editor.tsx index 63e4ca3..4d698ff 100644 --- a/apps/web/src/components/integrations/app-switcher-editor.tsx +++ b/apps/web/src/components/integrations/app-switcher-editor.tsx @@ -4,9 +4,9 @@ import { PlusIcon, Trash2Icon } from 'lucide-react' import { appSwitcherConfigSchema, type AppSwitcherConfig } from '@cfdm/shared' import { Button } from '@cfdm/ui/components/button' -import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card' import { FieldGroup } from '@cfdm/ui/components/field' import { Input } from '@cfdm/ui/components/input' +import { ItemSeparator } from '@cfdm/ui/components/item' import { FormFieldSimple } from '@/components/form-field' import { SelectField } from '@/components/select-field' import { LoadingButton } from '@/components/loading-button' @@ -33,29 +33,31 @@ export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitch const { fields, append, remove } = useFieldArray({ control: form.control, name: 'apps' }) return ( - - - Связанные приложения - URL для переключателя в sidebar - - -
void form.handleSubmit((values) => onSave(values))(e)} - > - - - - - {fields.map((field, index) => ( -
+ void form.handleSubmit((values) => onSave(values))(e)} + > + + + + + +
+ {fields.map((field, index) => ( +
+ {index > 0 ? : null} +
- + @@ -77,34 +79,42 @@ export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitch size="icon" disabled={fields.length <= 1} onClick={() => remove(index)} + aria-label="Удалить приложение" >
- ))} - - - - Сохранить приложения - - - - +
+ ))} +
+ + +
+ + + Сохранить приложения + + ) } diff --git a/apps/web/src/components/integrations/vps-tracker-integration-card.tsx b/apps/web/src/components/integrations/vps-tracker-integration-card.tsx index 26c39ac..0a260bc 100644 --- a/apps/web/src/components/integrations/vps-tracker-integration-card.tsx +++ b/apps/web/src/components/integrations/vps-tracker-integration-card.tsx @@ -3,13 +3,13 @@ import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { toast } from 'sonner' -import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card' +import { Badge } from '@/components/reui/badge' import { FieldGroup } from '@cfdm/ui/components/field' import { Input } from '@cfdm/ui/components/input' import { FormFieldSimple } from '@/components/form-field' import { SelectField } from '@/components/select-field' import { LoadingButton } from '@/components/loading-button' -import { AppButton } from '@/components/app-button' +import { Button } from '@cfdm/ui/components/button' import { api } from '@/lib/api-client' const formSchema = z.object({ @@ -55,72 +55,81 @@ export function VpsTrackerIntegrationCard({ else toast.error(result.error ?? 'Ошибка проверки') } + const syncBadgeVariant = settings?.vpsTrackerSyncEnabled ? 'success' : 'secondary' + return ( - - - VPS Tracker - - Исходящая синхронизация доменов и сервисов. URL API VPS Tracker (обычно порт 3001). - - - -
void form.handleSubmit((values) => onSave(values))(e)}> - - - void form.handleSubmit((values) => onSave(values))(e)} + > +
+ + {settings?.vpsTrackerSyncEnabled ? 'Синхронизация включена' : 'Синхронизация выключена'} + + {settings?.vpsTrackerIntegrationTokenSet ? ( + Токен настроен + ) : ( + Токен не задан + )} +
+ + + + + + + + + ( + + field.onChange((v ?? 'on') === 'on')} + options={[ + { value: 'on', label: 'Вкл' }, + { value: 'off', label: 'Выкл' }, + ]} /> - - - - ( - - field.onChange((v ?? 'on') === 'on')} - options={[ - { value: 'on', label: 'Вкл' }, - { value: 'off', label: 'Выкл' }, - ]} - /> - - )} - /> - {settings?.vpsTrackerLastSyncAt ? ( -

- Последний sync:{' '} - {new Date(settings.vpsTrackerLastSyncAt).toLocaleString('ru-RU')} -

- ) : null} -
-
- - Сохранить - - void handleTest()}> - Проверить связь - -
- -
-
+ )} + /> + {settings?.vpsTrackerLastSyncAt ? ( +

+ Последний sync:{' '} + {new Date(settings.vpsTrackerLastSyncAt).toLocaleString('ru-RU')} +

+ ) : ( +

Синхронизация ещё не выполнялась

+ )} + + +
+ + Сохранить + + +
+ ) } diff --git a/apps/web/src/components/reui-kit/dashboard-analytics.tsx b/apps/web/src/components/reui-kit/dashboard-analytics.tsx new file mode 100644 index 0000000..c6f5cc2 --- /dev/null +++ b/apps/web/src/components/reui-kit/dashboard-analytics.tsx @@ -0,0 +1,184 @@ +import { useMemo } from 'react' +import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts' + +import { StatusBadge } from '@/components/status-badge' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@cfdm/ui/components/chart' +import { Separator } from '@cfdm/ui/components/separator' +import { cn } from '@cfdm/ui/lib/utils' + +const statusChartConfig = { + count: { label: 'Сертификаты' }, + active: { label: 'Активен', color: 'var(--success)' }, + ok: { label: 'OK', color: 'var(--success)' }, + warning: { label: 'Предупреждение', color: 'var(--warning)' }, + pending_push: { label: 'Ожидает', color: 'var(--info)' }, + expired: { label: 'Истёк', color: 'var(--destructive)' }, + error: { label: 'Ошибка', color: 'var(--destructive)' }, + unknown: { label: 'Неизвестно', color: 'var(--muted-foreground)' }, +} satisfies ChartConfig + +const groupChartConfig = { + count: { label: 'Домены', color: 'var(--chart-1)' }, +} satisfies ChartConfig + +function statusColor(status: string) { + return ( + (statusChartConfig as Record)[status]?.color ?? + 'var(--chart-1)' + ) +} + +interface CertStatusChartProps { + data: { status: string; count: number }[] +} + +export function CertStatusChart({ data }: CertStatusChartProps) { + const total = useMemo( + () => data.reduce((sum, entry) => sum + entry.count, 0), + [data], + ) + + return ( + + + Статусы сертификатов + Распределение по последней проверке + + + {data.length === 0 ? ( +

Нет данных о сертификатах

+ ) : ( +
+
+ + + } + /> + + {data.map((entry) => ( + + ))} + + + + +
+ +
    + {data.map((entry, index) => ( +
  • +
    +
    +
    + {entry.count} +
    + {index < data.length - 1 ? : null} +
  • + ))} +
+
+ )} +
+ + ) +} + +interface GroupDomainsChartProps { + data: { name: string; count: number }[] +} + +export function GroupDomainsChart({ data }: GroupDomainsChartProps) { + return ( + + + Домены по группам + Топ-6 групп по количеству зон + + + {data.length === 0 ? ( +

Нет групп с доменами

+ ) : ( +
+ + + + + value.length > 12 ? `${value.slice(0, 11)}…` : value + } + /> + } /> + + + + +
    + {data.map((entry) => ( +
  • + {entry.name} + + {entry.count} + +
  • + ))} +
+
+ )} +
+ + ) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 9b666e9..d8a73f0 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -1,4 +1,5 @@ export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils' +export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics' export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page' export { OpsDashboard, OpsDashboardHintLink, type OpsKpiCard } from './ops-dashboard' export { KanbanBoard, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board' diff --git a/apps/web/src/components/reui-kit/ops-dashboard.tsx b/apps/web/src/components/reui-kit/ops-dashboard.tsx index 73da26c..6856beb 100644 --- a/apps/web/src/components/reui-kit/ops-dashboard.tsx +++ b/apps/web/src/components/reui-kit/ops-dashboard.tsx @@ -17,6 +17,7 @@ export interface OpsKpiCard { metricLabel: string value: string | number hint?: ReactNode + detail?: ReactNode icon: ReactNode iconBg?: string } @@ -61,6 +62,9 @@ function KpiCardItem({ card }: { card: OpsKpiCard }) { {card.hint} + {card.detail ? ( +

{card.detail}

+ ) : null} ) @@ -74,7 +78,7 @@ export function OpsDashboard({ queue, }: OpsDashboardProps) { return ( -
+

{title}

@@ -94,7 +98,7 @@ export function OpsDashboard({

{charts}
diff --git a/apps/web/src/components/reui-kit/settings-shell.tsx b/apps/web/src/components/reui-kit/settings-shell.tsx index 08a62ab..86b0bab 100644 --- a/apps/web/src/components/reui-kit/settings-shell.tsx +++ b/apps/web/src/components/reui-kit/settings-shell.tsx @@ -4,12 +4,6 @@ import { SettingsIcon } from 'lucide-react' import { useIsMobile } from '@cfdm/ui/hooks/use-mobile' import { cn } from '@cfdm/ui/lib/utils' -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from '@cfdm/ui/components/tabs' import { PageShell } from '@/components/page-shell' export interface SettingsTabConfig { @@ -32,23 +26,19 @@ interface SettingsShellProps { title?: string description?: string tabs?: SettingsTabConfig[] - children?: ReactNode } export function SettingsShell({ title = 'Настройки', description = 'Интеграции и конфигурация приложения', tabs = DEFAULT_TABS, - children, }: SettingsShellProps) { const isMobile = useIsMobile() const pathname = useRouterState({ select: (s) => s.location.pathname }) - const activeTab = - tabs.find((tab) => pathname.startsWith(tab.to))?.id ?? tabs[0]?.id ?? '' return ( -
+

{title}

@@ -56,45 +46,49 @@ export function SettingsShell({

- -
- + {tabs.length > 1 ? ( + + ) : null} -
- {tabs.map((tab) => ( - - {children ?? } - - ))} -
+
+
- +
) diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index 37b282e..773e13d 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -2,16 +2,7 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { - Bar, - BarChart, - CartesianGrid, - Cell, - Label, - Pie, - PieChart, - XAxis, -} from 'recharts' -import { + AlertTriangleIcon, FolderTreeIcon, GlobeIcon, ServerIcon, @@ -25,23 +16,13 @@ import { serviceGroupsQueryOptions, } from '@/queries' import { PageShell } from '@/components/page-shell' -import { OpsDashboard, OpsDashboardHintLink } from '@/components/reui-kit' import { - Frame, - FrameDescription, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' + CertStatusChart, + GroupDomainsChart, + OpsDashboard, + OpsDashboardHintLink, +} from '@/components/reui-kit' import { StatusBadge } from '@/components/status-badge' -import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from '@cfdm/ui/components/chart' import { Item, ItemContent, @@ -62,27 +43,14 @@ export const Route = createFileRoute('/_auth/')({ component: DashboardPage, }) -const statusChartConfig = { - count: { label: 'Сертификаты' }, - active: { label: 'Активен', color: 'var(--success)' }, - ok: { label: 'OK', color: 'var(--success)' }, - warning: { label: 'Предупреждение', color: 'var(--warning)' }, - pending_push: { label: 'Ожидает', color: 'var(--info)' }, - expired: { label: 'Истёк', color: 'var(--destructive)' }, - error: { label: 'Ошибка', color: 'var(--destructive)' }, - unknown: { label: 'Неизвестно', color: 'var(--muted-foreground)' }, -} satisfies ChartConfig +const KPI_ICON_BG = ['bg-chart-1', 'bg-chart-2', 'bg-chart-3', 'bg-chart-4'] -const groupChartConfig = { - count: { label: 'Домены', color: 'var(--chart-1)' }, -} satisfies ChartConfig - -const KPI_ICON_BG = [ - 'bg-chart-1', - 'bg-chart-2', - 'bg-chart-3', - 'bg-chart-4', -] +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) +} function DashboardPage() { const { data: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions()) @@ -95,6 +63,9 @@ function DashboardPage() { (serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) + (serviceData?.ungrouped.length ?? 0) + const groupedServiceCount = + serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0 + const isLoading = domainsLoading || summaryLoading const statusChartData = useMemo( @@ -115,6 +86,11 @@ function DashboardPage() { const [nowMs] = useState(() => Date.now()) + const ungroupedCount = useMemo( + () => (domains ?? []).filter((d) => d.group_id == null).length, + [domains], + ) + const expiringCerts = useMemo(() => { const warnMs = 14 * 24 * 60 * 60 * 1000 return (certs ?? []) @@ -130,6 +106,9 @@ function DashboardPage() { [domains], ) + const certWarnings = countByStatus(summary, ['warning', 'expired', 'error']) + const certOk = countByStatus(summary, ['active', 'ok']) + const kpiCards = [ { id: 'domains', @@ -137,6 +116,10 @@ function DashboardPage() { title: 'Домены', metricLabel: 'Импортированные зоны', value: domains?.length ?? 0, + detail: + ungroupedCount > 0 + ? `${ungroupedCount} без группы` + : 'Все домены распределены по группам', icon: