fix(evolution): сохранять черновики и подтверждать операции формы
This commit is contained in:
1 parent
7e3cb754b0
commit
118cd31d65
15 files changed
+162
-288
No files matched your search
+11
-279
@@ -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(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect,useRef,useState} from "react"
|
||||
import {evoBgpSettingsPatchSchema,evoBgpTestDraftSchema,type EvoBgpSavePayload} from "@mmapp/contracts/evobgp"
|
||||
import {useEvoBGP} from "@/lib/evobgp-context"
|
||||
import {useDataSource} from "@/lib/data-source"
|
||||
import {can} from "@/lib/auth"
|
||||
import {EvoBgpDataStatus} from "./evobgp-data-status"
|
||||
import {OpsPanel} from "@/components/ops-panel"
|
||||
import {FormField,FormToggle} from "@/components/form-kit"
|
||||
import {Alert,AlertDescription,AlertTitle} from "@/components/reui/alert"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {AlertDialog,AlertDialogContent,AlertDialogHeader,AlertDialogTitle,AlertDialogDescription,AlertDialogFooter,AlertDialogCancel,AlertDialogAction} from "@/components/ui/alert-dialog"
|
||||
import {AlertCircleIcon,EyeIcon,EyeOffIcon} from "lucide-react"
|
||||
|
||||
export function EvoBgpSettingsSection(){const evo=useEvoBGP();return <EvoBgpSettingsForm key={evo.sourceKey}/>}
|
||||
function EvoBgpSettingsForm(){
|
||||
const evo=useEvoBGP(),{mode,backendStatus}=useDataSource()
|
||||
const [baseUrl,setBaseUrl]=useState(evo.settingsLoaded?evo.baseUrl:"")
|
||||
const [enabled,setEnabled]=useState(evo.settingsLoaded?evo.enabled:false)
|
||||
const [apiKey,setApiKey]=useState("")
|
||||
const [showKey,setShowKey]=useState(false)
|
||||
const [changed,setChanged]=useState(false)
|
||||
const [busy,setBusy]=useState<"save"|"test"|"clear"|"refresh"|null>(null)
|
||||
const [error,setError]=useState<string|null>(null)
|
||||
const [outcome,setOutcome]=useState<string|null>(null)
|
||||
const [testResult,setTestResult]=useState<{ok:boolean;message:string}|null>(null)
|
||||
const [clearOpen,setClearOpen]=useState(false)
|
||||
const dirty=useRef(new Set<"baseUrl"|"enabled"|"apiKey">())
|
||||
const active=useRef(true),lock=useRef(false)
|
||||
useEffect(()=>{active.current=true;return()=>{active.current=false}},[])
|
||||
useEffect(()=>{if(!evo.settingsLoaded)return;let current=true;queueMicrotask(()=>{if(!current||!active.current)return;if(!dirty.current.has("baseUrl"))setBaseUrl(evo.baseUrl);if(!dirty.current.has("enabled"))setEnabled(evo.enabled)});return()=>{current=false}},[evo.settingsLoaded,evo.baseUrl,evo.enabled,evo.settingsReceivedAt])
|
||||
const writable=mode==="live"&&backendStatus===true&&can("mm:settings:admin")&&evo.settingsLoaded&&!evo.settingsError
|
||||
const blocked=!!busy||evo.operationBusy
|
||||
const mark=(field:"baseUrl"|"enabled"|"apiKey")=>{dirty.current.add(field);setChanged(true);setOutcome(null);setTestResult(null)}
|
||||
function begin(kind:NonNullable<typeof busy>){if(!active.current||lock.current||!writable||evo.operationBusy)return false;lock.current=true;setBusy(kind);setError(null);setOutcome(null);setTestResult(null);return true}
|
||||
function finish(){lock.current=false;if(active.current)setBusy(null)}
|
||||
async function save(){
|
||||
if(!changed||!begin("save"))return
|
||||
try{
|
||||
const patch:EvoBgpSavePayload={}
|
||||
if(dirty.current.has("baseUrl"))patch.baseUrl=baseUrl
|
||||
if(dirty.current.has("enabled"))patch.enabled=enabled
|
||||
if(dirty.current.has("apiKey")&&apiKey.trim())patch.apiKey=apiKey
|
||||
const parsed=evoBgpSettingsPatchSchema.safeParse(patch)
|
||||
if(!parsed.success)throw new Error(parsed.error.issues[0]?.message??"Проверьте поля EvoBGP")
|
||||
if(parsed.data.apiKey==="")throw new Error("Введите API-ключ; удаление сохранённого ключа подтверждается отдельно")
|
||||
await evo.saveSettings(parsed.data)
|
||||
if(!active.current)return
|
||||
for(const field of Object.keys(parsed.data))dirty.current.delete(field as "baseUrl"|"enabled"|"apiKey")
|
||||
if(parsed.data.baseUrl!==undefined)setBaseUrl(parsed.data.baseUrl)
|
||||
if(parsed.data.apiKey!==undefined){setApiKey("");setShowKey(false)}
|
||||
setChanged(dirty.current.size>0);setOutcome("Настройки EvoBGP сохранены")
|
||||
}catch(error){if(active.current)setError((error instanceof Error?error.message:"Не удалось сохранить EvoBGP")+". Черновик сохранён; автоматического повтора нет.")}
|
||||
finally{finish()}
|
||||
}
|
||||
async function test(){
|
||||
if(!begin("test"))return
|
||||
try{
|
||||
const draft=evoBgpTestDraftSchema.safeParse({baseUrl,...(apiKey.trim()?{apiKey}:{})})
|
||||
if(!draft.success)throw new Error(draft.error.issues[0]?.message??"Проверьте поля EvoBGP")
|
||||
if(!draft.data.baseUrl||(!draft.data.apiKey&&!evo.secretConfigured))throw new Error("Укажите URL и API-ключ для проверки")
|
||||
const result=await evo.testConnection(draft.data)
|
||||
if(active.current)setTestResult(result)
|
||||
}catch(error){if(active.current)setError(error instanceof Error?error.message:"Не удалось проверить EvoBGP")}
|
||||
finally{finish()}
|
||||
}
|
||||
async function clear(){
|
||||
if(!evo.secretConfigured||!begin("clear"))return
|
||||
try{await evo.saveSettings({apiKey:null});if(active.current){setClearOpen(false);setOutcome("Удаление сохранённого ключа подтверждено")}}
|
||||
catch(error){if(active.current)setError((error instanceof Error?error.message:"Удаление ключа не подтверждено")+". Подтверждения удаления нет; автоматического повтора нет.")}
|
||||
finally{finish()}
|
||||
}
|
||||
async function refresh(){if(!begin("refresh"))return;try{await evo.refresh()}finally{finish()}}
|
||||
return <section aria-label="Настройки EvoBGP" className="flex min-w-0 flex-col gap-4">
|
||||
<EvoBgpDataStatus/>
|
||||
{mode!=="live"?<p role="status" className="text-sm text-muted-foreground">Интеграция доступна в live-режиме. Выберите живые данные в разделе «Общие».</p>:null}
|
||||
<OpsPanel title="EvoBGP API" description="Каталог доменов, IP-диапазонов, ASN и communities. Проверка использует поля формы; обновление каталога — сохранённые настройки." contentClassName="flex min-w-0 flex-col gap-4 px-5 py-4">
|
||||
<FormField label="Базовый URL API" hint="HTTP(S) адрес без пути /v1, параметров и учётных данных"><Input aria-label="Базовый URL API" placeholder="http://localhost:8080" value={baseUrl} onChange={e=>{mark("baseUrl");setBaseUrl(e.target.value)}} disabled={!writable||blocked} className="min-w-0 font-mono" autoComplete="url"/></FormField>
|
||||
<FormField label="API-ключ" hint={evo.secretConfigured?"Оставьте пустым, чтобы сохранить текущий ключ. Введите новый только для замены.":"Введите ключ для подключения к каталогу."}>
|
||||
<div className="flex min-w-0 items-center gap-2"><Input aria-label="API-ключ EvoBGP" type={showKey?"text":"password"} autoComplete="off" value={apiKey} onChange={e=>{setApiKey(e.target.value);if(e.target.value.trim())mark("apiKey");else{dirty.current.delete("apiKey");setChanged(dirty.current.size>0)}}} placeholder={evo.secretConfigured?"Оставьте пустым, чтобы не менять":"Bearer-токен"} disabled={!writable||blocked} className="min-w-0 font-mono"/><Button type="button" variant="outline" size="icon" aria-label={showKey?"Скрыть API-ключ":"Показать API-ключ"} disabled={!writable||blocked} onClick={()=>setShowKey(value=>!value)}>{showKey?<EyeOffIcon/>:<EyeIcon/>}</Button></div>
|
||||
</FormField>
|
||||
<div className="flex items-center gap-3"><FormToggle ariaLabel="Использовать каталог EvoBGP" checked={enabled} disabled={!writable||blocked} onChange={value=>{mark("enabled");setEnabled(value)}}/><span className="text-sm">Использовать каталог EvoBGP{!evo.settingsLoaded?": —":""}</span></div>
|
||||
{error?<Alert variant="destructive"><AlertCircleIcon aria-hidden="true"/><AlertTitle>Операция EvoBGP требует внимания</AlertTitle><AlertDescription>{error}</AlertDescription></Alert>:null}
|
||||
{outcome?<p role="status" className="text-sm">{outcome}</p>:null}
|
||||
{testResult?<p role="status" className={testResult.ok?"text-sm text-success":"text-sm text-destructive"}>{testResult.message}</p>:null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" disabled={!writable||blocked||!changed} onClick={()=>{void save()}}>{busy==="save"?"Сохранение…":"Сохранить"}</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={!writable||blocked||!baseUrl.trim()||(!apiKey.trim()&&!evo.secretConfigured)} onClick={()=>{void test()}}>{busy==="test"?"Проверка…":"Проверить ключ"}</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={!writable||blocked||evo.loading||!evo.enabled} onClick={()=>{void refresh()}}>Обновить каталог</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={!writable||blocked||!evo.secretConfigured} onClick={()=>setClearOpen(true)}>Удалить сохранённый ключ</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Проверка соединения не сохраняет изменения. Пустое поле ключа не удаляет сохранённый ключ.</p>
|
||||
</OpsPanel>
|
||||
<AlertDialog open={clearOpen} onOpenChange={value=>{if(!blocked)setClearOpen(value)}}><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Удалить сохранённый ключ EvoBGP?</AlertDialogTitle><AlertDialogDescription>Каталог не сможет обновляться до сохранения нового ключа. Остальные поля и введённый новый ключ останутся в форме.</AlertDialogDescription></AlertDialogHeader>{error?<p role="alert" className="text-sm text-destructive">{error}</p>:null}<AlertDialogFooter><AlertDialogCancel disabled={blocked}>Отмена</AlertDialogCancel><AlertDialogAction variant="destructive" disabled={!writable||blocked} onClick={event=>{event.preventDefault();void clear()}}>{busy==="clear"?"Удаление…":"Удалить ключ"}</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog>
|
||||
</section>
|
||||
}
|
||||
@@ -278,3 +278,5 @@ REST запрос → ожидание → подтверждённый output /
|
||||
Завершён 11Б.3.5.3.1: Evolution provider known/error/time/readers/source/strict receipts, статус Settings; 8 provider (один selector повторён), 2 прежних и 6 source E2E, lint/typecheck/production build прошли. Форма/consumer mocks ещё не приняты. Далее 11Б.3.5.3.2 — upstream catalog strict/unknown counts; затем 11Б.3.5.4 форма/consumers.
|
||||
|
||||
Завершён 11Б.3.5.3.2: strict upstream catalog, nullable ASN/community counts, unknown «—» вместо 0. Parser/aggregation/HTTP/JWT/build/typecheck/lint и provider/counts E2E прошли; locator inline details исправлен. Реальный Evolution/PG не принят. Далее 11Б.3.5.4.1 — форма Evolution dirty/rights/lock/key clear/source.
|
||||
|
||||
Завершён 11Б.3.5.4.1: отдельная форма Evolution dirty/rights/lock/clear confirmation/source; 17 основных, 8 source и 3 responsive E2E, typecheck/lint/build прошли. 6 снимков, 320/ПК/clear просмотрены. Общие Settings и live consumer mocks ещё не приняты. Далее 11Б.3.5.4.2 — consumers/sidebar unknown/disabled/no fallback.
|
||||
+10
-1
@@ -28,7 +28,7 @@
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.3.2; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.4.1; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11Б.3.5.4.1 — форма Evolution (2026-10-03)
|
||||
|
||||
- Evolution вынесен из Settings в отдельную форму на существующих Frame/OpsPanel/FormField/FormToggle/AlertDialog. GET обновляет только нетронутые поля; ключ и другие dirty поля сохраняются при обновлении и ошибке. PUT отправляет только dirty fields через общий нормализующий контракт; пустой ключ не удаляет сохранённый. Успех — один inline receipt, без прежнего двойного toast и фиктивного сохранения mock-режима.
|
||||
- Удаление ключа требует AlertDialog и отдельного apiKey:null patch, сохраняет URL/toggle/введённую замену. Malformed/HTTP error удерживает подтверждение и черновики. Test не сохраняет, refresh использует сохранённые настройки; единый синхронный lock с provider отключает поля/другие действия. Права/known settings/live/backend проверяются до действий.
|
||||
- Форма перемонтируется по sourceKey, active guard исключает старые локальные результаты; provider продолжает отменять readers/guards mutation. Навигация Settings адаптирована (одна колонка/перенос на телефоне). Другие секции Settings ещё не приняты и сохраняют старые демонстрационные действия.
|
||||
- 17 основных Evolution E2E (7 новых формы, 8 provider, 2 прежних), 8 source E2E GET/catalog/save/test success/error, 3 responsive 1440/390/320 прошли. Typecheck, профильный ESLint и production frontend/contracts build прошли. Синтаксическая опечатка обработчика поля исправлена до проверок. В Settings осталась одна исходная lint ошибка sync backendUrl effect; её исправление входит в общие Settings.
|
||||
- 6 снимков docs/ux-ui/screens/evolution*.png; просмотрены 320/desktop/clear 320. Снимки пересняты с animations disabled, чтобы не фиксировать начальную прозрачность открытия popup. Реальный Evolution/PG не проверен. Source test clear не добавлялся: modal во время операции блокирует пользовательскую смену секции; не обходить inert для имитации сценария.
|
||||
- Далее 11Б.3.5.4.2 — live catalog consumers/sidebar: убрать fallback mocks, явно показать unknown/disabled/no access и последние данные; затем локальная приёмка интеграции.
|
||||
|
||||
## Блок 11Б.3.5.3.2 — достоверность каталога Evolution (2026-10-03)
|
||||
|
||||
- Backend проверяет все пять upstream collections/items, обязательные поля, уникальность идентификаторов, диапазон ASN и целые безопасные counts до mapping. Malformed больше не превращается в подтверждённый пустой каталог; корректные пустые массивы допустимы. Неизвестный prefix_count (нет поля/null) сохраняется как null; итог community с таким ASN тоже unknown, известный ноль остаётся 0. Переполнение суммы отклоняется.
|
||||
|
||||
@@ -206,3 +206,9 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
|
||||
Завершён 11Б.3.5.3.1: Evolution provider known/error/time/readers/source/strict receipts, статус Settings; 8 provider (один selector повторён), 2 прежних и 6 source E2E, lint/typecheck/production build прошли. Форма/consumer mocks ещё не приняты. Далее 11Б.3.5.3.2 — upstream catalog strict/unknown counts; затем 11Б.3.5.4 форма/consumers.
|
||||
|
||||
Завершён 11Б.3.5.3.2: strict upstream catalog, nullable ASN/community counts, unknown «—» вместо 0. Parser/aggregation/HTTP/JWT/build/typecheck/lint и provider/counts E2E прошли; locator inline details исправлен. Реальный Evolution/PG не принят. Далее 11Б.3.5.4.1 — форма Evolution dirty/rights/lock/key clear/source.
|
||||
|
||||
Завершён 11Б.3.5.4.1: отдельная форма Evolution dirty/rights/lock/clear confirmation/source; 17 основных, 8 source и 3 responsive E2E, typecheck/lint/build прошли. 6 снимков, 320/ПК/clear просмотрены. Общие Settings и live consumer mocks ещё не приняты. Далее 11Б.3.5.4.2 — consumers/sidebar unknown/disabled/no fallback.
|
||||
|
||||
| Блок | Референс | Реализованная схема |
|
||||
| --- | --- | --- |
|
||||
| 11Б.3.5.4.1 Evolution | [Settings 16](https://reui.io/preview/base/settings-16?ref=mcp) | Статус и свежесть → поля → сохранить / проверить → подтверждённый результат; удаление ключа → подтверждение → результат |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -2,15 +2,15 @@ import {test,expect,type Page} from "@playwright/test"
|
||||
import {evoSettings as settings,evoCatalog as catalog} from "./fixtures/evobgp"
|
||||
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,PUT,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
|
||||
async function setup(page:Page){await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const url=new URL(r.request().url()),fresh=url.port==="8101";return r.fulfill(url.pathname==="/api/auth/config"?{headers,json:{required:false}}:url.pathname==="/api/evobgp/settings"?{headers,json:{...settings,baseUrl:fresh?"https://new.evo.test":settings.baseUrl}}:url.pathname==="/api/evobgp/catalog"?{headers,json:{...catalog,domains:[{...catalog.domains[0],domain:fresh?"fresh.example.test":"stale.example.test"}]}}:{headers,status:503,json:{error:"outside Evolution context"}})})}
|
||||
for(const action of ["settings","catalog","save"] as const)for(const failed of [false,true])test("Evolution provider: старый backend/"+action+"/"+failed,async({page})=>{
|
||||
for(const action of ["settings","catalog","save","test"] as const)for(const failed of [false,true])test("Evolution provider: старый backend/"+action+"/"+failed,async({page})=>{
|
||||
await setup(page);let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
|
||||
const path=action==="catalog"?"/api/evobgp/catalog":"/api/evobgp/settings"
|
||||
await page.route("**"+path,async r=>{if(new URL(r.request().url()).port!=="8000"||r.request().method()==="OPTIONS"||(action==="save"?r.request().method()!=="PUT":action==="settings"?r.request().method()!=="GET":false))return r.fallback();started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА"}}:{headers,json:action==="settings"?settings:action==="catalog"?{...catalog,domains:[{...catalog.domains[0],domain:"stale.example.test"}]}:{...settings,...r.request().postDataJSON(),ok:true,savedFields:Object.keys(r.request().postDataJSON())}})}catch{}finally{completed=true}})
|
||||
const path=action==="catalog"?"/api/evobgp/catalog":action==="test"?"/api/evobgp/test":"/api/evobgp/settings"
|
||||
await page.route("**"+path,async r=>{if(new URL(r.request().url()).port!=="8000"||r.request().method()==="OPTIONS"||(action==="save"?r.request().method()!=="PUT":action==="settings"?r.request().method()!=="GET":false))return r.fallback();started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА"}}:{headers,json:action==="test"?{ok:true}:action==="settings"?settings:action==="catalog"?{...catalog,domains:[{...catalog.domains[0],domain:"stale.example.test"}]}:{...settings,...r.request().postDataJSON(),ok:true,savedFields:Object.keys(r.request().postDataJSON())}})}catch{}finally{completed=true}})
|
||||
try{
|
||||
await page.goto("/settings")
|
||||
if(action==="save"){await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue(settings.baseUrl);await page.getByPlaceholder("http://localhost:8080",{exact:true}).fill("https://old-write.evo.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click()}
|
||||
await expect.poll(()=>started).toBe(true);if(action==="save")await page.getByRole("button",{name:"Общие",exact:true}).click()
|
||||
if(action==="save"||action==="test"){await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue(settings.baseUrl);if(action==="save"){await page.getByPlaceholder("http://localhost:8080",{exact:true}).fill("https://old-write.evo.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click()}else await page.getByRole("button",{name:"Проверить ключ",exact:true}).click()}
|
||||
await expect.poll(()=>started).toBe(true);if(action==="save"||action==="test")await page.getByRole("button",{name:"Общие",exact:true}).click()
|
||||
const url=page.getByPlaceholder("http://localhost:8000",{exact:true});await url.fill("http://localhost:8101");await url.press("Enter");await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");release();await expect.poll(()=>completed).toBe(true)
|
||||
await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");await expect(page.getByText("СТАРАЯ ОШИБКА",{exact:true})).toHaveCount(0);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("fresh.example.test",{exact:true})).toBeVisible();await expect(page.getByText("stale.example.test",{exact:true})).toHaveCount(0)
|
||||
await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");await expect(page.getByText("СТАРАЯ ОШИБКА",{exact:true})).toHaveCount(0);await expect(page.getByText("Соединение с EvoBGP установлено",{exact:true})).toHaveCount(0);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("fresh.example.test",{exact:true})).toBeVisible();await expect(page.getByText("stale.example.test",{exact:true})).toHaveCount(0)
|
||||
}finally{release()}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import {test,expect,type Page} from "@playwright/test"
|
||||
import {evoSettings as initial,evoCatalog} from "./fixtures/evobgp"
|
||||
const section=(p:Page)=>p.getByRole("region",{name:"Настройки EvoBGP",exact:true})
|
||||
const base=(p:Page)=>p.getByRole("textbox",{name:"Базовый URL API",exact:true})
|
||||
const key=(p:Page)=>p.getByLabel("API-ключ EvoBGP",{exact:true})
|
||||
async function setup(page:Page){let settings={...initial};await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/evobgp/settings"){if(r.request().method()==="PUT"){const patch=r.request().postDataJSON();settings={baseUrl:patch.baseUrl??settings.baseUrl,enabled:patch.enabled??settings.enabled,secretConfigured:patch.apiKey!==undefined?!!patch.apiKey:settings.secretConfigured};return r.fulfill({json:{...settings,ok:true,savedFields:Object.keys(patch)}})}return r.fulfill({json:settings})}return r.fulfill(path==="/api/evobgp/catalog"?{json:evoCatalog}:path==="/api/evobgp/test"?{json:{ok:true}}:{status:503,json:{error:"outside fixture"}})});await page.goto("/settings");await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(base(page)).toHaveValue(initial.baseUrl)}
|
||||
|
||||
test("Evolution form: selective URL patch, canonical receipt and no duplicate success",async({page})=>{await setup(page);const patches:unknown[]=[];page.on("request",r=>{if(r.method()==="PUT")patches.push(r.postDataJSON())});await expect(section(page).getByRole("button",{name:"Сохранить",exact:true})).toBeDisabled();await base(page).fill(" new.evo.test/ ");await section(page).getByRole("button",{name:"Сохранить",exact:true}).click();await expect(base(page)).toHaveValue("https://new.evo.test");await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toHaveCount(1);await expect(section(page).getByRole("button",{name:"Сохранить",exact:true})).toBeDisabled();expect(patches).toEqual([{baseUrl:"https://new.evo.test"}])})
|
||||
test("Evolution form: refresh preserves dirty URL, toggle and replacement key",async({page})=>{await setup(page);await base(page).fill("https://draft.evo.test");await key(page).fill("draft-secret");await page.getByRole("switch",{name:"Использовать каталог EvoBGP"}).click();await page.getByRole("button",{name:"Обновить настройки EvoBGP"}).click();await expect(page.getByText("Обновление настроек EvoBGP…",{exact:true})).toHaveCount(0);await expect(base(page)).toHaveValue("https://draft.evo.test");await expect(key(page)).toHaveValue("draft-secret");await expect(page.getByRole("switch",{name:"Использовать каталог EvoBGP"})).not.toBeChecked();await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();await expect(key(page)).toHaveValue("")})
|
||||
test("Evolution form: invalid URL never writes and test never saves",async({page})=>{await setup(page);let writes=0;page.on("request",r=>{if(r.method()==="PUT")writes++});await base(page).fill("https://evo.test/v1");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(section(page).getByText(/Укажите HTTP\(S\) URL/)).toBeVisible();expect(writes).toBe(0);await base(page).fill("https://draft.evo.test");await page.getByRole("button",{name:"Проверить ключ",exact:true}).click();await expect(page.getByText("Соединение с EvoBGP установлено",{exact:true})).toBeVisible();expect(writes).toBe(0);await expect(page.getByRole("button",{name:"Сохранить",exact:true})).toBeEnabled()})
|
||||
test("Evolution form: unknown save preserves drafts without retry",async({page})=>{await setup(page);let writes=0;await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;return r.fulfill({status:503,json:{error:"save unknown"}})});await key(page).fill("replacement");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(section(page).getByText(/save unknown.*Черновик сохранён/)).toBeVisible();await expect(key(page)).toHaveValue("replacement");await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toHaveCount(0);expect(writes).toBe(1)})
|
||||
test("Evolution form: key clear requires confirmation and preserves other drafts",async({page})=>{await setup(page);const patches:unknown[]=[];page.on("request",r=>{if(r.method()==="PUT")patches.push(r.postDataJSON())});await base(page).fill("https://draft.evo.test");await key(page).fill("future-key");await page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true}).click();expect(patches).toEqual([]);await page.getByRole("alertdialog").getByRole("button",{name:"Удалить ключ",exact:true}).click();await expect(page.getByRole("alertdialog")).toHaveCount(0);await expect(page.getByText("Удаление сохранённого ключа подтверждено",{exact:true})).toBeVisible();await expect(base(page)).toHaveValue("https://draft.evo.test");await expect(key(page)).toHaveValue("future-key");expect(patches).toEqual([{apiKey:null}]);await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();expect(patches[1]).toEqual({baseUrl:"https://draft.evo.test",apiKey:"future-key"})})
|
||||
test("Evolution form: malformed clear retains confirmation and typed key",async({page})=>{await setup(page);await page.route("**/api/evobgp/settings",r=>r.request().method()==="PUT"?r.fulfill({json:{}}):r.fallback());await key(page).fill("future-key");await page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить ключ",exact:true}).click();await expect(page.getByRole("alertdialog").getByRole("alert")).toContainText("Подтверждения удаления нет");await page.getByRole("alertdialog").getByRole("button",{name:"Отмена",exact:true}).click();await expect(key(page)).toHaveValue("future-key");await expect(page.getByText("Ключ: сохранён",{exact:true})).toBeVisible()})
|
||||
test("Evolution form: repeated save and other actions are locked during PUT",async({page})=>{await setup(page);let started=false,writes=0,release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/evobgp/settings",async r=>{if(r.request().method()!=="PUT")return r.fallback();started=true;writes++;await gate;try{await r.fulfill({json:{...initial,baseUrl:"https://draft.evo.test",ok:true,savedFields:["baseUrl"]}})}catch{}});try{await base(page).fill("https://draft.evo.test");await page.getByRole("button",{name:"Сохранить",exact:true}).dblclick();await expect.poll(()=>started).toBe(true);await expect(page.getByRole("button",{name:"Сохранение…",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Проверить ключ",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true})).toBeDisabled();await expect(base(page)).toBeDisabled();expect(writes).toBe(1);release();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible()}finally{release()}})
|
||||
@@ -11,6 +11,6 @@ test("Evolution provider: каталог не задерживает known на
|
||||
test("Evolution provider: ошибка settings GET сохраняет настройки, каталог и время",async({page})=>{await setup(page);let failed=false;await page.route("**/api/evobgp/settings",r=>r.fulfill(failed?{status:503,json:{error:"settings unavailable"}}:{json:settings}));await open(page);await ready(page);const before=await settingsStamp(page).textContent(),catBefore=await catalogStamp(page).textContent();failed=true;await page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true}).click();await expect(page.getByText("Настройки EvoBGP не обновлены",{exact:true})).toBeVisible();await expect(settingsStamp(page)).toHaveText(before!);await expect(catalogStamp(page)).toHaveText(catBefore!);await expect(base(page)).toHaveValue(settings.baseUrl)})
|
||||
test("Evolution provider: ошибка каталога сохраняет последний снимок и время",async({page})=>{await setup(page);let failed=false;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog unavailable"}}:{json:catalog}));await open(page);await ready(page);const before=await catalogStamp(page).textContent();failed=true;await page.getByRole("button",{name:"Обновить каталог",exact:true}).click();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();await expect(catalogStamp(page)).toHaveText(before!);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("kept.example.test",{exact:true})).toBeVisible()})
|
||||
test("Evolution provider: отключение подтверждённой интеграции очищает каталог",async({page})=>{await setup(page);let enabled=true,posts=0;await page.route("**/api/evobgp/settings",r=>r.fulfill({json:{...settings,enabled}}));await page.route("**/api/evobgp/catalog",r=>{posts++;return r.fulfill({json:catalog})});await open(page);await ready(page);const before=posts;enabled=false;await page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true}).click();await expect(page.getByText("Интеграция: выключена",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP отключён в сохранённых настройках",{exact:true})).toBeVisible();await expect(catalogStamp(page)).toHaveCount(0);expect(posts).toBe(before)})
|
||||
test("Evolution provider: сохранение не ждёт каталог и не отменяется его отказом",async({page})=>{await setup(page);let failed=false,writes=0;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog after save unavailable"}}:{json:catalog}));await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;failed=true;const patch=r.request().postDataJSON();return r.fulfill({json:{...settings,...patch,ok:true,savedFields:Object.keys(patch)}})});await open(page);await ready(page);await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();expect(writes).toBe(1)})
|
||||
test("Evolution provider: malformed PUT не подтверждает сохранение",async({page})=>{await setup(page);let writes=0;await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;return r.fulfill({json:{}})});await open(page);await ready(page);await base(page).fill("https://new.example.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.locator("#main-content").getByText("Сохранение EvoBGP не подтверждено сервером",{exact:true})).toBeVisible();await expect(base(page)).toHaveValue("https://new.example.test");expect(writes).toBe(1)})
|
||||
test("Evolution provider: сохранение не ждёт каталог и не отменяется его отказом",async({page})=>{await setup(page);let failed=false,writes=0;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog after save unavailable"}}:{json:catalog}));await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;failed=true;const patch=r.request().postDataJSON();return r.fulfill({json:{...settings,...patch,ok:true,savedFields:Object.keys(patch)}})});await open(page);await ready(page);await base(page).fill(settings.baseUrl+"/");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();expect(writes).toBe(1)})
|
||||
test("Evolution provider: malformed PUT не подтверждает сохранение",async({page})=>{await setup(page);let writes=0;await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;return r.fulfill({json:{}})});await open(page);await ready(page);await base(page).fill("https://new.example.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.locator("#main-content").getByText(/Сохранение EvoBGP не подтверждено сервером/)).toBeVisible();await expect(base(page)).toHaveValue("https://new.example.test");expect(writes).toBe(1)})
|
||||
test("Evolution provider: права settings admin проверяются до GET",async({page})=>{const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:dashboard:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture";await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);await setup(page);await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let reads=0;page.on("request",r=>{if(new URL(r.url()).pathname.startsWith("/api/evobgp/"))reads++});await open(page);await expect(page.getByText("Нет доступа к настройкам EvoBGP",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true})).toBeDisabled();expect(reads).toBe(0)})
|
||||
@@ -0,0 +1,13 @@
|
||||
import {test,expect} from "@playwright/test"
|
||||
import {evoSettings,evoCatalog} from "./fixtures/evobgp"
|
||||
for(const width of [1440,390,320])test("Evolution form: responsive/"+width,async({page})=>{
|
||||
await page.setViewportSize({width,height:1000})
|
||||
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
|
||||
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/auth/config"?{json:{required:false}}:path==="/api/evobgp/settings"?{json:evoSettings}:path==="/api/evobgp/catalog"?{json:evoCatalog}:{status:503,json:{error:"outside fixture"}})})
|
||||
await page.goto("/settings");await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByRole("textbox",{name:"Базовый URL API",exact:true})).toHaveValue(evoSettings.baseUrl)
|
||||
const section=page.getByRole("region",{name:"Настройки EvoBGP",exact:true})
|
||||
await section.scrollIntoViewIfNeeded();expect(await section.evaluate(e=>e.scrollWidth<=e.clientWidth+2)).toBe(true);expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
|
||||
await page.screenshot({path:"docs/ux-ui/screens/evolution-"+width+".png",fullPage:true,animations:"disabled"})
|
||||
await page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true}).click();await expect(page.getByRole("alertdialog")).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
|
||||
await page.screenshot({path:"docs/ux-ui/screens/evolution-clear-"+width+".png",fullPage:true,animations:"disabled"})
|
||||
})
|
||||
Reference in new issue
Block a user