fix(settings): проверять доступность текущего источника без гонок
This commit is contained in:
1 parent
b6f1b72500
commit
e5842b415b
8 files changed
+67
-12
No files matched your search
@@ -92,7 +92,7 @@ function DatabaseRestoreConfirm({
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [section, setSection] = useState<NavSection>("Общие")
|
||||
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 && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="size-2 rounded-full bg-muted-foreground/40 inline-block" />
|
||||
Не проверялось
|
||||
{backendChecking ? "Проверка…" : "Не проверялось"}
|
||||
</span>
|
||||
)}
|
||||
{backendStatus === true && (
|
||||
|
||||
@@ -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.
|
||||
@@ -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 сервиса.
|
||||
|
||||
@@ -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.
|
||||
+30
-9
@@ -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<void>
|
||||
}
|
||||
|
||||
@@ -68,16 +69,30 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
|
||||
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
|
||||
const [backendUrl, setBackendUrlState] = useState(initialBackendUrl)
|
||||
const [prefsHydrated, setPrefsHydrated] = useState(false)
|
||||
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(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(() => {
|
||||
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 (
|
||||
<DataSourceContext.Provider
|
||||
value={{ mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, prefsHydrated, backendStatus, checkBackend }}
|
||||
value={{ mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, prefsHydrated, backendStatus, backendChecking, checkBackend }}
|
||||
>
|
||||
{children}
|
||||
</DataSourceContext.Provider>
|
||||
|
||||
@@ -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("<html>proxy</html>",{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}})
|
||||
@@ -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<void>{
|
||||
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())
|
||||
}
|
||||
@@ -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:[email protected]","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<void>(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<void>(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<void>(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)})
|
||||
Reference in new issue
Block a user