From a1a9124f3d6aa00c7f063b3c2b4a7f2262582768 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 30 Jun 2026 21:30:40 +0700 Subject: [PATCH] =?UTF-8?q?fix(config):=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=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8E=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ci/scripts/patch-servers-reui.py | 144 ++ .ci/scripts/patch-servers-stepper.py | 144 ++ app/(main)/alerts/page.tsx | 20 +- app/(main)/asns/page.tsx | 20 +- app/(main)/backups/page.tsx | 380 ++-- app/(main)/bgp/page.tsx | 366 +--- app/(main)/certificates/page.tsx | 218 +- app/(main)/data-collection/page.tsx | 35 +- app/(main)/domains/page.tsx | 20 +- app/(main)/filters/page.tsx | 34 +- app/(main)/firewall/page.tsx | 140 +- app/(main)/gre/page.tsx | 145 +- app/(main)/ip-ranges/page.tsx | 21 +- app/(main)/loading.tsx | 15 + app/(main)/probes/page.tsx | 16 +- app/(main)/recursive-routes/page.tsx | 57 +- app/(main)/route-optimizer/page.tsx | 14 +- app/(main)/servers/page.tsx | 623 ++---- app/(main)/settings/page.tsx | 124 +- app/(main)/uptime/page.tsx | 110 +- app/(main)/wireguard/page.tsx | 12 +- app/globals.css | 27 + components.json | 4 +- components/data-grids/backups-data-grid.tsx | 171 ++ components/data-grids/bgp-session-detail.tsx | 130 ++ .../data-grids/bgp-sessions-data-grid.tsx | 253 +++ .../data-grids/server-expanded-detail.tsx | 123 ++ components/data-grids/servers-data-grid.tsx | 452 ++++ components/data-page-toolbar.tsx | 79 + components/data-table.tsx | 113 +- components/empty-state.tsx | 32 + components/file-import-dialog.tsx | 120 + components/form-kit/form-field.tsx | 31 + components/form-kit/form-toggle.tsx | 24 + components/form-kit/index.ts | 4 + components/form-kit/section-title.tsx | 21 + components/form-kit/segmented-control.tsx | 47 + components/reui/alert.tsx | 92 + components/reui/autocomplete.tsx | 343 +++ components/reui/badge.tsx | 98 + .../data-grid/data-grid-column-filter.tsx | 167 ++ .../data-grid/data-grid-column-header.tsx | 345 +++ .../data-grid/data-grid-column-visibility.tsx | 53 + .../reui/data-grid/data-grid-pagination.tsx | 226 ++ .../reui/data-grid/data-grid-scroll-area.tsx | 421 ++++ .../data-grid/data-grid-table-dnd-rows.tsx | 309 +++ .../reui/data-grid/data-grid-table-dnd.tsx | 314 +++ .../data-grid/data-grid-table-virtual.tsx | 492 +++++ components/reui/data-grid/data-grid-table.tsx | 1433 ++++++++++++ components/reui/data-grid/data-grid.tsx | 270 +++ components/reui/data-grid/index.ts | 36 + components/reui/filters.tsx | 1934 +++++++++++++++++ components/reui/frame.tsx | 170 ++ components/reui/number-field.tsx | 260 +++ components/reui/sortable.tsx | 417 ++++ components/reui/stepper.tsx | 489 +++++ components/reui/timeline.tsx | 258 +++ components/ui/button-group.tsx | 87 + components/ui/button.tsx | 4 +- components/ui/checkbox.tsx | 29 + components/ui/command.tsx | 196 ++ components/ui/empty.tsx | 104 + components/ui/field.tsx | 238 ++ components/ui/input-group.tsx | 158 ++ components/ui/kbd.tsx | 26 + components/ui/label.tsx | 20 + components/ui/scroll-area.tsx | 55 + components/ui/select.tsx | 201 ++ components/ui/spinner.tsx | 10 + components/ui/table.tsx | 116 + components/ui/textarea.tsx | 18 + hooks/use-file-upload.ts | 417 ++++ lib/bgp/helpers.ts | 11 + lib/bgp/types.ts | 38 + lib/data-filters/apply-reui-filters.ts | 40 + lib/data-filters/bgp-filter-fields.ts | 36 + lib/data-filters/server-filter-fields.ts | 45 + package-lock.json | 621 +++++- package.json | 9 +- tsconfig.tsbuildinfo | 2 +- 80 files changed, 13310 insertions(+), 1587 deletions(-) create mode 100644 .ci/scripts/patch-servers-reui.py create mode 100644 .ci/scripts/patch-servers-stepper.py create mode 100644 app/(main)/loading.tsx create mode 100644 components/data-grids/backups-data-grid.tsx create mode 100644 components/data-grids/bgp-session-detail.tsx create mode 100644 components/data-grids/bgp-sessions-data-grid.tsx create mode 100644 components/data-grids/server-expanded-detail.tsx create mode 100644 components/data-grids/servers-data-grid.tsx create mode 100644 components/data-page-toolbar.tsx create mode 100644 components/empty-state.tsx create mode 100644 components/file-import-dialog.tsx create mode 100644 components/form-kit/form-field.tsx create mode 100644 components/form-kit/form-toggle.tsx create mode 100644 components/form-kit/index.ts create mode 100644 components/form-kit/section-title.tsx create mode 100644 components/form-kit/segmented-control.tsx create mode 100644 components/reui/alert.tsx create mode 100644 components/reui/autocomplete.tsx create mode 100644 components/reui/badge.tsx create mode 100644 components/reui/data-grid/data-grid-column-filter.tsx create mode 100644 components/reui/data-grid/data-grid-column-header.tsx create mode 100644 components/reui/data-grid/data-grid-column-visibility.tsx create mode 100644 components/reui/data-grid/data-grid-pagination.tsx create mode 100644 components/reui/data-grid/data-grid-scroll-area.tsx create mode 100644 components/reui/data-grid/data-grid-table-dnd-rows.tsx create mode 100644 components/reui/data-grid/data-grid-table-dnd.tsx create mode 100644 components/reui/data-grid/data-grid-table-virtual.tsx create mode 100644 components/reui/data-grid/data-grid-table.tsx create mode 100644 components/reui/data-grid/data-grid.tsx create mode 100644 components/reui/data-grid/index.ts create mode 100644 components/reui/filters.tsx create mode 100644 components/reui/frame.tsx create mode 100644 components/reui/number-field.tsx create mode 100644 components/reui/sortable.tsx create mode 100644 components/reui/stepper.tsx create mode 100644 components/reui/timeline.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/field.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 components/ui/kbd.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 hooks/use-file-upload.ts create mode 100644 lib/bgp/helpers.ts create mode 100644 lib/bgp/types.ts create mode 100644 lib/data-filters/apply-reui-filters.ts create mode 100644 lib/data-filters/bgp-filter-fields.ts create mode 100644 lib/data-filters/server-filter-fields.ts diff --git a/.ci/scripts/patch-servers-reui.py b/.ci/scripts/patch-servers-reui.py new file mode 100644 index 0000000..6672da2 --- /dev/null +++ b/.ci/scripts/patch-servers-reui.py @@ -0,0 +1,144 @@ +import re +from pathlib import Path + +path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx" +text = path.read_text(encoding="utf-8") + +text = text.replace( + 'import { Fragment, useEffect, useMemo, useState } from "react"\n' + 'import { PageHeader } from "@/components/page-header"\n' + 'import { StatusBadge } from "@/components/status-badge"', + 'import { useEffect, useMemo, useState } from "react"\n' + 'import { PageHeader } from "@/components/page-header"\n' + 'import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"\n' + 'import { DataPageToolbar } from "@/components/data-page-toolbar"\n' + 'import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"', + 1, +) + +text = text.replace( + """import { + DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, + DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup, +} from "@/components/ui/dropdown-menu" +""", + """import { + Stepper, + StepperContent, + StepperIndicator, + StepperItem, + StepperNav, + StepperPanel, + StepperSeparator, + StepperTitle, + StepperTrigger, +} from "@/components/reui/stepper" +""", + 1, +) + +text = text.replace( + """import { + SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon, + MoreHorizontalIcon, EyeIcon, EyeOffIcon, + ChevronRightIcon, ChevronDownIcon, + CheckCircleIcon, XCircleIcon, LoaderCircleIcon, + ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon, + HomeIcon, ServerIcon, NetworkIcon, +} from "lucide-react\"""", + """import { + RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon, + EyeIcon, EyeOffIcon, + ChevronRightIcon, ChevronDownIcon, + CheckCircleIcon, XCircleIcon, LoaderCircleIcon, + ShieldIcon, WifiIcon, + HomeIcon, ServerIcon, NetworkIcon, +} from "lucide-react\"""", + 1, +) + +text = re.sub( + r"// ─── RouterOS version utilities.*?// ─── Countries ─", + "// ─── Countries ─", + text, + count=1, + flags=re.S, +) + +text = re.sub( + r"// ─── Type config ─.*?// ─── Shared small components ─", + "// ─── Shared small components ─", + text, + count=1, + flags=re.S, +) + +text = re.sub( + r"function Field\(\{ label, hint, required, children \}:.*?^}\n\n// ─── Country field", + "// ─── Country field", + text, + count=1, + flags=re.S | re.M, +) + +text = text.replace( + "const [expandedId, setExpandedId] = useState(null)", + "const [sheetStep, setSheetStep] = useState(1)", + 1, +) + +text = text.replace( + 'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setOpen(true)', + 'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setSheetStep(1)\n setOpen(true)', + 1, +) + +text = text.replace( + 'setTestState("idle"); setTestMsg(""); setOpen(true)', + 'setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)', + 1, +) + +text = re.sub(r"", "", text) +text = re.sub(r" + ({ + value: tab.value, + label: tab.label, + count: tab.value === "all" ? counts.all : counts[tab.value as ServerType], + })), + }} + search={search} + onSearchChange={setSearch} + searchPlaceholder="Поиск по имени, хосту…" + countLabel={`${filtered.length} серверов`} + /> + + +""" + +text = re.sub( + r" \{/\* Table \*/\}\n .*?\n", + new_table, + text, + count=1, + flags=re.S, +) + +path.write_text(text, encoding="utf-8") +print("patched", path) diff --git a/.ci/scripts/patch-servers-stepper.py b/.ci/scripts/patch-servers-stepper.py new file mode 100644 index 0000000..7d9fc9f --- /dev/null +++ b/.ci/scripts/patch-servers-stepper.py @@ -0,0 +1,144 @@ +import re +from pathlib import Path + +path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx" +text = path.read_text(encoding="utf-8") + +# Wrap sheet form in stepper +text = text.replace( + """
+ + {/* 1. Основные */} +
+ Основные""", + """ + + + + 1 + Основные + + + + + + 2 + WAN + + + + + + 3 + API + + + + + + 4 + Дополнительно + + + + + """, + 1, +) + +text = text.replace( + """
+ + {/* 2. WAN-аплинки (только для home-router) */} + {isHomeRouter && ( +
+ WAN-аплинки""", + """ + + WAN-аплинки""", + 1, +) + +text = text.replace( + """
+ )} + + {/* 3. Подключение (API) */} +
+ Подключение (RouterOS REST API)""", + """ + + Подключение (RouterOS REST API)""", + 1, +) + +text = text.replace( + """
+ + {/* 4. Дополнительно */} +
+
+ +
+ + + }>Отмена + + """, + """ + + + + + }>Отмена + {sheetStep > 1 && ( + + )} + {sheetStep < 4 ? ( + + ) : ( + + )} + """, + 1, +) + +# WAN step 2: show message when not home router +text = text.replace( + """ + WAN-аплинки + + WAN-аплинки + {!isHomeRouter ? ( +

WAN-аплинки доступны только для типа Home Router.

+ ) : ( + +
+ + )} + + void; disabled?: boolean }) { - return ( - - ) -} - function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) { return

{children}

} @@ -739,7 +723,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled } !rule.enabled && "opacity-55", )}> {/* toggle */} - onToggle(rule.id, v)} disabled={interactionsDisabled} /> + onToggle(rule.id, v)} disabled={interactionsDisabled} /> {/* severity dot */} diff --git a/app/(main)/asns/page.tsx b/app/(main)/asns/page.tsx index 27c0dc1..cbb7833 100644 --- a/app/(main)/asns/page.tsx +++ b/app/(main)/asns/page.tsx @@ -1,18 +1,21 @@ "use client" -import { useMemo } from "react" +import { useMemo, useState } from "react" import { PageHeader } from "@/components/page-header" import { DataTable } from "@/components/data-table" +import { FileImportDialog } from "@/components/file-import-dialog" import { asns as mockAsns } from "@/lib/data" import { Button } from "@/components/ui/button" import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react" import { useDataSource } from "@/lib/data-source" import { useEvoBGP } from "@/lib/evobgp-context" import { cn } from "@/lib/utils" +import { toast } from "sonner" export default function AsnsPage() { const { mode } = useDataSource() const { enabled, snapshot, loading, error } = useEvoBGP() + const [importOpen, setImportOpen] = useState(false) const useEvoCatalog = mode === "live" && enabled @@ -28,7 +31,9 @@ export default function AsnsPage() { crumbs={[{ label: "Данные" }, { label: "ASN" }]} actions={ <> - + @@ -57,6 +62,7 @@ export default function AsnsPage() { + { + toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`) + }} + /> ) } diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index bb33ef3..a5b3393 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { PageHeader } from "@/components/page-header" +import { DataPageToolbar } from "@/components/data-page-toolbar" +import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid" +import { FileImportDialog } from "@/components/file-import-dialog" +import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit" import { StatusBadge } from "@/components/status-badge" import type { Backup, Server } from "@/lib/data" import { Card, CardContent } from "@/components/ui/card" @@ -22,52 +26,17 @@ import { listServers } from "@/shared/api/servers" import { toFrontendServer } from "@/entities/server/model/mappers" import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups" import { toast } from "sonner" - -// ─── small UI helpers ───────────────────────────────────────────────────────── - -function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { - return ( - - ) -} - -function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { - return ( -
- {icon} - {children} -
-
- ) -} - -function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ) -} - -function SegmentedControl({ value, onChange, options }: { - value: T; onChange: (v: T) => void; options: { value: T; label: string }[] -}) { - return ( -
- {options.map((o) => ( - - ))} -
- ) -} +import { + Stepper, + StepperContent, + StepperIndicator, + StepperItem, + StepperNav, + StepperPanel, + StepperSeparator, + StepperTitle, + StepperTrigger, +} from "@/components/reui/stepper" // ─── types ──────────────────────────────────────────────────────────────────── @@ -168,8 +137,11 @@ export default function BackupsPage() { // Manual backup sheet const [manualOpen, setManualOpen] = useState(false) + const [manualStep, setManualStep] = useState(1) const [manualServers, setManualServers] = useState>(new Set()) const [manualNotes, setManualNotes] = useState("") + const [restoreOpen, setRestoreOpen] = useState(false) + const [restoreTarget, setRestoreTarget] = useState(null) function toggleManualServer(id: string) { setManualServers((prev) => { const next = new Set(prev) @@ -356,7 +328,7 @@ export default function BackupsPage() { - ))} -
- {filtered.length} бэкапов - - -
- - - - - - - - - - - - - {filtered.length === 0 && ( - - )} - {filtered.map((b) => ( - - - - - - - - - - ))} - -
ФайлСерверРазмерТипЗаметкиСоздан -
Нет бэкапов
{b.filename}{b.server}{b.size} - - {b.kind === "auto" ? "авто" : "вручную"} - - {b.notes || "—"}{b.created} -
- - - -
-
-
+ + { + setRestoreTarget(b) + setRestoreOpen(true) + }} + onDelete={handleDelete} + /> )} @@ -517,11 +435,11 @@ export default function BackupsPage() {

Автоматический бэкап

Создавать бэкапы по расписанию

- setSched("enabled", v)} /> + setSched("enabled", v)} />
- + setSched("frequency", v)} @@ -531,10 +449,10 @@ export default function BackupsPage() { { value: "monthly", label: "Ежемесячно" }, ]} /> - + {schedule.frequency === "weekly" && ( - +
{WEEK_DAYS.map((d, i) => ( ))}
-
+ )} {schedule.frequency === "monthly" && ( - + setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} /> - + )} - +
- +
- + setSched("keepCount", Math.max(1, Number(e.target.value)))} /> - - + + setSched("format", v)} @@ -598,7 +516,7 @@ export default function BackupsPage() { { value: "backup", label: ".backup" }, ]} /> - +
@@ -609,7 +527,7 @@ export default function BackupsPage() { }>Хранилище - + setStore("type", v)} @@ -620,45 +538,45 @@ export default function BackupsPage() { { value: "smb", label: "SMB" }, ]} /> - +
{storage.type === "local" && ( - + setStore("localPath", e.target.value)} /> - + )} {storage.type !== "local" && ( <>
- + setStore("host", e.target.value)} /> - +
- + setStore("port", e.target.value)} /> - +
{storage.type === "smb" && ( - + setStore("share", e.target.value)} /> - + )}
- + setStore("username", e.target.value)} /> - - + +
-
+
- + setStore("remotePath", e.target.value)} /> - + )} @@ -759,71 +677,129 @@ export default function BackupsPage() {
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */} - + { setManualOpen(v); if (!v) setManualStep(1) }}> Новый бэкап Снять конфигурацию вручную с выбранных серверов -
-
-
-

