"use client" import { useCallback, useEffect, useMemo, useState } from "react" import Link from "next/link" import { PageHeader } from "@/components/page-header" import { FormToggle } from "@/components/form-kit" import { FileImportDialog } from "@/components/file-import-dialog" import { EvoBgpDataStatus } from "@/components/settings/evobgp-data-status" 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { SaveIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon, AlertCircleIcon, EyeIcon, EyeOffIcon, RefreshCwIcon, LoaderCircleIcon, DownloadIcon, UploadIcon, XIcon, } from "lucide-react" import { cn } from "@/lib/utils" import { requestJson } from "@/shared/api/http-client" import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database" import { toast } from "sonner" interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] } 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 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) } } function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) { return (

{label}

{description &&

{description}

}
{children}
) } function DatabaseRestoreConfirm({ open, filename, busy, onConfirm, onCancel, }: { open: boolean filename: string busy?: boolean onConfirm: () => void onCancel: () => void }) { return ( { if (!v && !busy) onCancel() }}> Восстановить базу приложения? Файл: {filename}. Текущие данные PostgreSQL на бекенде будут полностью заменены. Рекомендуется сначала скачать актуальный бэкап. Отмена {busy ? : null} {busy ? "Восстановление…" : "Восстановить"} ) } // ─── 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) // 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) }, []) 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() toast.success("Настройки EvoBGP сохранены") } catch (e) { const msg = e instanceof Error ? e.message : "Ошибка сохранения" setEvoSaveErr(msg) toast.error(msg) } 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}

)}
{systemDbAvailable ? Открыть NetFlow : }
Мин. выигрыш (переключение)
{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 и ключ хранятся в PostgreSQL на сервере бекенда ( 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}

)}
) // ── Уведомления ── if (section === "Уведомления") return (
{notifEmail &&
} {notifSlack &&
} {notifWh &&
}
) // ── 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)} />