fix(evolution): сохранять черновики и подтверждать операции формы

This commit is contained in:
Denozordec committed 2026-10-03 13:59:24 +07:00
1 parent 7e3cb754b0
commit 118cd31d65
15 files changed
+162 -288

No files matched your search

+11 -279
View File
@@ -1,17 +1,16 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, 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 { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section"
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,
@@ -26,12 +25,11 @@ import {
} from "@/components/ui/alert-dialog"
import {
SaveIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
AlertCircleIcon, EyeIcon, EyeOffIcon,
AlertCircleIcon,
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"
@@ -47,12 +45,6 @@ const INIT_API_KEYS: ApiKey[] = [
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">
@@ -116,14 +108,7 @@ export default function SettingsPage() {
// 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)
@@ -135,10 +120,6 @@ export default function SettingsPage() {
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")
@@ -180,57 +161,7 @@ export default function SettingsPage() {
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 handleSave = markSaved
const systemDbAvailable = mode === "live" && backendStatus === true
@@ -513,187 +444,7 @@ export default function SettingsPage() {
</div>
)
// ── EvoBGP ──
if (section === "EvoBGP") return (
<div className="flex flex-col gap-4">
<EvoBgpDataStatus />
{(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 и ключ хранятся в PostgreSQL на сервере бекенда (
<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>
)}
</OpsPanel>
</div>
)
if (section === "EvoBGP") return <EvoBgpSettingsSection />
// ── Уведомления ──
if (section === "Уведомления") return (
@@ -841,36 +592,17 @@ export default function SettingsPage() {
<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>
}
actions={section === "EvoBGP" ? null : <Button size="sm" onClick={handleSave}>
{saved ? <CheckIcon className="size-4" /> : <SaveIcon className="size-4" />}
{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">
<div className="max-w-[960px] mx-auto grid grid-cols-1 md:grid-cols-[180px_minmax(0,1fr)] gap-6">
{/* nav */}
<nav className="flex flex-col gap-1 self-start sticky top-0">
<nav className="flex flex-wrap md:flex-col gap-1 self-start md:sticky md:top-0">
{SECTIONS_NAV.map(s => (
<button key={s} onClick={() => setSection(s)}
className={cn(