"use client" import { useCallback, useEffect, useMemo, useState } from "react" import Link from "next/link" import { PageHeader } from "@/components/page-header" import { FormField, FormToggle } from "@/components/form-kit" import { FileImportDialog } from "@/components/file-import-dialog" import { OpsPanel } from "@/components/ops-panel" import { Frame, FramePanel } from "@/components/reui/frame" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { useDataSource } from "@/lib/data-source" import { useEvoBGP, type EvoBgpSavePayload, type EvoBgpTestDraft } from "@/lib/evobgp-context" import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data" import { Separator } from "@/components/ui/separator" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, } from "@/components/ui/sheet" import { Flag } from "@/components/flag" import { StatusDot } from "@/components/status-dot" import { servers } from "@/lib/data" import { SaveIcon, PencilIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon, XIcon, AlertCircleIcon, ShieldIcon, EyeIcon, EyeOffIcon, WrenchIcon, UserIcon, ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon, DownloadIcon, UploadIcon, } from "lucide-react" import { SubusersDataGrid } from "@/components/data-grids/subusers-data-grid" import { SettingsAccessSummaryDataGrid } from "@/components/data-grids/settings-access-summary-data-grid" import { DataPageCard } from "@/components/data-page-card" import { cn } from "@/lib/utils" import { requestJson } from "@/shared/api/http-client" import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database" import { toast } from "sonner" // ─── types ──────────────────────────────────────────────────────────────────── type Role = "admin" | "operator" | "viewer" type PermLevel = "none" | "read" | "write" interface SectionPerm { section: string; level: PermLevel } interface ServerPerm { serverId: string; level: PermLevel } interface User { id: string; name: string; login: string; email: string role: Role; last: string; avatar: string; active: boolean sections: SectionPerm[]; servers: ServerPerm[] subUsers: SubUser[] } interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] } // Подчинённый пользователь — учётка для подключения устройства к JH через GRE interface SubUser { id: string login: string password: string description: string // "офисный роутер", "склад", и т.п. jhServerIds: string[] // на каких JH зарегистрирована учётка clientIp: string // назначенный IP внутри туннеля active: boolean lastSeen: string | null } // ─── section definitions ────────────────────────────────────────────────────── const SECTION_GROUPS: { group: string; icon: React.ReactNode; items: string[] }[] = [ { group: "Обзор", icon: , items: ["Дашборд", "Трафик", "Карта сети", "Мониторинг"] }, { group: "Данные", icon: , items: ["Домены", "IP-диапазоны", "ASN", "Communities"] }, { group: "Управление", icon: , items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] }, { group: "Инструменты", icon: , items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] }, { group: "Система", icon: , items: ["Оповещения", "Сбор данных", "Настройки"] }, ] const ALL_SECTIONS = SECTION_GROUPS.flatMap(g => g.items) // ─── default permissions by role ────────────────────────────────────────────── function defaultSections(role: Role): SectionPerm[] { return ALL_SECTIONS.map(section => { let level: PermLevel = "none" if (role === "admin") level = "write" else if (role === "operator") level = ["Серверы","Фильтры","Firewall","GRE-туннели","Бэкапы","Диагностика GRE","Оптимизатор маршрутов","OSPF"].includes(section) ? "write" : "read" else if (role === "viewer") level = ["Настройки","Терминал"].includes(section) ? "none" : "read" return { section, level } }) } function defaultServers(role: Role): ServerPerm[] { return servers.map(s => ({ serverId: s.id, level: role === "admin" ? "write" : role === "operator" ? "read" : "read" as PermLevel, })) } // ─── initial data ────────────────────────────────────────────────────────────── const INIT_USERS: User[] = [ { id: "u1", name: "Александр Коротаев", login: "a.korotaev", email: "a.korotaev@company.io", role: "admin", last: "сейчас", avatar: "АК", active: true, sections: defaultSections("admin"), servers: defaultServers("admin"), subUsers: [ { id: "su1", login: "gre-office-msk", password: "xK9#mQ2$vLp8", description: "Офис MSK", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.2", active: true, lastSeen: "5м назад" }, { id: "su2", login: "gre-datacenter", password: "pW3@jT7!hD5k", description: "ЦОД Tier-2", jhServerIds: ["srv1"], clientIp: "10.210.0.6", active: true, lastSeen: "1ч назад" }, { id: "su3", login: "gre-warehouse", password: "rN6%bF2*cM8s", description: "Склад Химки", jhServerIds: ["srv7"], clientIp: "10.210.1.2", active: false, lastSeen: "3 дн назад" }, ], }, { id: "u2", name: "Дмитрий Фёдоров", login: "d.fedorov", email: "d.fedorov@company.io", role: "operator", last: "2ч назад", avatar: "ДФ", active: true, sections: defaultSections("operator"), servers: defaultServers("operator"), subUsers: [ { id: "su4", login: "gre-spb-branch", password: "qA4^eU9#wG1o", description: "Филиал SPB", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.10", active: true, lastSeen: "20м назад" }, ], }, { id: "u3", name: "Мария Соколова", login: "m.sokolova", email: "m.sokolova@company.io", role: "viewer", last: "вчера", avatar: "МС", active: true, sections: defaultSections("viewer"), servers: defaultServers("viewer"), subUsers: [], }, { id: "u4", name: "Игорь Петров", login: "i.petrov", email: "i.petrov@company.io", role: "operator", last: "3 дн назад", avatar: "ИП", active: false, sections: defaultSections("operator"), servers: defaultServers("operator"), subUsers: [ { id: "su5", login: "gre-retail-01", password: "bF7!nK3@mZ9v", description: "Магазин #1", jhServerIds: ["srv1"], clientIp: "10.210.0.14", active: false, lastSeen: null }, ], }, ] const INIT_API_KEYS: ApiKey[] = [ { id: "k1", name: "Monitoring Script", prefix: "rl_live_mU9k2…", created: "12 дн назад", last: "2м назад", scopes: ["servers:read", "probes:read"] }, { id: "k2", name: "CI/CD deploy hook", prefix: "rl_live_vB3p8…", created: "1 мес назад", last: "вчера", scopes: ["filters:write", "domains:write"] }, { id: "k3", name: "Grafana datasource", prefix: "rl_live_xK7r1…", created: "2 мес назад", last: "сейчас", scopes: ["traffic:read", "servers:read"] }, ] const ROLE_LABEL: Record = { admin: "Администратор", operator: "Оператор", viewer: "Наблюдатель" } const ROLE_COLOR: Record = { admin: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20", operator: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border border-sky-500/20", viewer: "bg-muted text-muted-foreground border border-border", } const PERM_OPTS: { v: PermLevel; label: string }[] = [ { v: "none", label: "Нет" }, { v: "read", label: "Просмотр" }, { v: "write", label: "Управление"}, ] const PERM_COLOR: Record = { none: "bg-muted text-muted-foreground", read: "bg-sky-500/10 text-sky-600 dark:text-sky-400", write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", } const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const type NavSection = typeof SECTIONS_NAV[number] function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) } } // ─── small components ───────────────────────────────────────────────────────── function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) { return (

