diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx index a25b6866..c7f870df 100644 --- a/app/(main)/settings/page.tsx +++ b/app/(main)/settings/page.tsx @@ -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(path: string, init?: RequestInit): Promise { - return requestJson(backendUrl, path, init) - } -} - function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) { return (
@@ -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(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() {
) - // ── EvoBGP ── - if (section === "EvoBGP") return ( -
- - {(mode !== "live" || backendStatus !== true) && ( - - -

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

-

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

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

{evoSaveErr}

} -

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

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

- {evoTestResult.message} -

- )} -
-
- ) + if (section === "EvoBGP") return // ── Уведомления ── if (section === "Уведомления") return ( @@ -841,36 +592,17 @@ export default function SettingsPage() {
{ void handleSave() }} - disabled={ - section === "EvoBGP" && - (evoSaveBusy || mode !== "live" || backendStatus !== true) - } - > - {section === "EvoBGP" && evoSaveBusy ? ( - - ) : saved ? ( - - ) : ( - - )} - {section === "EvoBGP" && evoSaveBusy - ? "Сохранение…" - : saved - ? "Сохранено!" - : "Сохранить"} - - } + actions={section === "EvoBGP" ? null : } />
-
+
{/* nav */} -
+ +
{mark("enabled");setEnabled(value)}}/>Использовать каталог EvoBGP{!evo.settingsLoaded?": —":""}
+ {error?:null} + {outcome?

{outcome}

:null} + {testResult?

{testResult.message}

:null} +
+ + + + +
+

Проверка соединения не сохраняет изменения. Пустое поле ключа не удаляет сохранённый ключ.

+ + {if(!blocked)setClearOpen(value)}}>Удалить сохранённый ключ EvoBGP?Каталог не сможет обновляться до сохранения нового ключа. Остальные поля и введённый новый ключ останутся в форме.{error?

{error}

:null}Отмена{event.preventDefault();void clear()}}>{busy==="clear"?"Удаление…":"Удалить ключ"}
+ +} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 5b3ba2fa..c5c3703f 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f95607ce..59c07e12 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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. Переполнение суммы отклоняется. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 6f0fee1d..fbd1a36e 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -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) | Статус и свежесть → поля → сохранить / проверить → подтверждённый результат; удаление ключа → подтверждение → результат | diff --git a/docs/ux-ui/screens/evolution-1440.png b/docs/ux-ui/screens/evolution-1440.png new file mode 100644 index 00000000..e2728bf6 Binary files /dev/null and b/docs/ux-ui/screens/evolution-1440.png differ diff --git a/docs/ux-ui/screens/evolution-320.png b/docs/ux-ui/screens/evolution-320.png new file mode 100644 index 00000000..c3120188 Binary files /dev/null and b/docs/ux-ui/screens/evolution-320.png differ diff --git a/docs/ux-ui/screens/evolution-390.png b/docs/ux-ui/screens/evolution-390.png new file mode 100644 index 00000000..04ea7a6a Binary files /dev/null and b/docs/ux-ui/screens/evolution-390.png differ diff --git a/docs/ux-ui/screens/evolution-clear-1440.png b/docs/ux-ui/screens/evolution-clear-1440.png new file mode 100644 index 00000000..db1d6f02 Binary files /dev/null and b/docs/ux-ui/screens/evolution-clear-1440.png differ diff --git a/docs/ux-ui/screens/evolution-clear-320.png b/docs/ux-ui/screens/evolution-clear-320.png new file mode 100644 index 00000000..480e4bf8 Binary files /dev/null and b/docs/ux-ui/screens/evolution-clear-320.png differ diff --git a/docs/ux-ui/screens/evolution-clear-390.png b/docs/ux-ui/screens/evolution-clear-390.png new file mode 100644 index 00000000..63fa1913 Binary files /dev/null and b/docs/ux-ui/screens/evolution-clear-390.png differ diff --git a/tests/e2e/evobgp-context.spec.ts b/tests/e2e/evobgp-context.spec.ts index 06cf299d..c2b894d5 100644 --- a/tests/e2e/evobgp-context.spec.ts +++ b/tests/e2e/evobgp-context.spec.ts @@ -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(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()} }) diff --git a/tests/e2e/evobgp-form.spec.ts b/tests/e2e/evobgp-form.spec.ts new file mode 100644 index 00000000..55ec169b --- /dev/null +++ b/tests/e2e/evobgp-form.spec.ts @@ -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(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()}}) diff --git a/tests/e2e/evobgp-provider.spec.ts b/tests/e2e/evobgp-provider.spec.ts index d86aa1f5..29a033c1 100644 --- a/tests/e2e/evobgp-provider.spec.ts +++ b/tests/e2e/evobgp-provider.spec.ts @@ -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)}) diff --git a/tests/e2e/evobgp-responsive.spec.ts b/tests/e2e/evobgp-responsive.spec.ts new file mode 100644 index 00000000..57ef202d --- /dev/null +++ b/tests/e2e/evobgp-responsive.spec.ts @@ -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"}) +})