From 3710fc6aabe8c1e835b2f2890763be12e0c2bb13 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 17:30:46 +0700 Subject: [PATCH] =?UTF-8?q?fix(alerts):=20=D0=BE=D1=82=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=BE=D1=82=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/alerts/page.tsx | 62 +++++++++++++++++++-------------- docs/ux-ui-plan.md | 4 ++- docs/ux-ui-progress.md | 10 +++++- docs/ux-ui-stage-11b.md | 2 ++ tests/e2e/alerts-reader.spec.ts | 5 +++ 5 files changed, 54 insertions(+), 29 deletions(-) create mode 100644 tests/e2e/alerts-reader.spec.ts diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx index 2b03a87e..490b353d 100644 --- a/app/(main)/alerts/page.tsx +++ b/app/(main)/alerts/page.tsx @@ -39,6 +39,7 @@ import { } from "lucide-react" import { Flag, countryName } from "@/components/flag" import { cn } from "@/lib/utils" +import {getAlerts} from "@/shared/api/alerts" import { requestJson } from "@/shared/api/http-client" import { toast } from "sonner" @@ -2351,18 +2352,25 @@ function AddRuleSheet({ type SeverityFilter = AlertSeverity | "all" -export default function AlertsPage() { +export default function AlertsPage(){const {mode,backendUrl}=useDataSource();return } + +function AlertsContent() { const { mode, backendUrl, backendStatus } = useDataSource() const isLive = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) - const [rules, setRules] = useState(INIT_RULES) - const [groups, setGroups] = useState(INIT_GROUPS) - const [history, setHistory] = useState(INIT_HISTORY) - const [tg, setTg] = useState(INIT_TG) + const [rules, setRules] = useState(isLive?[]:INIT_RULES) + const [groups, setGroups] = useState(isLive?[]:INIT_GROUPS) + const [history, setHistory] = useState(isLive?[]:INIT_HISTORY) + const [tg, setTg] = useState(isLive?{token:"",chatId:"",messageThreadId:"",connected:false}:INIT_TG) const [tokenConfigured, setTokenConfigured] = useState(false) const [meta, setMeta] = useState(null) + const [known,setKnown]=useState(!isLive) + const [receivedAt,setReceivedAt]=useState(null) + const pageActive=useRef(true),reader=useRef(null),revision=useRef(0) + useEffect(()=>{pageActive.current=true;return()=>{pageActive.current=false;reader.current?.abort()}},[]) + const [sheetOpen, setSheetOpen] = useState(false) const [pendingPresetReplaceId, setPendingPresetReplaceId] = useState(null) const [sheetSnap, setSheetSnap] = useState(null) @@ -2407,6 +2415,7 @@ export default function AlertsPage() { }, [isLive, meta]) const load = useCallback(async () => { + if(!pageActive.current)return if (!isLive) { setRules(INIT_RULES) setGroups(INIT_GROUPS) @@ -2417,21 +2426,15 @@ export default function AlertsPage() { setLoadError(null) return } + reader.current?.abort() + const controller=new AbortController();reader.current=controller + const requestRevision=++revision.current setLoadBusy(true) setLoadError(null) try { - const data = await apiFetch<{ - telegram: { - chatId: string - tokenConfigured: boolean - connected: boolean - messageThreadId: number | null - } - groups?: AlertsApiGroup[] - rules: AlertsApiRule[] - history: AlertsApiHistoryEntry[] - meta: AlertsMeta - }>("/api/alerts") + const data = await getAlerts(backendUrl,{signal:controller.signal}) + if(!pageActive.current||requestRevision!==revision.current)return + setKnown(true);setReceivedAt(new Date().toISOString()) setTokenConfigured(data.telegram.tokenConfigured) setTg({ token: "", @@ -2445,7 +2448,7 @@ export default function AlertsPage() { setHistory(mapApiHistory(data.history)) let mergedMeta: AlertsMeta = data.meta try { - const bgpRows = await apiFetch("/api/bgp/sessions") + const bgpRows = await apiFetch("/api/bgp/sessions",{signal:controller.signal}) const bgpPeers = uniqStrings( bgpRows.map((s) => `${String(s.serverName ?? "").trim()}: ${String(s.name ?? "").trim()}`.trim()), ) @@ -2457,7 +2460,7 @@ export default function AlertsPage() { const [srvList, greRes] = await Promise.all([ apiFetch< Array<{ id: number; name: string; site?: string; country?: string; host?: string }> - >("/api/servers"), + >("/api/servers",{signal:controller.signal}), apiFetch<{ tunnels: Array<{ id?: string @@ -2467,7 +2470,7 @@ export default function AlertsPage() { status?: string comment?: string }> - }>("/api/filters/gre-tunnels"), + }>("/api/filters/gre-tunnels",{signal:controller.signal}), ]) const sidToSrv = new Map( srvList.map((s) => [ @@ -2504,18 +2507,20 @@ export default function AlertsPage() { } catch { mergedMeta = { ...mergedMeta, greTunnelTargets: [], greTunnelDetail: [] } } - setMeta(mergedMeta) + if(pageActive.current&&requestRevision===revision.current)setMeta(mergedMeta) } catch (e) { - setLoadError(e instanceof Error ? e.message : "Ошибка загрузки") + if(pageActive.current&&requestRevision===revision.current)setLoadError(e instanceof Error ? e.message : "Ошибка загрузки") } finally { - setLoadBusy(false) + if(pageActive.current&&requestRevision===revision.current)setLoadBusy(false) } - }, [apiFetch, isLive]) + }, [apiFetch, isLive, backendUrl]) useEffect(() => { + let current=true queueMicrotask(() => { - void load() + if(current)void load() }) + return()=>{current=false} }, [load]) const flushAlertsConfig = useCallback(async (nextRules: AlertRule[], nextGroups: AlertGroup[]) => { @@ -2637,7 +2642,7 @@ export default function AlertsPage() { ) const serverNamesForPresets = useMemo(() => { - if (isLive && meta?.servers?.length) return meta.servers + if (isLive) return meta?.servers??[] return TYPE_TARGETS.server }, [isLive, meta]) @@ -2811,7 +2816,7 @@ export default function AlertsPage() { {loadBusy ? : } Обновить - @@ -2839,6 +2844,8 @@ export default function AlertsPage() {

)} + {isLive?

{known?"Последние данные получены: "+new Date(receivedAt!).toLocaleString("ru-RU"):loadBusy?"Загрузка оповещений…":"Данные оповещений ещё не получены."}

:null} + {known?<> @@ -3147,6 +3154,7 @@ export default function AlertsPage() { + :null} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index d9f52ae7..9db141b1 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.4.2.2.1.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.1: Alerts UI source lifetime/strict readers/known/error/time; затем drafts/locks/rights/unknown/adaptive, GET consistency/outbox audit. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. +Этап 11Б в работе; последний проверенный блок 11Б.4.3.1.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.1.2: независимые Alerts BGP/GRE readers/last good metadata/source ABA; затем Telegram/rules drafts/locks/rights/unknown/adaptive и GET/revision/outbox audit. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). @@ -314,3 +314,5 @@ REST запрос → ожидание → подтверждённый output / 2026-10-03: 11Б.4.2.2.1.1 — общая нормализация rules/groups и 400 до удаления; 8 unit и backend profile/builds/typecheck/lint прошли. Далее transaction receipt 11Б.4.2.2.1.2. 2026-10-03: 11Б.4.2.2.1.2 — transaction rules receipt/readback/lock, DI/API/JWT/build/typecheck/lint прошли. Реальный PG rollback/overlap и optimistic revision не приняты. Далее Alerts UI 11Б.4.3.1. + +2026-10-03: 11Б.4.3.1.1 — live Alerts без первоначальных demo/0, strict primary reader/known/time, 3 E2E/lint/typecheck/build прошли. Далее independent secondary readers/source 11Б.4.3.1.2. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index e33000cf..c17d07ef 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Б.4.2.2.1.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.3.1.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.4.3.1.1 — первоначальное состояние Alerts и strict reader (2026-10-03) + +- Live root больше не инициализируется INIT_RULES/INIT_GROUPS/INIT_HISTORY/INIT_TG. До подтверждённого GET нет demo rows, ложного «Всего: 0» и якобы полученной Telegram конфигурации. Known empty отличается от unknown; основной контент раскрывается только после strict getAlerts. Browser receivedAt показан явно; failed background GET сохраняет последний снимок. +- Source-keyed root, active lifetime/AbortController/revision для GET и secondary requests; late primary/final metadata не публикуются в другой lifetime. Load после unmount не начинает новый reader. Браузерные source/ABA проверки этого раздела ещё впереди; мутации пока старые и не приняты. +- Убран прямой demo fallback для live serverNamesForPresets. Secondary BGP/GRE всё ещё последовательные, часть errors маскируется [] и load busy ждёт их; refresh всё ещё заменяет Telegram drafts. Это промежуточная миграция reader, не приёмка Alerts. +- 3 browser reader сценария (malformed без demo/0/Telegram и известный пустой snapshot с failed refresh), lint, typecheck без incremental и production frontend/contracts build прошли. Первый typecheck обнаружил collision active ref/active count и transient tsbuildinfo write error; ref переименован, nonincremental проверка и build прошли. +- Следующий точный шаг 11Б.4.3.1.2: независимые BGP/GRE readers с known/error/time, сохранение last good metadata и source/ABA E2E. Затем Telegram dirty/selective save/test/unknown, rules confirmation/lock/rights/draft preservation/adaptive. GET consistency, optimistic revision и outbox unknown policy ещё требуют аудита; этап 12 не начат. + ## Блок 11Б.4.2.2.1.2 — transactional receipt правил (2026-10-03) - Replace теперь возвращает strict rules/groups receipt из самой transaction. Omitted groups читает внутри той же transaction после nonblocking pg_try_advisory_xact_lock; lock false даёт 409 без очереди/удаления, unknown lock не подтверждает действие. Targets/conditions/groups/rules/last-fired readback использует tx context вместо отдельных pool запросов после commit. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index be72e30d..c75a30c3 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -244,3 +244,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 11Б.4.2.2.1.1: shared rules/groups normalization до delete, 8 unit/HTTP/JWT/builds/typecheck/lint прошли. Router readback остаётся после commit; omitted groups read вне transaction. Далее 11Б.4.2.2.1.2 transactional receipt/context/DI/concurrency; затем UI/outbox. Завершён 11Б.4.2.2.1.2: transactional rules/groups receipt/readback и nonblocking replace lock/409; 4 DI + profile/build/typecheck/lint прошли. Real PG и optimistic revision не проверены. Далее 11Б.4.3.1 — Alerts UI source/strict readers/known/error/time; затем drafts/locks/rights/adaptive и GET/outbox audit. + +Завершён 11Б.4.3.1.1: primary Alerts strict reader/unknown/known empty/time/last snapshot без live initial mocks; 3 E2E/lint/typecheck/build прошли. Secondary readers/dirty/forms пока прежние. Далее 11Б.4.3.1.2 — независимые BGP/GRE/last good/source ABA; затем forms/rights/adaptive/GET/revision/outbox audit. diff --git a/tests/e2e/alerts-reader.spec.ts b/tests/e2e/alerts-reader.spec.ts new file mode 100644 index 00000000..413fa286 --- /dev/null +++ b/tests/e2e/alerts-reader.spec.ts @@ -0,0 +1,5 @@ +import {test,expect,type Page} from "@playwright/test" +const empty={telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}} +async function setup(page:Page,reply:()=>{status?:number;json:unknown}){await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));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/alerts")return r.fulfill(reply());if(path==="/api/bgp/sessions"||path==="/api/servers")return r.fulfill({json:[]});if(path==="/api/filters/gre-tunnels")return r.fulfill({json:{tunnels:[]}});return r.fulfill({status:503,json:{error:"outside alerts fixture"}})});await page.goto("/alerts")} +for(const malformed of [{},{...empty,rules:null}])test("Alerts reader: malformed live response does not show demo rules or configured Telegram/"+JSON.stringify(malformed),async({page})=>{await setup(page,()=>({json:malformed}));await expect(page.getByText("Данные оповещений не подтверждены сервером",{exact:true})).toBeVisible();await expect(page.getByText("Данные оповещений ещё не получены.",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Добавить правило",exact:true})).toBeDisabled();await expect(page.getByText("Всего: 0",{exact:true})).toHaveCount(0);await expect(page.getByText("Bot Token",{exact:true})).toHaveCount(0)}) +test("Alerts reader: confirmed empty is distinct from unknown and background failure retains snapshot",async({page})=>{let fail=false;await setup(page,()=>fail?{status:503,json:{error:"Refresh unavailable"}}:{json:empty});await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await expect(page.getByText(/Последние данные получены:/)).toBeVisible();fail=true;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByText("Refresh unavailable",{exact:true})).toBeVisible();await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await expect(page.getByText(/Последние данные получены:/)).toBeVisible()})