diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx index 55b94647..4752d400 100644 --- a/app/(main)/settings/page.tsx +++ b/app/(main)/settings/page.tsx @@ -92,7 +92,7 @@ function DatabaseRestoreConfirm({ export default function SettingsPage() { const [section, setSection] = useState("Общие") - const { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, checkBackend } = useDataSource() + const { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, backendChecking, checkBackend } = useDataSource() const { theme, setTheme } = useTheme() const [dbBackupBusy, setDbBackupBusy] = useState(false) const [dbRestoreBusy, setDbRestoreBusy] = useState(false) @@ -197,7 +197,7 @@ export default function SettingsPage() { {backendStatus === undefined && ( - Не проверялось + {backendChecking ? "Проверка…" : "Не проверялось"} )} {backendStatus === true && ( diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 08a8f512..6a8c2948 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -286,3 +286,5 @@ REST запрос → ожидание → подтверждённый output / Завершён 11Б.3.5.5: Evolution catalog DI HTTP/JWT, один settings snapshot, backend test/build/lint прошли. Evolution принят локально в границах предыдущих form/provider/source/consumer/adaptive проверок; реальный upstream/PG/Portal/RouterOS не принят. Далее 11Б.3.6.1 — достоверные общие Settings без фиктивных сохранений/API keys/MFA; затем system DB, Alerts/Releases/probes. Завершён 11Б.3.6.1: общие Settings без фиктивных сохранений/demo keys/MFA, реальная тема/source URL, переходы к инструментам; 32 E2E с одним исправленным locator, 3 source с исправленным alert locator, 8 Evolution source, typecheck/lint/build прошли. 6 screenshots, 320 просмотрены. System DB ещё не принят. Далее 11Б.3.6.1.2 health/source guards; затем 11Б.3.6.2 DB operations. + +Завершён 11Б.3.6.1.2: DataSource health token/revision/abort/ABA, explicit checking и validated health body. 2 unit, 10 source/health, 16 Settings/Evo provider и 8 Evo source E2E, typecheck/lint/build прошли; два test setup уточнены. System monitor остаётся отдельным reader. Далее 11Б.3.6.2.1 — system DB API/credentials in events; затем backend outcomes и UI. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 1f941373..796b8125 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.6.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.1.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.3.6.1.2 — достоверный health источника (2026-10-03) + +- DataSource health привязан к immutable token каждой смены backend URL, revision/AbortController и active lifetime; старый success/error и ABA не меняют новый статус. Новый источник не наследует прежний available status. Checking отличается от not checked; повторная проверка отменяет прежний reader. +- Health reader использует no-store/3s/caller abort, проверяет actual backend status:ok или legacy literal ok:true (совместимость фикстур/старого ответа). HTTP 200 HTML/{}/false не подтверждает доступность. Это доступность health endpoint, не проверка PG/RouterOS/всех интеграций. +- Hydration local preferences отложена с active guard; устранена исходная DataSource sync-effect lint ошибка. 2 unit, итоговые 10 Settings source/health E2E, 16 соседних Settings/Evolution provider и 8 Evolution source E2E, typecheck/lint/production build прошли. +- Первоначально два новых E2E уточнены: добавлен явный Checking label и исправлен счётчик ABA (health запрашивает ещё System monitor; удерживаются все старые A запросы, сравнивается новый прирост). Worker timeout и backend abort не доказывают остановку операции. System monitor имеет собственный reader и пока не мигрирован на этот helper. +- Следующий блок 11Б.3.6.2.1 — строгие system database export/restore readers/receipts и удаление credentials из event payload; затем backend partial outcomes/restore transaction и форма/приёмка. + ## Блок 11Б.3.6.1 — достоверные общие Settings (2026-10-03) - Удалены фиктивный общий Save/toast, demo API keys, неработающие MFA/IP/audit/reset, язык English/timezone/polling/probe timeout без реализации, default-only Route AI KPI. Вместо них — явные unavailable состояния и переходы к существующим Alerts/Users/Data collection/Probes/Route optimizer. Это исправление ложных операций, не реализация нового API keys/MFA сервиса. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 4e17ecb5..5a562583 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -218,3 +218,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 11Б.3.5.5: Evolution catalog DI HTTP/JWT, один settings snapshot, backend test/build/lint прошли. Evolution принят локально в границах предыдущих form/provider/source/consumer/adaptive проверок; реальный upstream/PG/Portal/RouterOS не принят. Далее 11Б.3.6.1 — достоверные общие Settings без фиктивных сохранений/API keys/MFA; затем system DB, Alerts/Releases/probes. Завершён 11Б.3.6.1: общие Settings без фиктивных сохранений/demo keys/MFA, реальная тема/source URL, переходы к инструментам; 32 E2E с одним исправленным locator, 3 source с исправленным alert locator, 8 Evolution source, typecheck/lint/build прошли. 6 screenshots, 320 просмотрены. System DB ещё не принят. Далее 11Б.3.6.1.2 health/source guards; затем 11Б.3.6.2 DB operations. + +Завершён 11Б.3.6.1.2: DataSource health token/revision/abort/ABA, explicit checking и validated health body. 2 unit, 10 source/health, 16 Settings/Evo provider и 8 Evo source E2E, typecheck/lint/build прошли; два test setup уточнены. System monitor остаётся отдельным reader. Далее 11Б.3.6.2.1 — system DB API/credentials in events; затем backend outcomes и UI. diff --git a/lib/data-source.tsx b/lib/data-source.tsx index c3e97446..1c5407ac 100644 --- a/lib/data-source.tsx +++ b/lib/data-source.tsx @@ -1,6 +1,6 @@ "use client" -import { createContext, useContext, useEffect, useState, useCallback } from "react" +import { createContext, useContext, useEffect, useState, useCallback, useMemo, useRef } from "react" import { configuredBackendUrl, defaultDataSourceMode, @@ -9,7 +9,7 @@ import { LOCAL_DEFAULT_BACKEND_URL, resolveStoredBackendUrl, } from "@/lib/backend-url" -import { resolveApiUrl } from "@/shared/api/http-client" +import { getBackendHealth } from "@/shared/api/backend-health" // ── types ───────────────────────────────────────────────────────────────────── @@ -31,6 +31,7 @@ interface DataSourceContextValue { prefsHydrated: boolean /** undefined = не проверялось, true = OK, false = недоступен */ backendStatus: boolean | undefined + backendChecking: boolean checkBackend: () => Promise } @@ -68,16 +69,30 @@ export function DataSourceProvider({ children }: { children: React.ReactNode }) const [mode, setModeState] = useState(defaultDataSourceMode) const [backendUrl, setBackendUrlState] = useState(initialBackendUrl) const [prefsHydrated, setPrefsHydrated] = useState(false) - const [backendStatus, setBackendStatus] = useState(undefined) + const healthToken = useMemo(() => Symbol(backendUrl), [backendUrl]) + const [health, setHealth] = useState<{ token: symbol; value: boolean | undefined; checking: boolean }>(() => ({ token: healthToken, value: undefined, checking: false })) + const backendStatus = health.token === healthToken ? health.value : undefined + const backendChecking = health.token === healthToken && health.checking + const healthSession = useRef<{token:symbol;active:boolean;revision:number;controller?:AbortController}>({token:healthToken,active:true,revision:0}) + useEffect(() => { + const scope = {token:healthToken,active:true,revision:0,controller:undefined as AbortController | undefined} + healthSession.current = scope + return () => { scope.active = false; scope.controller?.abort() } + }, [healthToken]) const backendUrlLocked = isBackendUrlLocked() const mockModeAvailable = isMockDataSourceAvailable() useEffect(() => { - const storedMode = readStoredMode() - const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND)) - setModeState(storedMode) - setBackendUrlState(url) - setPrefsHydrated(true) + let active = true + queueMicrotask(() => { + if (!active) return + const storedMode = readStoredMode() + const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND)) + setModeState(storedMode) + setBackendUrlState(url) + setPrefsHydrated(true) + }) + return () => { active = false } }, []) const setMode = useCallback((m: DataSourceMode) => { @@ -94,14 +109,20 @@ export function DataSourceProvider({ children }: { children: React.ReactNode }) }, [backendUrlLocked]) const checkBackend = useCallback(async () => { - const healthUrl = resolveApiUrl(backendUrl, "/health") + const scope = healthSession.current + if (!scope.active || scope.token !== healthToken) return + const revision = ++scope.revision + scope.controller?.abort() + const controller = new AbortController(); scope.controller = controller + const current = () => scope.active && scope.revision === revision && !controller.signal.aborted + setHealth({token:healthToken,value:undefined,checking:true}) try { - const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) }) - setBackendStatus(res.ok) + await getBackendHealth(backendUrl, controller.signal) + if (current()) setHealth({token:healthToken,value:true,checking:false}) } catch { - setBackendStatus(false) + if (current()) setHealth({token:healthToken,value:false,checking:false}) } - }, [backendUrl]) + }, [backendUrl, healthToken]) useEffect(() => { if (!prefsHydrated || mode !== "live") return @@ -110,7 +131,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode }) return ( {children} diff --git a/shared/api/backend-health.test.ts b/shared/api/backend-health.test.ts new file mode 100644 index 00000000..492790ed --- /dev/null +++ b/shared/api/backend-health.test.ts @@ -0,0 +1,5 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {confirmBackendHealth,getBackendHealth} from "./backend-health" +test("health confirms actual status ok and legacy literal ok true only",()=>{for(const value of [{status:"ok",timestamp:"2026-10-03"},{ok:true}])assert.doesNotThrow(()=>confirmBackendHealth(value));for(const value of [null,{},[],{ok:false},{ok:"true"},{status:"error"},"ok"])assert.throws(()=>confirmBackendHealth(value),/не подтверждена/)}) +test("HTTP success alone cannot confirm backend, signals and no retries are preserved",async()=>{const original=globalThis.fetch;let calls=0;try{const controller=new AbortController();globalThis.fetch=async(_url,init)=>{calls++;assert.equal(init?.signal instanceof AbortSignal,true);return new Response("proxy",{status:200})};await assert.rejects(()=>getBackendHealth("https://backend.test",controller.signal));assert.equal(calls,1);globalThis.fetch=async()=>{calls++;return Response.json({status:"ok"})};await getBackendHealth("https://backend.test");assert.equal(calls,2)}finally{globalThis.fetch=original}}) diff --git a/shared/api/backend-health.ts b/shared/api/backend-health.ts new file mode 100644 index 00000000..faf47029 --- /dev/null +++ b/shared/api/backend-health.ts @@ -0,0 +1,9 @@ +import {resolveApiUrl} from "./http-client" +export function confirmBackendHealth(raw:unknown):void { + if(typeof raw!=="object"||raw===null||(!("status" in raw&&raw.status==="ok")&&!("ok" in raw&&raw.ok===true)))throw new Error("Доступность backend не подтверждена") +} +export async function getBackendHealth(baseUrl:string,signal?:AbortSignal):Promise{ + const response=await fetch(resolveApiUrl(baseUrl,"/health"),{cache:"no-store",signal:signal?AbortSignal.any([signal,AbortSignal.timeout(3000)]):AbortSignal.timeout(3000)}) + if(!response.ok)throw new Error("Backend недоступен") + confirmBackendHealth(await response.json()) +} diff --git a/tests/e2e/settings-context.spec.ts b/tests/e2e/settings-context.spec.ts index f0bafef2..2216d9f6 100644 --- a/tests/e2e/settings-context.spec.ts +++ b/tests/e2e/settings-context.spec.ts @@ -6,3 +6,11 @@ async function setup(page:Page){await page.route("**/health",r=>r.fulfill({heade test("Settings source: invalid backend URL stays draft without changing active source",async({page})=>{await setup(page);const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});for(const value of ["bad","ftp://host.test","https://user:secret@host.test","https://host.test?token=secret"]){await input.fill(value);await input.press("Enter");await expect(page.locator("#main-content").getByRole("alert")).toContainText("Укажите HTTP(S) адрес");expect(await page.evaluate(()=>localStorage.getItem("routerlists:backend-url"))).toBeNull();await expect(input).toHaveValue(value)}}) test("Settings source: URL canonicalizes and persists after Enter",async({page})=>{await setup(page);const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});await input.fill(" http://LOCALHOST:8101/ ");await input.press("Enter");await expect(input).toHaveValue("http://localhost:8101");expect(await page.evaluate(()=>localStorage.getItem("routerlists:backend-url"))).toBe("http://localhost:8101");await page.reload();await expect(input).toHaveValue("http://localhost:8101")}) test("Settings source: explicit demo/live mode persists",async({page})=>{await setup(page);await page.getByRole("button",{name:"Моковые",exact:true}).click();expect(await page.evaluate(()=>localStorage.getItem("routerlists:data-source"))).toBe("mock");await page.reload();await expect(page.getByRole("button",{name:"Моковые",exact:true})).toBeVisible();await page.getByRole("button",{name:"Живые",exact:true}).click();expect(await page.evaluate(()=>localStorage.getItem("routerlists:data-source"))).toBe("live")}) +for(const failed of [false,true])test("Settings health: old backend result ignored/"+failed,async({page})=>{ + await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));let started=false,completed=false,release!:()=>void;const gate=new Promise(r=>release=r) + await page.route("http://localhost:8000/health",async r=>{started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"old health"}}:{headers,json:{status:"ok"}})}catch{}finally{completed=true}}) + try{await page.route("**/api/**",r=>r.fulfill({headers,json:{required:false}}));await page.goto("/settings");await expect.poll(()=>started).toBe(true);const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});await input.fill("http://localhost:8101");await input.press("Enter");await expect(page.getByText("Доступен",{exact:true})).toBeVisible();release();await expect.poll(()=>completed).toBe(true);await expect(page.getByText("Доступен",{exact:true})).toBeVisible();await expect(page.getByText("Недоступен",{exact:true})).toHaveCount(0)}finally{release()} +}) +test("Settings health: source switch does not inherit available status",async({page})=>{await setup(page);await expect(page.getByText("Доступен",{exact:true})).toBeVisible();let started=false,release!:()=>void;const gate=new Promise(r=>release=r);await page.route("http://localhost:8101/health",async r=>{started=true;await gate;try{await r.fulfill({headers,json:{ok:false}})}catch{}});try{const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});await input.fill("http://localhost:8101");await input.press("Enter");await expect.poll(()=>started).toBe(true);await expect(page.getByText("Проверка…",{exact:true})).toBeVisible();await expect(page.getByText("Доступен",{exact:true})).toHaveCount(0);release();await expect(page.getByText("Недоступен",{exact:true})).toBeVisible()}finally{release()}}) +test("Settings health: returning to source A rejects its old failed request",async({page})=>{await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",r=>r.fulfill({headers,json:{required:false}}));let count=0,freshA=false,completed=false,release!:()=>void;const gate=new Promise(r=>release=r);await page.route("http://localhost:8000/health",async r=>{count++;if(freshA)return r.fulfill({headers,json:{status:"ok"}});await gate;try{await r.fulfill({headers,status:503,json:{error:"first A failed"}})}catch{}finally{completed=true}});try{await page.goto("/settings");await expect.poll(()=>count).toBeGreaterThan(0);const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});await input.fill("http://localhost:8101");await input.press("Enter");await expect(page.getByText("Доступен",{exact:true})).toBeVisible();const previousARequests=count;freshA=true;await input.fill("http://localhost:8000");await input.press("Enter");await expect.poll(()=>count).toBeGreaterThan(previousARequests);await expect(page.getByText("Доступен",{exact:true})).toBeVisible();release();await expect.poll(()=>completed).toBe(true);await expect(page.getByText("Доступен",{exact:true})).toBeVisible()}finally{release()}}) +for(const payload of [{},{ok:false},"proxy html"])test("Settings health: HTTP 200 with invalid body is unavailable/"+JSON.stringify(payload),async({page})=>{await setup(page);await expect(page.getByText("Доступен",{exact:true})).toBeVisible();await page.route("**/health",r=>r.fulfill({headers,json:payload}));await page.getByRole("button",{name:"Проверить",exact:true}).click();await expect(page.getByText("Недоступен",{exact:true})).toBeVisible();await expect(page.getByText("Доступен",{exact:true})).toHaveCount(0)})