Выберите серверы

-
- - · - + + + + + 1 + Серверы + + + + + + 2 + Заметка + + + + + + 3 + Подтверждение + + + + + +
+

Выберите серверы

+
+ + · + +
-
- {liveServers.map((s) => { - const checked = manualServers.has(s.id) - return ( -
- {s.status === "offline" && ( - недоступен - )} - - ) - })} -
- -
- - setManualNotes(e.target.value)} /> -
-
+
+

{s.name}

+
+ {s.host} + +
+
+ {s.status === "offline" && ( + недоступен + )} + + ) + })} +
+ + + setManualNotes(e.target.value)} /> + + + +

+ Будет создан бэкап для {manualServers.size} серверов. +

+ {manualNotes && ( +

Заметка: {manualNotes}

+ )} +
+ + }>Отмена - + {manualStep > 1 && ( + + )} + {manualStep < 3 ? ( + + ) : ( + + )} + + { + toast.success(`Файл ${files[0]?.name} подготовлен к восстановлению на ${restoreTarget?.server ?? "сервер"}`) + }} + /> ) } diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx index e03dcbb..c3c56fe 100644 --- a/app/(main)/bgp/page.tsx +++ b/app/(main)/bgp/page.tsx @@ -2,6 +2,13 @@ import { Fragment, useState, useMemo, useEffect } from "react" import { PageHeader } from "@/components/page-header" +import { DataPageToolbar } from "@/components/data-page-toolbar" +import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid" +import type { Filter } from "@/components/reui/filters" +import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters" +import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields" +import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types" +import { BGP_AS_NAMES } from "@/lib/bgp/types" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -17,49 +24,12 @@ import { useDataSource } from "@/lib/data-source" // ─── types ──────────────────────────────────────────────────────────────────── -type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm" -type BgpType = "eBGP" | "iBGP" -type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast" +type BgpSession = BgpSessionRow type BgpTab = "sessions" | "routers" | "analytics" type StateFilter = "all" | BgpState type TypeFilter = "all" | BgpType -interface BgpSession { - id: string - serverId: string - serverLabel: string - serverSite: string - peerIp: string - remoteAs: number - localAs: number - routerId: string - description: string - state: BgpState - type: BgpType - afi: BgpAfi - uptime: string | null - holdTime: number - keepalive: number - prefixesRx: number - prefixesTx: number - prefixesActive: number - inputMessages: number - outputMessages: number - capabilities: string[] - lastError: string | null -} - -// ─── AS name lookup ─────────────────────────────────────────────────────────── - -const AS_NAMES: Record = { - 8359: "МТС / Tele2", - 13238: "Яндекс", - 12389: "Ростелеком", - 24940: "Hetzner", - 6777: "AMS-IX", - 1299: "Telia", - 65001: "iBGP internal", -} +const AS_NAMES = BGP_AS_NAMES // ─── mock data ──────────────────────────────────────────────────────────────── const SESSIONS: BgpSession[] = [ @@ -240,13 +210,6 @@ function TypeBadge({ type }: { type: BgpType }) { ) } -function CapChip({ cap }: { cap: string }) { - return ( - - {cap} - - ) -} function fmtNum(n: number) { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` @@ -254,108 +217,6 @@ function fmtNum(n: number) { return String(n) } -function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) { - const max = Math.max(rx, 1) - return ( -
- {[ - { label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max }, - { label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max }, - { label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) }, - ].map(r => ( -
- {r.label} -
-
0 ? 2 : 0)}%` }} /> -
- {fmtNum(r.val)} -
- ))} -
- ) -} - -// ─── RSC snippet ────────────────────────────────────────────────────────────── - -function rscSnippet(s: BgpSession) { - return `/routing bgp connection\nadd name=peer-as${s.remoteAs} remote.address=${s.peerIp}/32 \\\n remote.as=${s.remoteAs} local.role=${s.type === "eBGP" ? "ebgp" : "ibgp"} \\\n output.filter-chain=export-filter input.filter=import-filter \\\n routing-table=main` -} - -// ─── session expanded row ───────────────────────────────────────────────────── - -function SessionDetail({ s }: { s: BgpSession }) { - const [copied, setCopied] = useState(false) - function copy() { - navigator.clipboard.writeText(rscSnippet(s)).then(() => { - setCopied(true); setTimeout(() => setCopied(false), 1800) - }) - } - return ( -
-
- {[ - { label: "Router ID", value: s.routerId }, - { label: "Hold / KA", value: `${s.holdTime}s / ${s.keepalive}s` }, - { label: "AFI/SAFI", value: s.afi }, - { label: "Сообщения ↓/↑", value: `${fmtNum(s.inputMessages)} / ${fmtNum(s.outputMessages)}` }, - ].map(({ label, value }) => ( -
-

{label}

-

{value}

-
- ))} -
- - {/* prefix bars */} - {s.state === "Established" && ( -
-

Префиксы

- -
- )} - - {/* capabilities */} - {s.capabilities.length > 0 && ( -
-

Capabilities

-
- {s.capabilities.map(c => )} -
-
- )} - - {/* last error */} - {s.lastError && ( -
- -

{s.lastError}

-
- )} - - {/* rsc export */} -
-

RouterOS Export

-
-
-            {rscSnippet(s)}
-          
- -
-
-
- ) -} - // ─── backend mapping ────────────────────────────────────────────────────────── interface BackendBgpSession { @@ -397,158 +258,81 @@ function backendToFrontend(b: BackendBgpSession): BgpSession { // ─── sessions tab ───────────────────────────────────────────────────────────── -const STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [ - { value: "all", label: "Все" }, - { value: "Established", label: "Established" }, - { value: "Active", label: "Active" }, - { value: "Idle", label: "Idle" }, - { value: "OpenSent", label: "OpenSent" }, -] +const BGP_FILTER_ACCESSORS = { + state: (s: BgpSession) => s.state, + type: (s: BgpSession) => s.type, + afi: (s: BgpSession) => s.afi, +} function SessionsTab({ sessions }: { sessions: BgpSession[] }) { - const [search, setSearch] = useState("") + const [search, setSearch] = useState("") const [stateFilter, setStateFilter] = useState("all") - const [typeFilter, setTypeFilter] = useState("all") - const [expandedId, setExpandedId] = useState(null) + const [typeFilter, setTypeFilter] = useState("all") + const [advancedFilters, setAdvancedFilters] = useState([]) const q = search.toLowerCase() - const filtered = useMemo(() => sessions.filter(s => { - if (stateFilter !== "all" && s.state !== stateFilter) return false - if (typeFilter !== "all" && s.type !== typeFilter) return false - if (q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q) - && !s.serverLabel.includes(q) && !String(s.remoteAs).includes(q) - && !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)) return false - return true - }), [sessions, q, stateFilter, typeFilter]) + const filtered = useMemo(() => { + const base = sessions.filter((s) => { + if (stateFilter !== "all" && s.state !== stateFilter) return false + if (typeFilter !== "all" && s.type !== typeFilter) return false + if ( + q && + !s.peerIp.includes(q) && + !s.description.toLowerCase().includes(q) && + !s.serverLabel.includes(q) && + !String(s.remoteAs).includes(q) && + !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q) + ) { + return false + } + return true + }) + return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS) + }, [sessions, q, stateFilter, typeFilter, advancedFilters]) return (
- - {/* filter bar */} -
- {/* search */} -
- - setSearch(e.target.value)} - placeholder="IP, AS, описание…" - className="h-8 pl-8 pr-8 w-52 text-xs" - /> - {search && ( - - )} -
- - {/* state filter */} -
- {STATE_FILTERS.map(f => ( - - ))} -
- - {/* type filter */} -
- {(["all", "eBGP", "iBGP"] as const).map(t => ( - - ))} -
- - - {filtered.length} из {sessions.length} - -
- - {/* table */} -
- - - - - ))} - - - - {filtered.map(s => { - const isOpen = expandedId === s.id - return ( - - setExpandedId(isOpen ? null : s.id)} - className={cn( - "cursor-pointer transition-colors", - isOpen ? "bg-muted/30" : "hover:bg-muted/20", - )}> - - - - - - - - - - - - {isOpen && ( - - - - )} - - ) - })} - - {filtered.length === 0 && ( - - - - )} - -
- {["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => ( - {h}
- {isOpen - ? - : } - {s.serverLabel}{s.peerIp} -
- AS{s.remoteAs} - {AS_NAMES[s.remoteAs] && ( - {AS_NAMES[s.remoteAs]} - )} -
-
{s.description} - {s.uptime ?? "—"} - - {s.prefixesRx > 0 - ? {fmtNum(s.prefixesRx)} - : } - - {s.prefixesTx > 0 - ? {fmtNum(s.prefixesTx)} - : } -
- -
- Нет сессий по заданным фильтрам -
-
+ s.state === "Established").length }, + { value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length }, + { value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length }, + { value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length }, + ], + }} + filters={advancedFilters} + onFiltersChange={setAdvancedFilters} + filterFields={BGP_FILTER_FIELDS} + search={search} + onSearchChange={setSearch} + searchPlaceholder="IP, AS, описание…" + countLabel={`${filtered.length} из ${sessions.length}`} + actions={ +
+ {(["all", "eBGP", "iBGP"] as const).map((t) => ( + + ))} +
+ } + /> +
) diff --git a/app/(main)/certificates/page.tsx b/app/(main)/certificates/page.tsx index db99095..108c0fe 100644 --- a/app/(main)/certificates/page.tsx +++ b/app/(main)/certificates/page.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" import { PageHeader } from "@/components/page-header" +import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" +import { FileImportDialog } from "@/components/file-import-dialog" import { routerCertificates, servers as mockServers } from "@/lib/data" import type { CertStatus, Server } from "@/lib/data" import type { CertificateDto } from "@mmapp/contracts/certificates" @@ -47,7 +49,19 @@ import { ChevronDownIcon, ChevronRightIcon, RefreshCwIcon, + UploadIcon, } from "lucide-react" +import { + Stepper, + StepperContent, + StepperIndicator, + StepperItem, + StepperNav, + StepperPanel, + StepperSeparator, + StepperTitle, + StepperTrigger, +} from "@/components/reui/stepper" const STATUS_CONFIG: Record< CertStatus, @@ -93,60 +107,6 @@ function daysLeftBar(days: number, total = 365): number { return Math.min(100, Math.round((days / total) * 100)) } -function Field({ - label, - hint, - required, - children, -}: { - label: string - hint?: string - required?: boolean - children: ReactNode -}) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ) -} - -function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { - return ( - - ) -} - -function SectionTitle({ children }: { children: ReactNode }) { - return ( -
- {children} -
-
- ) -} - function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto { return { id: cert.id, @@ -679,6 +639,7 @@ function CertPartIssueForm({ setIssueTrustWww, issueTrustApi, setIssueTrustApi, + step, }: { serverList: Server[] issueServerId: string @@ -693,12 +654,15 @@ function CertPartIssueForm({ setIssueTrustWww: (v: boolean) => void issueTrustApi: boolean setIssueTrustApi: (v: boolean) => void + step?: 1 | 2 | 3 | 4 }) { + const showAll = step == null return (
+ {(showAll || step === 1) && (
Основные - + - - + + setIssueCertName(e.target.value)} placeholder="router-le" /> - +
+ )} + {(showAll || step === 2) && (
Домены - + setIssueCommonName(e.target.value)} placeholder="vpn.example.com" /> - - + +