{label}

{description &&

{description}

}
{children}
) } function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) { return (
{PERM_OPTS.map((o, i) => ( ))}
) } function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) { return (
{avatar}
{!active && ( )}
) } // ─── user sheet ─────────────────────────────────────────────────────────────── interface UserForm { name: string; login: string; email: string; role: Role; active: boolean sections: SectionPerm[]; servers: ServerPerm[] subUsers: SubUser[] } function emptyForm(): UserForm { return { name: "", login: "", email: "", role: "viewer", active: true, sections: defaultSections("viewer"), servers: defaultServers("viewer"), subUsers: [] } } function genPassword(): string { const chars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!@#$%" return Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join("") } function genSubLogin(userLogin: string, existing: SubUser[]): string { const base = `gre-${userLogin.split(".").pop() ?? "client"}` const n = existing.filter(s => s.login.startsWith(base)).length return n === 0 ? base : `${base}-${String(n + 1).padStart(2, "0")}` } function UserSheet({ open, user, onSave, onClose }: { open: boolean user: User | null // null = create onSave: (f: UserForm) => void onClose: () => void }) { const isCreate = user === null const [tab, setTab] = useState<"profile" | "sections" | "servers" | "subusers">("profile") const [form, setForm] = useState(() => user ? { name: user.name, login: user.login, email: user.email, role: user.role, active: user.active, sections: user.sections.map(s => ({ ...s })), servers: user.servers.map(s => ({ ...s })), subUsers: user.subUsers.map(s => ({ ...s })), } : emptyForm() ) // sub-user add form state const [addSubOpen, setAddSubOpen] = useState(false) const [newSubLogin, setNewSubLogin] = useState("") const [newSubPwd, setNewSubPwd] = useState("") const [newSubDesc, setNewSubDesc] = useState("") const [newSubJhs, setNewSubJhs] = useState([]) const [newSubIp, setNewSubIp] = useState("") const [revealedIds, setRevealedIds] = useState>(new Set()) const [errors, setErrors] = useState>>({}) const setField = (k: K, v: UserForm[K]) => { setForm(f => ({ ...f, [k]: v })) if (errors[k]) setErrors(e => ({ ...e, [k]: undefined })) } // When role changes → reset permissions to defaults const setRole = (role: Role) => { setForm(f => ({ ...f, role, sections: defaultSections(role), servers: defaultServers(role) })) } const setSectionPerm = (section: string, level: PermLevel) => { setForm(f => ({ ...f, sections: f.sections.map(s => s.section === section ? { ...s, level } : s), })) } const setServerPerm = (serverId: string, level: PermLevel) => { setForm(f => ({ ...f, servers: f.servers.map(s => s.serverId === serverId ? { ...s, level } : s), })) } const setAllSections = (level: PermLevel) => { setForm(f => ({ ...f, sections: f.sections.map(s => ({ ...s, level })) })) } const setAllServers = (level: PermLevel) => { setForm(f => ({ ...f, servers: f.servers.map(s => ({ ...s, level })) })) } const validate = () => { const e: Partial> = {} if (!form.name.trim()) e.name = "Обязательное поле" if (!form.login.trim()) e.login = "Обязательное поле" if (!form.email.trim()) e.email = "Обязательное поле" setErrors(e) return !Object.keys(e).length } const handleSave = () => { if (validate()) onSave(form) } const isAdmin = form.role === "admin" const jhServers = servers.filter(s => s.type === "jump-host" && s.enabled) const addSubUser = () => { if (!newSubLogin.trim() || !newSubPwd.trim() || newSubJhs.length === 0) return const sub: SubUser = { id: `su${Date.now()}`, login: newSubLogin.trim(), password: newSubPwd, description: newSubDesc.trim(), jhServerIds: [...newSubJhs], clientIp: newSubIp.trim(), active: true, lastSeen: null, } setForm(f => ({ ...f, subUsers: [...f.subUsers, sub] })) setAddSubOpen(false) setNewSubLogin(""); setNewSubPwd(""); setNewSubDesc(""); setNewSubIp(""); setNewSubJhs([]) } const toggleNewSubJh = (id: string) => setNewSubJhs(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) const removeSubUser = (id: string) => setForm(f => ({ ...f, subUsers: f.subUsers.filter(s => s.id !== id) })) const toggleSubUser = (id: string) => setForm(f => ({ ...f, subUsers: f.subUsers.map(s => s.id === id ? { ...s, active: !s.active } : s) })) const toggleReveal = (id: string) => setRevealedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next }) const TABS = [ { id: "profile", label: "Профиль" }, { id: "sections", label: "Разделы" }, { id: "servers", label: "Серверы" }, { id: "subusers", label: "GRE-клиенты", badge: form.subUsers.length || undefined }, ] as const return ( { if (!v) onClose() }}> {/* header */}
{!isCreate && user && ( )}
{isCreate ? "Новый пользователь" : form.name || "—"} {isCreate ? "Заполните данные и настройте доступ" : `@${form.login}`}
{/* tab bar */}
{TABS.map(t => ( ))}
{/* body */}
{/* ── Profile tab ── */} {tab === "profile" && (
setField("name", e.target.value)} placeholder="Иван Иванов" className="h-9" /> setField("login", e.target.value)} placeholder="i.ivanov" className="h-9 font-mono" /> setField("email", e.target.value)} placeholder="i.ivanov@company.io" type="email" className="h-9" />
{(["viewer", "operator", "admin"] as Role[]).map(r => ( ))}

