Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Failing after 1m31s
Docker images / frontend-image (push) Successful in 2m36s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Skipped
Считать payload отдельно от overlay GRE/ESP и mesh; вкладка Пути и KPI Wire из счётчиков iface. Co-authored-by: Cursor <cursoragent@cursor.com>
952 lines
43 KiB
TypeScript
952 lines
43 KiB
TypeScript
"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 { 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 { purgeTrafficFlowData } from "@/shared/api/traffic-flow"
|
||
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
|
||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||
return requestJson<T>(backendUrl, path, init)
|
||
}
|
||
}
|
||
|
||
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-4 py-3.5">
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-medium">{label}</p>
|
||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||
</div>
|
||
<div className="shrink-0">{children}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DatabaseRestoreConfirm({
|
||
open,
|
||
filename,
|
||
busy,
|
||
onConfirm,
|
||
onCancel,
|
||
}: {
|
||
open: boolean
|
||
filename: string
|
||
busy?: boolean
|
||
onConfirm: () => void
|
||
onCancel: () => void
|
||
}) {
|
||
return (
|
||
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
|
||
<AlertDialogContent size="default">
|
||
<AlertDialogHeader>
|
||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||
<AlertCircleIcon />
|
||
</AlertDialogMedia>
|
||
<AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Файл: {filename}. Текущие данные SQLite на бекенде будут полностью заменены.
|
||
Рекомендуется сначала скачать актуальный бэкап.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
variant="destructive"
|
||
disabled={busy}
|
||
onClick={onConfirm}
|
||
>
|
||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||
{busy ? "Восстановление…" : "Восстановить"}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
)
|
||
}
|
||
|
||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||
|
||
export default function SettingsPage() {
|
||
const [section, setSection] = useState<NavSection>("Общие")
|
||
const [saved, setSaved] = useState(false)
|
||
const [copied, setCopied] = useState<string | null>(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<string | null>(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<File | null>(null)
|
||
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
|
||
const [dbPurgeOpen, setDbPurgeOpen] = useState(false)
|
||
const [dbPurgeBusy, setDbPurgeBusy] = 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<ApiKey[]>(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 handleNetflowPurgeConfirm = useCallback(async () => {
|
||
if (!systemDbAvailable) return
|
||
setDbPurgeBusy(true)
|
||
try {
|
||
const result = await purgeTrafficFlowData(backendUrl)
|
||
toast.success(formatFlowPurgeResult(result))
|
||
setDbPurgeOpen(false)
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow")
|
||
} finally {
|
||
setDbPurgeBusy(false)
|
||
}
|
||
}, [backendUrl, systemDbAvailable])
|
||
|
||
const renderContent = () => {
|
||
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||
|
||
// ── Общие ──
|
||
if (section === "Общие") return (
|
||
<div className="flex flex-col gap-4">
|
||
|
||
{/* ── Источник данных ── */}
|
||
<OpsPanel
|
||
title="Источник данных"
|
||
description={
|
||
mockModeAvailable
|
||
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts"
|
||
: "В продакшен-образе доступны только живые данные от бекенда"
|
||
}
|
||
contentClassName="divide-y px-5"
|
||
>
|
||
|
||
{/* mode toggle */}
|
||
{mockModeAvailable ? (
|
||
<SettingRow label="Режим данных" description="Моковые — статичные тестовые данные; Живые — RouterOS REST API через бекенд">
|
||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||
{([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => (
|
||
<button key={v} onClick={() => setMode(v)}
|
||
className={cn(
|
||
"px-3 text-xs transition-colors border-input",
|
||
i === 0 && "border-r",
|
||
mode === v
|
||
? "bg-primary text-primary-foreground"
|
||
: "text-muted-foreground hover:bg-muted",
|
||
)}>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</SettingRow>
|
||
) : (
|
||
<SettingRow label="Режим данных" description="В Docker-образе приложение работает только с живыми данными бекенда">
|
||
<span className="inline-flex h-8 items-center rounded-md border border-input bg-primary px-3 text-xs text-primary-foreground">
|
||
Живые
|
||
</span>
|
||
</SettingRow>
|
||
)}
|
||
|
||
{/* backend URL — visible in both modes so user can configure before switching */}
|
||
<SettingRow
|
||
label="URL бекенда"
|
||
description={
|
||
backendUrlLocked
|
||
? "В Docker-образе URL API задаётся через прокси фронтенда на backend; поле только для просмотра."
|
||
: "Адрес Node.js/Fastify сервера (запускается командой npm run dev в /backend)"
|
||
}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
className="w-56 h-8 text-sm font-mono"
|
||
value={urlDraft}
|
||
onChange={e => setUrlDraft(e.target.value)}
|
||
onBlur={commitBackendUrl}
|
||
onKeyDown={e => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault()
|
||
commitBackendUrl()
|
||
}
|
||
}}
|
||
readOnly={backendUrlLocked}
|
||
placeholder="http://localhost:8000"
|
||
/>
|
||
<Button variant="outline" size="sm" className="h-8 px-2.5 shrink-0"
|
||
onClick={commitBackendUrl}
|
||
title="Проверить соединение">
|
||
<RefreshCwIcon className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
</SettingRow>
|
||
|
||
{/* connection status */}
|
||
<SettingRow label="Статус бекенда">
|
||
<div className="flex items-center gap-2">
|
||
{backendStatus === undefined && (
|
||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
<span className="size-2 rounded-full bg-muted-foreground/40 inline-block" />
|
||
Не проверялось
|
||
</span>
|
||
)}
|
||
{backendStatus === true && (
|
||
<span className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
|
||
<CheckIcon className="size-3.5" />Доступен
|
||
</span>
|
||
)}
|
||
{backendStatus === false && (
|
||
<span className="flex items-center gap-1.5 text-xs text-destructive">
|
||
<XIcon className="size-3.5" />Недоступен
|
||
</span>
|
||
)}
|
||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground"
|
||
onClick={checkBackend}>
|
||
Проверить
|
||
</Button>
|
||
</div>
|
||
</SettingRow>
|
||
|
||
{/* hint when live but backend unreachable */}
|
||
{mode === "live" && backendStatus === false && (
|
||
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
|
||
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
||
<span>Бекенд недоступен — данные будут отображаться из кеша или моков. Запустите бекенд: <code className="font-mono bg-muted px-1 rounded">cd backend && npm run dev</code></span>
|
||
</div>
|
||
)}
|
||
</OpsPanel>
|
||
|
||
{/* ── Основные настройки ── */}
|
||
<OpsPanel title="Основные настройки" contentClassName="divide-y px-5">
|
||
<SettingRow label="Язык интерфейса">
|
||
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||
value={lang} onChange={e => setLang(e.target.value)}>
|
||
<option value="ru">Русский</option>
|
||
<option value="en">English</option>
|
||
</select>
|
||
</SettingRow>
|
||
<SettingRow label="Тема оформления">
|
||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||
{[["light","Светлая"],["dark","Тёмная"],["system","Авто"]].map(([v, l]) => (
|
||
<button key={v} onClick={() => setTheme(v)}
|
||
className={cn("px-3 text-xs transition-colors border-r last:border-r-0 border-input",
|
||
theme === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</SettingRow>
|
||
<SettingRow label="Часовой пояс">
|
||
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||
value={timezone} onChange={e => setTimezone(e.target.value)}>
|
||
{["Europe/Moscow","Europe/Berlin","Europe/Amsterdam","Asia/Singapore","UTC"].map(z => (
|
||
<option key={z} value={z}>{z}</option>
|
||
))}
|
||
</select>
|
||
</SettingRow>
|
||
<SettingRow label="Интервал автообновления (сек)"
|
||
description="Как часто страницы обновляют данные">
|
||
<Input className="w-20 h-8 text-sm" value={refreshSec} onChange={e => setRefreshSec(e.target.value)} />
|
||
</SettingRow>
|
||
<SettingRow label="Таймаут диагностики (сек)"
|
||
description="Максимальное время ожидания при probe-тестах">
|
||
<Input className="w-20 h-8 text-sm" value={probeTimeout} onChange={e => setProbeTimeout(e.target.value)} />
|
||
</SettingRow>
|
||
</OpsPanel>
|
||
|
||
<OpsPanel
|
||
title="База данных приложения"
|
||
description="Резервная копия SQLite бекенда и сброс таблиц NetFlow. На время операции планировщик и коллектор IPFIX приостанавливаются. Preview: https://reui.io/preview/base/settings-16"
|
||
contentClassName="divide-y px-5"
|
||
>
|
||
{!systemDbAvailable && (
|
||
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
|
||
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
||
<span>Доступно только в live-режиме при доступном бекенде.</span>
|
||
</div>
|
||
)}
|
||
<SettingRow
|
||
label="Скачать бэкап"
|
||
description="Консистентная копия файла mikrotik.db"
|
||
>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8"
|
||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
||
onClick={() => { void handleSystemDatabaseBackup() }}
|
||
>
|
||
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
|
||
{dbBackupBusy ? "Подготовка…" : "Скачать"}
|
||
</Button>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="Восстановить из файла"
|
||
description="Полностью заменяет текущую базу SQLite"
|
||
>
|
||
<div className="flex flex-col items-end gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8"
|
||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
||
onClick={() => setDbRestoreDialogOpen(true)}
|
||
>
|
||
<UploadIcon className="size-4" />
|
||
Выбрать файл
|
||
</Button>
|
||
{dbRestoreFile && (
|
||
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
|
||
)}
|
||
</div>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="Сбросить данные NetFlow"
|
||
description="Удалит сессии и агрегаты из SQLite, затем VACUUM. Ключи WG и пиры не трогает"
|
||
>
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
className="h-8"
|
||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
||
onClick={() => setDbPurgeOpen(true)}
|
||
>
|
||
{dbPurgeBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <TrashIcon className="size-4" />}
|
||
{dbPurgeBusy ? "Сброс…" : "Сбросить"}
|
||
</Button>
|
||
</SettingRow>
|
||
</OpsPanel>
|
||
|
||
<OpsPanel
|
||
id="route-ai"
|
||
className="scroll-mt-4"
|
||
title="Route AI"
|
||
description="Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и веса настраиваются в инструменте, не через отдельный API."
|
||
contentClassName="flex flex-col gap-4 px-5 pb-5"
|
||
>
|
||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
|
||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
|
||
<dd className="font-mono tabular-nums">{ra.switchThreshold}%</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||
<dt className="text-muted-foreground">Гистерезис</dt>
|
||
<dd className="font-mono tabular-nums">{ra.hysteresisThreshold}%</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||
<dt className="text-muted-foreground">Вес задержки (ping)</dt>
|
||
<dd className="font-mono tabular-nums">{ra.pingWeight}%</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||
<dt className="text-muted-foreground">Интервал зондирования</dt>
|
||
<dd className="font-mono tabular-nums">{ra.probeIntervalMin} мин</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5 sm:col-span-2">
|
||
<dt className="text-muted-foreground">Автоприменение</dt>
|
||
<dd>{ra.autoApply ? `да, каждые ${ra.autoApplyIntervalMin} мин` : "нет"}</dd>
|
||
</div>
|
||
</dl>
|
||
<Link
|
||
href="/route-optimizer"
|
||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||
>
|
||
Открыть оптимизатор маршрутов
|
||
</Link>
|
||
</OpsPanel>
|
||
|
||
</div>
|
||
)
|
||
|
||
// ── EvoBGP ──
|
||
if (section === "EvoBGP") return (
|
||
<div className="flex flex-col gap-4">
|
||
{(mode !== "live" || backendStatus !== true) && (
|
||
<Frame dense className="w-full">
|
||
<FramePanel className="px-4 py-4">
|
||
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
|
||
</p>
|
||
</FramePanel>
|
||
</Frame>
|
||
)}
|
||
|
||
<OpsPanel
|
||
title="EvoBGP API"
|
||
description={
|
||
<>
|
||
Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "}
|
||
<a
|
||
className="underline underline-offset-2"
|
||
href="https://git.shx.one/denozord/EvoBGP/src/branch/main/docs/access.md"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
access.md
|
||
</a>
|
||
. URL и ключ хранятся в SQLite на сервере бекенда (
|
||
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
|
||
). Каталог —{" "}
|
||
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
|
||
</>
|
||
}
|
||
contentClassName="divide-y px-5 flex flex-col gap-4 pb-5"
|
||
>
|
||
<SettingRow
|
||
label="Базовый URL API"
|
||
description="Например http://control.example:8080 — без суффикса /v1"
|
||
>
|
||
<Input
|
||
className="w-full max-w-md h-8 text-sm font-mono"
|
||
value={evoBaseDraft}
|
||
onChange={(e) => setEvoBaseDraft(e.target.value)}
|
||
placeholder="http://localhost:8080"
|
||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="API-ключ"
|
||
description={
|
||
evo.secretConfigured
|
||
? "В БД уже есть ключ — введите новый только для замены"
|
||
: "Сохраняется общей кнопкой «Сохранить» в шапке страницы"
|
||
}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
className="w-full max-w-md h-8 text-sm font-mono"
|
||
type={showEvoKey ? "text" : "password"}
|
||
autoComplete="off"
|
||
value={evoKeyDraft}
|
||
onChange={(e) => setEvoKeyDraft(e.target.value)}
|
||
placeholder={evo.secretConfigured ? "Оставьте пустым, чтобы не менять" : "Bearer-токен"}
|
||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 shrink-0"
|
||
onClick={() => setShowEvoKey((v) => !v)}
|
||
>
|
||
{showEvoKey ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||
</Button>
|
||
</div>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="Подставлять данные EvoBGP"
|
||
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
||
>
|
||
<FormToggle
|
||
checked={evoEnabledDraft}
|
||
onChange={(v) => setEvoEnabledDraft(v)}
|
||
/>
|
||
</SettingRow>
|
||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 text-destructive border-destructive/40 hover:bg-destructive/10"
|
||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy || !evo.secretConfigured}
|
||
onClick={async () => {
|
||
setEvoSaveBusy(true)
|
||
setEvoSaveErr(null)
|
||
try {
|
||
await evo.saveSettings({ apiKey: null })
|
||
setEvoKeyDraft("")
|
||
markSaved()
|
||
} catch (e) {
|
||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
|
||
} finally {
|
||
setEvoSaveBusy(false)
|
||
}
|
||
}}
|
||
>
|
||
Удалить ключ из БД
|
||
</Button>
|
||
</div>
|
||
{evoSaveErr && <p className="text-xs text-destructive">{evoSaveErr}</p>}
|
||
<p className="text-[11px] text-muted-foreground">
|
||
«Проверить ключ» использует поля формы; незаполненное поле подставляется из БД. «Обновить каталог» — только по сохранённым в БД настройкам.
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8"
|
||
disabled={
|
||
mode !== "live" ||
|
||
backendStatus !== true ||
|
||
evoBusy !== null ||
|
||
evoSaveBusy ||
|
||
!(
|
||
(evoBaseDraft.trim() || evo.baseUrl.trim()) &&
|
||
(evoKeyDraft.trim() || evo.secretConfigured)
|
||
)
|
||
}
|
||
onClick={async () => {
|
||
setEvoBusy("test")
|
||
setEvoTestResult(null)
|
||
const b = evoBaseDraft.trim()
|
||
const k = evoKeyDraft.trim()
|
||
let draft: EvoBgpTestDraft | undefined
|
||
if (b || k) {
|
||
draft = {}
|
||
if (b) draft.baseUrl = b
|
||
if (k) draft.apiKey = k
|
||
}
|
||
const r = await evo.testConnection(draft)
|
||
setEvoTestResult({ ok: r.ok, message: r.message })
|
||
setEvoBusy(null)
|
||
}}
|
||
>
|
||
{evoBusy === "test" ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||
Проверить ключ
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8"
|
||
disabled={mode !== "live" || backendStatus !== true || evoBusy !== null || evoSaveBusy || !evo.enabled}
|
||
onClick={async () => {
|
||
setEvoBusy("refresh")
|
||
await evo.refresh()
|
||
setEvoBusy(null)
|
||
}}
|
||
>
|
||
{evoBusy === "refresh" || evo.loading ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||
Обновить каталог
|
||
</Button>
|
||
{evo.snapshot?.fetchedAt && (
|
||
<span className="text-xs text-muted-foreground">
|
||
Загружено: {new Date(evo.snapshot.fetchedAt).toLocaleString("ru-RU")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{evoTestResult && (
|
||
<p className={cn(
|
||
"text-xs",
|
||
evoTestResult.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive",
|
||
)}>
|
||
{evoTestResult.message}
|
||
</p>
|
||
)}
|
||
{evo.error && (
|
||
<p className="text-xs text-destructive">{evo.error}</p>
|
||
)}
|
||
</OpsPanel>
|
||
</div>
|
||
)
|
||
|
||
// ── Уведомления ──
|
||
if (section === "Уведомления") return (
|
||
<div className="flex flex-col gap-4">
|
||
<OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5">
|
||
<SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io">
|
||
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
|
||
</SettingRow>
|
||
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="admin@routerlists.io" /></div>}
|
||
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
|
||
<FormToggle checked={notifSlack} onChange={setNotifSlack} />
|
||
</SettingRow>
|
||
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
|
||
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
|
||
<FormToggle checked={notifWh} onChange={setNotifWh} />
|
||
</SettingRow>
|
||
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
||
</OpsPanel>
|
||
<OpsPanel title="Триггеры" contentClassName="divide-y px-5">
|
||
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
||
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
|
||
</SettingRow>
|
||
<SettingRow label="Узел ушёл offline">
|
||
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
|
||
</SettingRow>
|
||
<SettingRow label="Падение BGP-сессии">
|
||
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
|
||
</SettingRow>
|
||
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
||
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
|
||
</SettingRow>
|
||
</OpsPanel>
|
||
</div>
|
||
)
|
||
|
||
// ── API-ключи ──
|
||
if (section === "API-ключи") return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex justify-end">
|
||
<Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button>
|
||
</div>
|
||
<div className="flex flex-col gap-3">
|
||
{apiKeys.map(k => (
|
||
<Frame key={k.id} dense className="w-full">
|
||
<FramePanel className="pt-4 pb-3 px-4">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-medium">{k.name}</p>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{k.prefix}</code>
|
||
<button onClick={() => handleCopy(k.prefix)} className="text-muted-foreground/40 hover:text-muted-foreground transition-colors">
|
||
{copied === k.prefix ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1 mt-2">
|
||
{k.scopes.map(s => (
|
||
<span key={s} className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{s}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="text-right shrink-0">
|
||
<p className="text-xs text-muted-foreground">Создан: {k.created}</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">Использован: {k.last}</p>
|
||
<div className="flex gap-1 justify-end mt-2">
|
||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs">Переиздать</Button>
|
||
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"
|
||
onClick={() => setApiKeys(p => p.filter(x => x.id !== k.id))}>
|
||
<TrashIcon className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</FramePanel>
|
||
</Frame>
|
||
))}
|
||
</div>
|
||
<OpsPanel
|
||
title="Документация API"
|
||
description="Base URL: https://api.routerlists.io/v1"
|
||
contentClassName="px-4 pb-4"
|
||
>
|
||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||
{[
|
||
["GET", "/servers", "Список серверов"],
|
||
["GET", "/traffic", "Данные трафика"],
|
||
["GET", "/filters", "Фильтры маршрутов"],
|
||
["POST", "/filters", "Создать фильтр"],
|
||
["GET", "/probes", "Диагностические пробы"],
|
||
["POST", "/probes/run", "Запустить тест"],
|
||
].map(([method, path, desc], i) => (
|
||
<div key={`${method}-${path}-${i}`} className="flex items-baseline gap-2">
|
||
<span className={cn("font-mono text-[10px] font-bold w-8 shrink-0",
|
||
method === "GET" ? "text-emerald-500" : "text-sky-500")}>{method}</span>
|
||
<code className="font-mono text-muted-foreground">{path}</code>
|
||
<span className="text-muted-foreground/60 text-[10px]">{desc}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</OpsPanel>
|
||
</div>
|
||
)
|
||
|
||
// ── Безопасность ──
|
||
if (section === "Безопасность") return (
|
||
<div className="flex flex-col gap-4">
|
||
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
|
||
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
||
description="TOTP / Authenticator app для всех администраторов">
|
||
<FormToggle checked={mfa} onChange={setMfa} />
|
||
</SettingRow>
|
||
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
||
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
||
</SettingRow>
|
||
</OpsPanel>
|
||
<OpsPanel
|
||
title="Allowlist IP-адресов"
|
||
description="Доступ разрешён только с этих сетей. Одна запись на строку."
|
||
contentClassName="px-4 pb-4"
|
||
>
|
||
<textarea value={ipAllow} onChange={e => setIpAllow(e.target.value)} rows={4}
|
||
className="w-full rounded-md border bg-muted px-3 py-2 text-xs font-mono resize-none outline-none focus:ring-1 focus:ring-ring" />
|
||
</OpsPanel>
|
||
<OpsPanel title="Аудит" contentClassName="divide-y px-5">
|
||
<SettingRow label="Расширенный журнал аудита"
|
||
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
||
<FormToggle checked={auditLog} onChange={setAuditLog} />
|
||
</SettingRow>
|
||
</OpsPanel>
|
||
<OpsPanel title={<span className="text-destructive">Опасная зона</span>} contentClassName="px-4 pb-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">Сбросить все настройки</p>
|
||
<p className="text-xs text-muted-foreground">Удалит пользовательские параметры и вернёт заводские значения</p>
|
||
</div>
|
||
<Button variant="outline" size="sm" className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||
Сбросить
|
||
</Button>
|
||
</div>
|
||
</OpsPanel>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Система" }, { label: "Настройки" }]}
|
||
actions={
|
||
<Button
|
||
size="sm"
|
||
onClick={() => { void handleSave() }}
|
||
disabled={
|
||
section === "EvoBGP" &&
|
||
(evoSaveBusy || mode !== "live" || backendStatus !== true)
|
||
}
|
||
>
|
||
{section === "EvoBGP" && evoSaveBusy ? (
|
||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||
) : saved ? (
|
||
<CheckIcon className="size-4" />
|
||
) : (
|
||
<SaveIcon className="size-4" />
|
||
)}
|
||
{section === "EvoBGP" && evoSaveBusy
|
||
? "Сохранение…"
|
||
: saved
|
||
? "Сохранено!"
|
||
: "Сохранить"}
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="max-w-[960px] mx-auto grid grid-cols-[180px_1fr] gap-6">
|
||
|
||
{/* nav */}
|
||
<nav className="flex flex-col gap-1 self-start sticky top-0">
|
||
{SECTIONS_NAV.map(s => (
|
||
<button key={s} onClick={() => setSection(s)}
|
||
className={cn(
|
||
"text-left px-3 py-2 rounded-md text-sm transition-colors",
|
||
s === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
{s}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
{/* content */}
|
||
<div className="min-w-0">{renderContent()}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* delete confirm */}
|
||
{dbRestoreFile && (
|
||
<DatabaseRestoreConfirm
|
||
open={!!dbRestoreFile}
|
||
filename={dbRestoreFile.name}
|
||
busy={dbRestoreBusy}
|
||
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
|
||
onCancel={() => {
|
||
if (dbRestoreBusy) return
|
||
setDbRestoreFile(null)
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<NetflowPurgeConfirm
|
||
open={dbPurgeOpen}
|
||
busy={dbPurgeBusy}
|
||
onConfirm={() => { void handleNetflowPurgeConfirm() }}
|
||
onCancel={() => { if (!dbPurgeBusy) setDbPurgeOpen(false) }}
|
||
/>
|
||
|
||
<FileImportDialog
|
||
open={dbRestoreDialogOpen}
|
||
onOpenChange={setDbRestoreDialogOpen}
|
||
title="Восстановление базы данных"
|
||
description="Выберите файл SQLite (.db) — текущая база будет полностью заменена"
|
||
accept=".db,.sqlite,.sqlite3,application/octet-stream"
|
||
onImport={async (files) => {
|
||
const file = files[0]
|
||
if (!file) return
|
||
setDbRestoreFile(file)
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|