diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx index 490b353d..74190966 100644 --- a/app/(main)/alerts/page.tsx +++ b/app/(main)/alerts/page.tsx @@ -1,6 +1,8 @@ "use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import {useAlertsTargets} from "@/hooks/use-alerts-targets" +import {AlertsTargetsStatus} from "@/components/alerts-targets-status" import { useDataSource } from "@/lib/data-source" import { PageHeader } from "@/components/page-header" import { FormToggle } from "@/components/form-kit" @@ -171,12 +173,6 @@ interface AlertsMeta { greTunnelDetail?: AlertsGreTunnelRow[] } -/** Минимальный ответ BGP для подписей «роутер · сосед». */ -interface BgpSessionListRow { - serverName: string - name: string -} - function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) @@ -2364,7 +2360,19 @@ function AlertsContent() { 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 [baseMeta, setMeta] = useState(null) + const catalogs=useAlertsTargets(backendUrl,isLive) + const meta=useMemo(()=>{ + if(!baseMeta)return null + const serverMap=new Map(catalogs.servers.data?.map(server=>[String(server.id),server])) + const greTunnelDetail:AlertsGreTunnelRow[]|undefined=catalogs.gre.data?.tunnels.map(tunnel=>{ + const server=serverMap.get(tunnel.serverId) + const routerName=server?.name.trim()||tunnel.serverId + const tunnelName=tunnel.name.trim()||tunnel.id?.trim()||"GRE" + return {targetLabel:tunnelName+" / "+routerName,tunnelName,routerName,country:server?.country||"—",site:server?.site||"—",remoteAddress:tunnel.remoteAddress||"—",status:tunnel.status?.trim()||"Неизвестно",comment:tunnel.comment} + }) + return {...baseMeta,bgpPeers:catalogs.bgp.data?.map(row=>row.serverName+": "+row.name),greTunnelDetail,greTunnelTargets:greTunnelDetail?.map(row=>row.targetLabel)} + },[baseMeta,catalogs.bgp.data,catalogs.servers.data,catalogs.gre.data]) const [known,setKnown]=useState(!isLive) const [receivedAt,setReceivedAt]=useState(null) @@ -2446,74 +2454,13 @@ function AlertsContent() { setRules(mapApiRules(data.rules)) setGroups(mapApiGroups(data.groups ?? [])) setHistory(mapApiHistory(data.history)) - let mergedMeta: AlertsMeta = data.meta - try { - 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()), - ) - mergedMeta = { ...data.meta, bgpPeers } - } catch { - mergedMeta = { ...data.meta, bgpPeers: [] } - } - try { - const [srvList, greRes] = await Promise.all([ - apiFetch< - Array<{ id: number; name: string; site?: string; country?: string; host?: string }> - >("/api/servers",{signal:controller.signal}), - apiFetch<{ - tunnels: Array<{ - id?: string - name: string - serverId: string - remoteAddress?: string - status?: string - comment?: string - }> - }>("/api/filters/gre-tunnels",{signal:controller.signal}), - ]) - const sidToSrv = new Map( - srvList.map((s) => [ - String(s.id), - { - name: String(s.name ?? "").trim() || String(s.id), - site: String(s.site ?? "").trim(), - country: String(s.country ?? "").trim(), - host: String(s.host ?? "").trim(), - }, - ]), - ) - const greTunnelDetail: AlertsGreTunnelRow[] = greRes.tunnels.map((t) => { - const srv = sidToSrv.get(String(t.serverId)) - const sn = srv?.name ?? String(t.serverId) - const tn = (String(t.name ?? "").trim() || String(t.id ?? "").trim() || "gre").trim() - const label = `${tn} / ${sn}`.trim() - const rawSt = (t.status ?? "down").toLowerCase() - const status: AlertsGreTunnelRow["status"] = - rawSt === "up" || rawSt === "down" || rawSt === "degraded" ? rawSt : "degraded" - return { - targetLabel: label, - tunnelName: tn, - routerName: sn, - country: srv?.country ? srv.country : "—", - site: srv?.site ? srv.site : "—", - remoteAddress: (String(t.remoteAddress ?? "").trim() || "—"), - status, - comment: (t.comment ?? "").trim() || undefined, - } - }) - const greTunnelTargets = uniqStrings(greTunnelDetail.map((r) => r.targetLabel)) - mergedMeta = { ...mergedMeta, greTunnelTargets, greTunnelDetail } - } catch { - mergedMeta = { ...mergedMeta, greTunnelTargets: [], greTunnelDetail: [] } - } - if(pageActive.current&&requestRevision===revision.current)setMeta(mergedMeta) + setMeta(data.meta) } catch (e) { if(pageActive.current&&requestRevision===revision.current)setLoadError(e instanceof Error ? e.message : "Ошибка загрузки") } finally { if(pageActive.current&&requestRevision===revision.current)setLoadBusy(false) } - }, [apiFetch, isLive, backendUrl]) + }, [isLive, backendUrl]) useEffect(() => { let current=true @@ -2806,13 +2753,13 @@ function AlertsContent() { const chipActive = (sev: SeverityFilter) => sevFilter === sev return ( -
+
@@ -2823,7 +2770,7 @@ function AlertsContent() { } /> -
+
{isLive && backendStatus === false && ( @@ -2839,12 +2786,13 @@ function AlertsContent() { )} {loadError && ( -

+

{loadError}

)} {isLive?

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

:null} + {isLive?:null} {known?<> diff --git a/components/alerts-targets-status.tsx b/components/alerts-targets-status.tsx new file mode 100644 index 00000000..6e2b169c --- /dev/null +++ b/components/alerts-targets-status.tsx @@ -0,0 +1,22 @@ +"use client" + +import {OpsPanel} from "@/components/ops-panel" +import {Button} from "@/components/ui/button" +import type {useAlertsTargets} from "@/hooks/use-alerts-targets" + +export function AlertsTargetsStatus({catalogs}:{catalogs:ReturnType}){ + const rows=[{key:"bgp" as const,label:"BGP",note:"API не сообщает о полноте опроса устройств."},{key:"servers" as const,label:"Серверы",note:"Подписи устройств для целей GRE."},{key:"gre" as const,label:"GRE",note:"Без статуса API состояние туннеля неизвестно."}] + return + {rows.map(({key,label,note})=>{const state=catalogs[key];const count=key==="gre"?catalogs.gre.data?.tunnels.length:key==="bgp"?catalogs.bgp.data?.length:catalogs.servers.data?.length + return
+
+

{label}

+

{state.loading?"Обновление…":state.data===undefined?"Данные ещё не получены.":"Получено объектов: "+count}{state.receivedAt?" Последний успешный ответ: "+new Date(state.receivedAt).toLocaleString("ru-RU"):""}

+ {state.error?

{state.error}{state.data!==undefined?". Показан последний успешный каталог.":""}

:null} +

{note}

+
+ +
+ })} +
+} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 2e62e81f..a90e5cc4 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.4.3.1.2.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.1.2.2: подключить независимые Alerts BGP/GRE readers/status/last good metadata/source ABA; затем Telegram/rules drafts/locks/rights/unknown/adaptive и GET/revision/outbox audit. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. +Этап 11Б в работе; последний проверенный блок 11Б.4.3.1.2.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.2: Telegram dirty draft/selective save/test/rights/unknown review/source; затем правила, GET/revision/outbox audit и оставшиеся 11Б/12. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index ff212b16..3c9decb0 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.3.1.2.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.3.1.2.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.4.3.1.2.2 — независимые каталоги Alerts (2026-10-03) + +- Primary strict reader больше не ждёт BGP/server labels/GRE. Каждый каталог имеет собственные abort/revision/lifetime, loading/error/known/time, retry и последний успешный snapshot; header refresh запускает их независимо. Unknown не отображается как пустой каталог. +- Неполный GRE с failures не заменяет последний полный список. Первоначальный partial остаётся unknown; missing status не становится down/degraded. Для отсутствующей подписи сервера используется ID с объяснением зависимости. BGP API не сообщает completeness — это прямо указано. +- 7 основных browser сценариев (3 primary, 3 catalogs и права BGP/GRE), 2 source ABA (late success/error), typecheck без incremental, lint и ReUI Frame audit пройдены. Первые test assertions на абсолютное количество GET скорректированы: initial source/StrictMode допускают отменённые GET; manual refresh проверяется относительно baseline. Общий dashboard reader отдельно обращается к /servers — его права не приняты этим блоком. +- Переиспользован установленный OpsPanel/Frame, добавлен min-h-0 scroll chain и responsive padding. Контракт компонента не менялся. Реальные RouterOS/PG/Telegram не вызывались; весь Alerts ещё не принят. +- Следующий точный шаг 11Б.4.3.2: Telegram dirty draft/selective save/test/rights/unknown review/source; затем правила, GET/revision/outbox audit и оставшиеся 11Б/12. + ## Блок 11Б.4.3.1.2.1 — строгие secondary catalog readers (2026-10-03) - Новый API module отдельно читает BGP sessions, server labels и GRE для Alerts: schema validation/known empty/caller abort до GET и после decode/30s signal. Отсутствующий GRE status остаётся undefined, не down. GRE partial device failures сохраняются, отсутствие failures в legacy DTO не подменяется [] как доказательство полноты. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index bf05c8ba..2e694239 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -248,3 +248,8 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 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. Завершён 11Б.4.3.1.2.1: strict BGP/server/GRE target readers, partial GRE failures/unknown status; 4 unit/typecheck/lint прошли, module не подключён. BGP endpoint не раскрывает failures. Далее 11Б.4.3.1.2.2 — independent readers/status/last good/source ABA; затем forms и audit. + + +### 11Б.4.3.1.2.2 — каталоги Alerts (2026-10-03) + +Primary и три вторичных reader независимы, partial GRE сохраняет last good, unknown status не down. OpsPanel/Frame показывает права, ошибки, время и отдельный retry. 9 browser сценариев, typecheck/lint/audit пройдены; реальные сетевые опросы не проверены. Следующий шаг 11Б.4.3.2: Telegram dirty draft/selective save/test/rights/unknown review/source; затем правила, GET/revision/outbox audit и оставшиеся 11Б/12. diff --git a/hooks/use-alerts-targets.ts b/hooks/use-alerts-targets.ts new file mode 100644 index 00000000..36e73ef9 --- /dev/null +++ b/hooks/use-alerts-targets.ts @@ -0,0 +1,44 @@ +"use client" + +import {useCallback,useEffect,useRef,useState} from "react" +import {can} from "@/lib/auth" +import {getAlertsBgpTargets,getAlertsServerTargets,getAlertsGreTargets} from "@/shared/api/alerts-targets" + +type Snapshot={data:T|undefined;loading:boolean;error:string|null;receivedAt:string|null} +type Reader=(base:string,options:{signal:AbortSignal})=>Promise + +// Each catalog owns its lifetime and revision. The page is keyed by source. +function useCatalog(base:string,live:boolean,permission:string,read:Reader){ + const [snapshot,setSnapshot]=useState>({data:undefined,loading:false,error:null,receivedAt:null}) + const active=useRef(false),revision=useRef(0),controller=useRef(null) + const refresh=useCallback(async()=>{ + if(!active.current||!live)return + controller.current?.abort();const requestRevision=++revision.current + if(!can(permission)){setSnapshot({data:undefined,loading:false,error:"Нет доступа к каталогу",receivedAt:null});return} + const request=new AbortController();controller.current=request + setSnapshot(previous=>({...previous,loading:true,error:null})) + const current=()=>active.current&&requestRevision===revision.current&&can(permission) + try{ + const data=await read(base,{signal:request.signal}) + if(current())setSnapshot({data,loading:false,error:null,receivedAt:new Date().toISOString()}) + }catch(error){ + if(current())setSnapshot(previous=>({...previous,loading:false,error:error instanceof Error?error.message:"Не удалось получить каталог"})) + } + },[base,live,permission,read]) + useEffect(()=>{const generation=revision;active.current=true;let pending=true;queueMicrotask(()=>{if(pending)void refresh()});return()=>{pending=false;active.current=false;generation.current++;controller.current?.abort()}},[refresh]) + return {...snapshot,refresh} +} + +async function readGre(base:string,options:{signal:AbortSignal}){ + const result=await getAlertsGreTargets(base,options) + if(result.failures?.length)throw Error("Неполный каталог GRE: не ответили устройства — "+result.failures.map(f=>f.serverName||f.serverId).join(", ")) + return result +} + +export function useAlertsTargets(base:string,live:boolean){ + const bgp=useCatalog(base,live,"mm:bgp:read",getAlertsBgpTargets) + const servers=useCatalog(base,live,"mm:servers:read",getAlertsServerTargets) + const gre=useCatalog(base,live,"mm:filters:read",readGre) + const refresh=()=>{void bgp.refresh();void servers.refresh();void gre.refresh()} + return {bgp,servers,gre,refresh} +} diff --git a/playwright.alerts-context.config.ts b/playwright.alerts-context.config.ts new file mode 100644 index 00000000..c5a91c89 --- /dev/null +++ b/playwright.alerts-context.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "@playwright/test" +import base from "./playwright.config" +const server = base.webServer +if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер") +export default defineConfig({ ...base, use: { ...base.use, baseURL: "http://127.0.0.1:3101" }, testIgnore: [], testMatch: "alerts-context.spec.ts", webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local" } } }) diff --git a/playwright.config.ts b/playwright.config.ts index bd9ce24d..0ccba510 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], + testIgnore: ["**/alerts-context.spec.ts", "**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], fullyParallel: false, workers: 1, retries: 0, diff --git a/tests/e2e/alerts-context.spec.ts b/tests/e2e/alerts-context.spec.ts new file mode 100644 index 00000000..b76522eb --- /dev/null +++ b/tests/e2e/alerts-context.spec.ts @@ -0,0 +1,13 @@ +import {test,expect,type Page} from "@playwright/test" +import {emptyAlerts} from "./fixtures/alerts" +const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"} +async function source(page:Page,value:string){const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});await input.fill(value);await input.press("Enter");await expect(input).toHaveValue(value);await expect(page.getByText("Доступен",{exact:true})).toBeVisible()} +for(const failed of [false,true])test("Alerts source: ABA discards old primary and target outcomes/"+failed,async({page})=>{ + const startedPaths=new Set();let old=true,started=0,completed=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve) + await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}})) + await page.route("**/api/**",async r=>{const url=new URL(r.request().url()),path=url.pathname;if(path==="/api/auth/config")return r.fulfill({headers,json:{required:false}}) + if(old&&url.port==="8000"&&["/api/alerts","/api/bgp/sessions","/api/filters/gre-tunnels"].includes(path)){started++;startedPaths.add(path);await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD ALERTS FAILED"}}:{headers,json:path==="/api/alerts"?{...emptyAlerts,telegram:{...emptyAlerts.telegram,chatId:"OLD CHAT"}}:path==="/api/bgp/sessions"?[{serverName:"old",name:"peer"}]:{tunnels:[{name:"old",serverId:"1"}]}})}catch{}finally{completed++};return} + return r.fulfill({headers,json:path==="/api/alerts"?emptyAlerts:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) + try{await page.goto("/alerts");await expect.poll(()=>startedPaths.size).toBe(3);await page.goto("/settings");await source(page,"http://localhost:8101");old=false;await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();release();await expect.poll(()=>completed).toBe(started);await expect(page.getByText("OLD ALERTS FAILED",{exact:true})).toHaveCount(0);await expect(page.locator('input[value="OLD CHAT"]')).toHaveCount(0);for(const label of ["BGP","GRE"])await expect(page.getByRole("region",{name:"Каталог "+label,exact:true})).toContainText("Получено объектов: 0") + }finally{release()} +}) diff --git a/tests/e2e/alerts-targets.spec.ts b/tests/e2e/alerts-targets.spec.ts new file mode 100644 index 00000000..508b0eff --- /dev/null +++ b/tests/e2e/alerts-targets.spec.ts @@ -0,0 +1,30 @@ +import {test,expect,type Page,type Route} from "@playwright/test" +import {emptyAlerts} from "./fixtures/alerts" +async function setup(page:Page,secondary:(route:Route,path:string)=>Promise,required=false){ + 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}});if(path==="/api/alerts")return r.fulfill({json:emptyAlerts});return secondary(r,path)}) + await page.goto("/alerts") +} +test("Alerts catalogs: delayed BGP does not block primary or GRE",async({page})=>{ + let started=false,release!:()=>void;const gate=new Promise(resolve=>release=resolve) + try{await setup(page,async(r,path)=>{if(path==="/api/bgp/sessions"){started=true;await gate;try{await r.fulfill({json:[]})}catch{};return}return r.fulfill({json:path==="/api/servers"?[]:{tunnels:[],failures:[]}})}) + await expect.poll(()=>started).toBe(true);await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeEnabled();await expect(page.getByRole("region",{name:"Каталог GRE",exact:true})).toContainText("Получено объектов: 0");await expect(page.getByRole("region",{name:"Каталог BGP",exact:true})).toContainText("Обновление…");release();await expect(page.getByRole("region",{name:"Каталог BGP",exact:true})).toContainText("Получено объектов: 0") + }finally{release()} +}) +test("Alerts catalogs: partial GRE and malformed refresh preserve last complete snapshot",async({page})=>{ + let response:unknown={tunnels:[{name:"gre-fixture",serverId:"1"}],failures:[]};let greCalls=0,bgpCalls=0 + await setup(page,(r,path)=>{if(path==="/api/filters/gre-tunnels"){greCalls++;return r.fulfill({json:response})}if(path==="/api/bgp/sessions"){bgpCalls++;return r.fulfill({json:[]})}return r.fulfill({json:[]})}) + const gre=page.getByRole("region",{name:"Каталог GRE",exact:true});await expect(gre).toContainText("Получено объектов: 1");const time=await gre.getByRole("status").innerText();const previousBgpCalls=bgpCalls,previousGreCalls=greCalls + response={tunnels:[],failures:[{serverId:"2",serverName:"router-fixture",error:"offline"}]};await gre.getByRole("button").click();await expect(gre.getByRole("alert")).toContainText("Неполный каталог GRE");await expect(gre.getByRole("status")).toHaveText(time);expect(bgpCalls).toBe(previousBgpCalls) + response={tunnels:null};await gre.getByRole("button").click();await expect(gre.getByRole("alert")).toContainText("Каталог GRE для оповещений не подтверждён");await expect(gre.getByRole("status")).toHaveText(time);expect(greCalls).toBe(previousGreCalls+2) +}) +test("Alerts catalogs: initial partial GRE remains unknown",async({page})=>{await setup(page,(r,path)=>r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[{serverId:"1",serverName:"router-fixture",error:"offline"}]}:[]}));const gre=page.getByRole("region",{name:"Каталог GRE",exact:true});await expect(gre.getByRole("alert")).toContainText("Неполный каталог GRE");await expect(gre.getByRole("status")).toHaveText("Данные ещё не получены.");await expect(gre).not.toContainText("Получено объектов: 0")}) + +test("Alerts catalogs: alerts read permission does not grant network catalog access",async({page})=>{ + const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:alerts:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture" + await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token) + let calls=0;await setup(page,(r,path)=>{if(["/api/bgp/sessions","/api/filters/gre-tunnels"].includes(path))calls++;return r.fulfill({json:[]})},true) + await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible() + for(const label of ["BGP","Серверы","GRE"])await expect(page.getByRole("region",{name:"Каталог "+label,exact:true}).getByRole("alert")).toHaveText("Нет доступа к каталогу") + expect(calls).toBe(0) +}) diff --git a/tests/e2e/fixtures/alerts.ts b/tests/e2e/fixtures/alerts.ts new file mode 100644 index 00000000..92947e6d --- /dev/null +++ b/tests/e2e/fixtures/alerts.ts @@ -0,0 +1 @@ +export const emptyAlerts={telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}