{form.role === "admin" ? "Полный доступ ко всем разделам и серверам, не ограничивается матрицей прав" : form.role === "operator" ? "Управление инфраструктурой согласно выданным правам" : "Только просмотр согласно выданным правам"}

setField("active", v)} /> {form.active ? "Активна" : "Заблокирована"}
)} {/* ── Sections tab ── */} {tab === "sections" && (
{/* bulk actions */}
Выбрать всё: {PERM_OPTS.map(o => ( ))} {isAdmin && ( Администратор имеет полный доступ )}
{SECTION_GROUPS.map(group => (
{/* group header */}
{group.icon} {group.group}
{/* section rows */}
{group.items.map(section => { const perm = form.sections.find(s => s.section === section) const level = perm?.level ?? "none" return (
{section} setSectionPerm(section, v)} disabled={isAdmin} />
) })}
))}
)} {/* ── Servers tab ── */} {tab === "servers" && (
{/* bulk actions */}
Выбрать всё: {PERM_OPTS.map(o => ( ))} {isAdmin && ( Администратор имеет полный доступ )}
{servers.map(srv => { const perm = form.servers.find(s => s.serverId === srv.id) const level = perm?.level ?? "none" return (
{srv.country && }

{srv.name}

{srv.host} · {srv.site}

setServerPerm(srv.id, v)} disabled={isAdmin} />
) })}
)} {/* ── GRE-клиенты tab ── */} {tab === "subusers" && (
{/* hint */}

Учётные записи для подключения устройств к JH через GRE

{/* inline add form */} {addSubOpen ? (

Новый GRE-клиент

setNewSubLogin(e.target.value)} />
setNewSubDesc(e.target.value)} />
setNewSubPwd(e.target.value)} />
{jhServers.map(s => { const checked = newSubJhs.includes(s.id) return ( ) })}
{newSubJhs.length === 0 && (

Выберите хотя бы один JH-сервер

)}
setNewSubIp(e.target.value)} />
) : ( )}
)}
{/* footer */}
) } // ─── delete confirm ─────────────────────────────────────────────────────────── function DatabaseRestoreConfirm({ filename, busy, onConfirm, onCancel, }: { filename: string busy?: boolean onConfirm: () => void onCancel: () => void }) { return (

