fix(alerts): разделить загрузку каталогов целей и правил
This commit is contained in:
1 parent
a8a3eb59fb
commit
526f305aa6
11 files changed
+153
-77
No files matched your search
+22
-74
@@ -1,6 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
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 { useDataSource } from "@/lib/data-source"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
@@ -171,12 +173,6 @@ interface AlertsMeta {
|
|||||||
greTunnelDetail?: AlertsGreTunnelRow[]
|
greTunnelDetail?: AlertsGreTunnelRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Минимальный ответ BGP для подписей «роутер · сосед». */
|
|
||||||
interface BgpSessionListRow {
|
|
||||||
serverName: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeApiFetch(backendUrl: string) {
|
function makeApiFetch(backendUrl: string) {
|
||||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
return requestJson<T>(backendUrl, path, init)
|
return requestJson<T>(backendUrl, path, init)
|
||||||
@@ -2364,7 +2360,19 @@ function AlertsContent() {
|
|||||||
const [history, setHistory] = useState<HistoryEntry[]>(isLive?[]:INIT_HISTORY)
|
const [history, setHistory] = useState<HistoryEntry[]>(isLive?[]:INIT_HISTORY)
|
||||||
const [tg, setTg] = useState<TelegramConfig>(isLive?{token:"",chatId:"",messageThreadId:"",connected:false}:INIT_TG)
|
const [tg, setTg] = useState<TelegramConfig>(isLive?{token:"",chatId:"",messageThreadId:"",connected:false}:INIT_TG)
|
||||||
const [tokenConfigured, setTokenConfigured] = useState(false)
|
const [tokenConfigured, setTokenConfigured] = useState(false)
|
||||||
const [meta, setMeta] = useState<AlertsMeta | null>(null)
|
const [baseMeta, setMeta] = useState<AlertsMeta | null>(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 [known,setKnown]=useState(!isLive)
|
||||||
const [receivedAt,setReceivedAt]=useState<string|null>(null)
|
const [receivedAt,setReceivedAt]=useState<string|null>(null)
|
||||||
@@ -2446,74 +2454,13 @@ function AlertsContent() {
|
|||||||
setRules(mapApiRules(data.rules))
|
setRules(mapApiRules(data.rules))
|
||||||
setGroups(mapApiGroups(data.groups ?? []))
|
setGroups(mapApiGroups(data.groups ?? []))
|
||||||
setHistory(mapApiHistory(data.history))
|
setHistory(mapApiHistory(data.history))
|
||||||
let mergedMeta: AlertsMeta = data.meta
|
setMeta(data.meta)
|
||||||
try {
|
|
||||||
const bgpRows = await apiFetch<BgpSessionListRow[]>("/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)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(pageActive.current&&requestRevision===revision.current)setLoadError(e instanceof Error ? e.message : "Ошибка загрузки")
|
if(pageActive.current&&requestRevision===revision.current)setLoadError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||||
} finally {
|
} finally {
|
||||||
if(pageActive.current&&requestRevision===revision.current)setLoadBusy(false)
|
if(pageActive.current&&requestRevision===revision.current)setLoadBusy(false)
|
||||||
}
|
}
|
||||||
}, [apiFetch, isLive, backendUrl])
|
}, [isLive, backendUrl])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let current=true
|
let current=true
|
||||||
@@ -2806,13 +2753,13 @@ function AlertsContent() {
|
|||||||
const chipActive = (sev: SeverityFilter) => sevFilter === sev
|
const chipActive = (sev: SeverityFilter) => sevFilter === sev
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex min-h-0 flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Система" }, { label: "Оповещения" }]}
|
crumbs={[{ label: "Система" }, { label: "Оповещения" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" disabled={loadBusy || (isLive && backendStatus === false)}
|
<Button variant="outline" size="sm" disabled={loadBusy || (isLive && backendStatus === false)}
|
||||||
onClick={() => { void load() }}>
|
onClick={() => { void load(); catalogs.refresh() }}>
|
||||||
{loadBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}
|
{loadBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2823,7 +2770,7 @@ function AlertsContent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{isLive && backendStatus === false && (
|
{isLive && backendStatus === false && (
|
||||||
@@ -2839,12 +2786,13 @@ function AlertsContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<p className="text-xs text-destructive rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2">
|
<p role="alert" className="text-xs text-destructive rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2">
|
||||||
{loadError}
|
{loadError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLive?<p role="status" className="text-xs text-muted-foreground">{known?"Последние данные получены: "+new Date(receivedAt!).toLocaleString("ru-RU"):loadBusy?"Загрузка оповещений…":"Данные оповещений ещё не получены."}</p>:null}
|
{isLive?<p role="status" className="text-xs text-muted-foreground">{known?"Последние данные получены: "+new Date(receivedAt!).toLocaleString("ru-RU"):loadBusy?"Загрузка оповещений…":"Данные оповещений ещё не получены."}</p>:null}
|
||||||
|
{isLive?<AlertsTargetsStatus catalogs={catalogs}/>:null}
|
||||||
{known?<>
|
{known?<>
|
||||||
<Collapsible open={hintsExpanded} onOpenChange={setHintsExpanded} className="rounded-lg border border-dashed border-border bg-muted/15">
|
<Collapsible open={hintsExpanded} onOpenChange={setHintsExpanded} className="rounded-lg border border-dashed border-border bg-muted/15">
|
||||||
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-foreground hover:bg-muted/40 rounded-lg">
|
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-foreground hover:bg-muted/40 rounded-lg">
|
||||||
|
|||||||
@@ -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<typeof useAlertsTargets>}){
|
||||||
|
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 <OpsPanel title="Каталоги целей" description="Обновляются независимо от правил и журнала." contentClassName="divide-y">
|
||||||
|
{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 <section key={key} aria-label={"Каталог "+label} className="flex flex-wrap items-start justify-between gap-3 px-5 py-3">
|
||||||
|
<div className="min-w-0 flex-1 basis-48 space-y-1 break-words">
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
<p role="status" className="text-xs text-muted-foreground">{state.loading?"Обновление…":state.data===undefined?"Данные ещё не получены.":"Получено объектов: "+count}{state.receivedAt?" Последний успешный ответ: "+new Date(state.receivedAt).toLocaleString("ru-RU"):""}</p>
|
||||||
|
{state.error?<p role="alert" className="text-xs text-destructive">{state.error}{state.data!==undefined?". Показан последний успешный каталог.":""}</p>:null}
|
||||||
|
<p className="text-xs text-muted-foreground">{note}</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" variant="outline" disabled={state.loading} onClick={()=>void state.refresh()} aria-label={"Обновить каталог "+label}>Обновить</Button>
|
||||||
|
</section>
|
||||||
|
})}
|
||||||
|
</OpsPanel>
|
||||||
|
}
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Актуальная точка продолжения — 2026-10-03
|
## Актуальная точка продолжения — 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).
|
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
| 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 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 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)
|
## Блок 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 не подменяется [] как доказательство полноты.
|
- Новый 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 не подменяется [] как доказательство полноты.
|
||||||
|
|||||||
@@ -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.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.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.
|
||||||
@@ -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<T>={data:T|undefined;loading:boolean;error:string|null;receivedAt:string|null}
|
||||||
|
type Reader<T>=(base:string,options:{signal:AbortSignal})=>Promise<T>
|
||||||
|
|
||||||
|
// Each catalog owns its lifetime and revision. The page is keyed by source.
|
||||||
|
function useCatalog<T>(base:string,live:boolean,permission:string,read:Reader<T>){
|
||||||
|
const [snapshot,setSnapshot]=useState<Snapshot<T>>({data:undefined,loading:false,error:null,receivedAt:null})
|
||||||
|
const active=useRef(false),revision=useRef(0),controller=useRef<AbortController|null>(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}
|
||||||
|
}
|
||||||
@@ -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" } } })
|
||||||
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
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,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
|
|||||||
@@ -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<string>();let old=true,started=0,completed=0,release!:()=>void;const gate=new Promise<void>(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()}
|
||||||
|
})
|
||||||
@@ -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<unknown>,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<void>(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)
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const emptyAlerts={telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}
|
||||||
Reference in new issue
Block a user