Восстановить базу приложения?

{filename}

Текущие данные SQLite на бекенде будут полностью заменены содержимым файла. Рекомендуется сначала скачать актуальный бэкап.

) } function DeleteConfirm({ user, onConfirm, onCancel }: { user: User; onConfirm: () => void; onCancel: () => void }) { return (

Удалить пользователя?

{user.name} · @{user.login}

Это действие нельзя отменить. Пользователь потеряет доступ немедленно.

) } // ─── page ───────────────────────────────────────────────────────────────────── export default function SettingsPage() { const [section, setSection] = useState("Общие") const [saved, setSaved] = useState(false) const [copied, setCopied] = useState(null) // data source const { mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, backendStatus, checkBackend } = useDataSource() const evo = useEvoBGP() const [evoBaseDraft, setEvoBaseDraft] = useState("") const [evoEnabledDraft, setEvoEnabledDraft] = useState(false) const [evoKeyDraft, setEvoKeyDraft] = useState("") const [evoSaveBusy, setEvoSaveBusy] = useState(false) const [evoSaveErr, setEvoSaveErr] = useState(null) const [urlDraft, setUrlDraft] = useState(backendUrl) const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) useEffect(() => { setUrlDraft(backendUrl) }, [backendUrl]) const commitBackendUrl = useCallback(() => { const normalized = urlDraft.trim().replace(/\/$/, "") setUrlDraft(normalized) setBackendUrl(normalized) void checkBackend() }, [urlDraft, setBackendUrl, checkBackend]) const [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null) const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null) const [showEvoKey, setShowEvoKey] = useState(false) // general const [lang, setLang] = useState("ru") const [theme, setTheme] = useState("system") const [timezone, setTimezone] = useState("Europe/Moscow") const [refreshSec, setRefreshSec] = useState("30") const [probeTimeout, setProbeTimeout] = useState("5") const [dbBackupBusy, setDbBackupBusy] = useState(false) const [dbRestoreBusy, setDbRestoreBusy] = useState(false) const [dbRestoreFile, setDbRestoreFile] = useState(null) const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false) // notifications const [notifEmail, setNotifEmail] = useState(true) const [notifSlack, setNotifSlack] = useState(false) const [notifWh, setNotifWh] = useState(true) const [notifDegr, setNotifDegr] = useState(true) const [notifOffline, setNotifOffline] = useState(true) const [notifBgp, setNotifBgp] = useState(true) const [notifBackup, setNotifBackup] = useState(false) // users const [users, setUsers] = useState(INIT_USERS) const [sheetOpen, setSheetOpen] = useState(false) const [editUser, setEditUser] = useState(null) // null = create mode const [deleteTarget, setDeleteTarget]= useState(null) // api keys const [apiKeys, setApiKeys] = useState(INIT_API_KEYS) // security const [mfa, setMfa] = useState(true) const [sessMin, setSessMin] = useState("480") const [ipAllow, setIpAllow] = useState("10.0.0.0/8\n192.168.0.0/16") const [auditLog, setAuditLog] = useState(true) const handleCopy = (text: string) => { navigator.clipboard.writeText(text).catch(() => {}) setCopied(text); setTimeout(() => setCopied(null), 1500) toast.success("Скопировано в буфер обмена") } const markSaved = useCallback(() => { setSaved(true) toast.success("Настройки сохранены") setTimeout(() => setSaved(false), 2000) }, []) const handleUserSave = (form: UserForm) => { if (!editUser) { const initials = form.name.split(" ").map(p => p[0] ?? "").slice(0, 2).join("").toUpperCase() setUsers(prev => [...prev, { id: "u" + Date.now(), ...form, last: "только что", avatar: initials || "??", }]) } else { setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form } : u)) } setSheetOpen(false) } // total sub-users count for summary const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0) useEffect(() => { if (section !== "EvoBGP") return if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() }) }, [section, mode, backendStatus, evo.loadSettings]) useEffect(() => { if (section !== "EvoBGP" || !evo.settingsLoaded) return setEvoBaseDraft(evo.baseUrl) setEvoEnabledDraft(evo.enabled) setEvoKeyDraft("") setEvoSaveErr(null) }, [section, evo.settingsLoaded, evo.baseUrl, evo.enabled]) const handleSave = useCallback(async () => { if (section === "EvoBGP") { if (mode !== "live" || backendStatus !== true) { markSaved() return } setEvoSaveBusy(true) setEvoSaveErr(null) try { const patch: EvoBgpSavePayload = { baseUrl: evoBaseDraft.trim(), enabled: evoEnabledDraft, } if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim() await evo.saveSettings(patch) setEvoKeyDraft("") markSaved() } catch (e) { setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения") } finally { setEvoSaveBusy(false) } return } markSaved() }, [ section, mode, backendStatus, evoBaseDraft, evoEnabledDraft, evoKeyDraft, markSaved, evo.saveSettings, ]) const systemDbAvailable = mode === "live" && backendStatus === true const handleSystemDatabaseBackup = useCallback(async () => { if (!systemDbAvailable) return setDbBackupBusy(true) try { const { blob, filename } = await downloadSystemDatabaseBackup(backendUrl) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = filename a.click() URL.revokeObjectURL(url) toast.success("Бэкап базы приложения скачан") } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось создать бэкап") } finally { setDbBackupBusy(false) } }, [backendUrl, systemDbAvailable]) const handleSystemDatabaseRestoreConfirm = useCallback(async () => { if (!dbRestoreFile || !systemDbAvailable) return setDbRestoreBusy(true) try { await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile) toast.success("База приложения восстановлена") setDbRestoreFile(null) } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу") } finally { setDbRestoreBusy(false) } }, [backendUrl, dbRestoreFile, systemDbAvailable]) const renderContent = () => { const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS // ── Общие ── if (section === "Общие") return (
{/* ── Источник данных ── */} {/* mode toggle */} {mockModeAvailable ? (
{([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => ( ))}
) : ( Живые )} {/* backend URL — visible in both modes so user can configure before switching */}
setUrlDraft(e.target.value)} onBlur={commitBackendUrl} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault() commitBackendUrl() } }} readOnly={backendUrlLocked} placeholder="http://localhost:8000" />
{/* connection status */}
{backendStatus === undefined && ( Не проверялось )} {backendStatus === true && ( Доступен )} {backendStatus === false && ( Недоступен )}
{/* hint when live but backend unreachable */} {mode === "live" && backendStatus === false && (
Бекенд недоступен — данные будут отображаться из кеша или моков. Запустите бекенд: cd backend && npm run dev
)}
{/* ── Основные настройки ── */}
{[["light","Светлая"],["dark","Тёмная"],["system","Авто"]].map(([v, l]) => ( ))}
setRefreshSec(e.target.value)} /> setProbeTimeout(e.target.value)} />
{!systemDbAvailable && (
Доступно только в live-режиме при доступном бекенде.
)}
{dbRestoreFile && (

{dbRestoreFile.name}

)}
Мин. выигрыш (переключение)
{ra.switchThreshold}%
Гистерезис
{ra.hysteresisThreshold}%
Вес задержки (ping)
{ra.pingWeight}%
Интервал зондирования
{ra.probeIntervalMin} мин
Автоприменение
{ra.autoApply ? `да, каждые ${ra.autoApplyIntervalMin} мин` : "нет"}
Открыть оптимизатор маршрутов
) // ── EvoBGP ── if (section === "EvoBGP") return (
{(mode !== "live" || backendStatus !== true) && (

Интеграция доступна в live-режиме

Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).

)} Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "} access.md . URL и ключ хранятся в SQLite на сервере бекенда ( evobgp_settings ). Каталог —{" "} GET /v1/router-lists/catalog через прокси. } contentClassName="divide-y px-5 flex flex-col gap-4 pb-5" > setEvoBaseDraft(e.target.value)} placeholder="http://localhost:8080" disabled={mode !== "live" || backendStatus !== true || evoSaveBusy} />
setEvoKeyDraft(e.target.value)} placeholder={evo.secretConfigured ? "Оставьте пустым, чтобы не менять" : "Bearer-токен"} disabled={mode !== "live" || backendStatus !== true || evoSaveBusy} />
setEvoEnabledDraft(v)} />
{evoSaveErr &&

{evoSaveErr}

}

«Проверить ключ» использует поля формы; незаполненное поле подставляется из БД. «Обновить каталог» — только по сохранённым в БД настройкам.

{evo.snapshot?.fetchedAt && ( Загружено: {new Date(evo.snapshot.fetchedAt).toLocaleString("ru-RU")} )}
{evoTestResult && (

{evoTestResult.message}

)} {evo.error && (

{evo.error}

)}
) // ── Уведомления ── if (section === "Уведомления") return (
{notifEmail &&
} {notifSlack &&
} {notifWh &&
}
) // ── Пользователи ── if (section === "Пользователи") return (

{users.length} пользователей · {users.filter(u => u.active).length} активных {totalSubUsers > 0 && ` · ${totalSubUsers} GRE-клиентов`}

{/* table header */}
Пользователь Роль Последний вход Статус
{users.map(u => { return (
{/* user */}

{u.name}

@{u.login}

{u.subUsers.length > 0 && ( {u.subUsers.length} )}
{/* role */} {ROLE_LABEL[u.role]} {/* last */} {u.last} {/* status */} {u.active ? "Активен" : "Заблокирован"} {/* actions */}
{u.id !== "u1" && ( )}
) })}
{/* access summary */}
Сводка прав доступа
) // ── API-ключи ── if (section === "API-ключи") return (
{apiKeys.map(k => (

{k.name}

{k.prefix}
{k.scopes.map(s => ( {s} ))}

Создан: {k.created}

Использован: {k.last}

))}
{[ ["GET", "/servers", "Список серверов"], ["GET", "/traffic", "Данные трафика"], ["GET", "/filters", "Фильтры маршрутов"], ["POST", "/filters", "Создать фильтр"], ["GET", "/probes", "Диагностические пробы"], ["POST", "/probes/run", "Запустить тест"], ].map(([method, path, desc], i) => (
{method} {path} {desc}
))}
) // ── Безопасность ── if (section === "Безопасность") return (
setSessMin(e.target.value)} />