From 9c1d14635a5527d2e86de8bd6636f081112eb07e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 18:13:57 +0700 Subject: [PATCH] =?UTF-8?q?fix(alerts):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D1=8C=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=8F=D1=82=D1=8C?= =?UTF-8?q?=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=BF=D1=80=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B1=D0=BE=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/alerts/page.tsx | 238 +++++++++++-------------------- components/alerts-group-form.tsx | 19 +++ docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 11 +- docs/ux-ui-stage-11b.md | 5 + hooks/use-alerts-config-write.ts | 37 +++++ tests/e2e/alerts-context.spec.ts | 10 +- tests/e2e/alerts-rules.spec.ts | 27 ++++ tests/e2e/fixtures/alerts.ts | 3 + 9 files changed, 198 insertions(+), 154 deletions(-) create mode 100644 components/alerts-group-form.tsx create mode 100644 hooks/use-alerts-config-write.ts create mode 100644 tests/e2e/alerts-rules.spec.ts diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx index 9d8374ee..db9d601f 100644 --- a/app/(main)/alerts/page.tsx +++ b/app/(main)/alerts/page.tsx @@ -1,6 +1,9 @@ "use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import {can} from "@/lib/auth" +import {useAlertsConfigWrite} from "@/hooks/use-alerts-config-write" +import {AlertsGroupForm} from "@/components/alerts-group-form" import {useAlertsTargets} from "@/hooks/use-alerts-targets" import {AlertsTelegramForm} from "@/components/alerts-telegram-form" import {AlertsTargetsStatus} from "@/components/alerts-targets-status" @@ -670,10 +673,10 @@ const ALERT_PRESETS: AlertPresetDef[] = [ description: "До 6 правил: задержка выше 100 мс для первых серверов.", build: (serverNames) => serverNames.slice(0, 6).map((name) => ({ - name: `RTT ${name} → 8.8.8.8`, + name: `RTT ${name}`, type: "rtt" as const, - target: `${name} → 8.8.8.8`, - targets: [`${name} → 8.8.8.8`], + target: name, + targets: [name], groupId: null, condition: "> 100 мс", conditions: ["> 100 мс"], @@ -731,7 +734,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled } !rule.enabled && "opacity-55", )}> {/* toggle */} - onToggle(rule.id, v)} disabled={interactionsDisabled} /> + onToggle(rule.id, v)} disabled={interactionsDisabled} /> {/* severity dot */} @@ -790,14 +793,14 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled } {/* actions (visible on hover) */} -
+
{onEdit && ( @@ -805,6 +808,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }
{ @@ -2170,11 +2179,11 @@ function AddRuleSheet({
)} - + {/* ── fixed footer ── */} - {isLive && onTestRuleTelegram ? ( @@ -2182,10 +2191,10 @@ function AddRuleSheet({ type="button" variant="secondary" className="w-full sm:flex-1 min-h-9 inline-flex items-center justify-center gap-2" - disabled={!canSave || testTelegramBusy || testTelegramDisabled} + disabled={!canSave || testTelegramBusy || testTelegramDisabled||saving} title={ testTelegramDisabled - ? "Бэкенд недоступен" + ? "Тест из формы будет доступен после подключения защиты повторной отправки" : !canSave ? "Заполните название, объект, условие и при необходимости порог" : undefined @@ -2200,7 +2209,9 @@ function AddRuleSheet({ Тест в Telegram ) : null} - @@ -2255,9 +2266,14 @@ function AlertsContent() { const [loadBusy, setLoadBusy] = useState(false) const [loadError, setLoadError] = useState(null) - const [rulesSaveBusy, setRulesSaveBusy] = useState(false) - const telegramLock=useRef(false) - const [telegramBusy,setTelegramBusy]=useState(false) + const operationLock=useRef(false) + const [operationBusy,setOperationBusy]=useState(false) + const beginOperation=()=>{if(!pageActive.current||operationLock.current)return false;operationLock.current=true;reader.current?.abort();revision.current++;setLoadBusy(false);setOperationBusy(true);return true} + const endOperation=()=>{operationLock.current=false;setOperationBusy(false)} + const config=useAlertsConfigWrite(backendUrl,isLive,known&&backendStatus===true,beginOperation,endOperation) + const rulesSaveBusy=config.busy + const writesDisabled=!config.enabled||operationBusy||config.unknown + const [pendingDelete,setPendingDelete]=useState<{kind:"rule"|"group";id:string}|null>(null) /** Пресеты и группы ANY/ALL — по умолчанию свёрнуты, чтобы не занимать место */ const [extrasExpanded, setExtrasExpanded] = useState(false) const [hintsExpanded, setHintsExpanded] = useState(false) @@ -2289,7 +2305,7 @@ function AlertsContent() { }, [isLive, meta]) const load = useCallback(async () => { - if(!pageActive.current||telegramLock.current)return + if(!pageActive.current||operationLock.current)return if (!isLive) { setRules(INIT_RULES) setGroups(INIT_GROUPS) @@ -2336,29 +2352,11 @@ function AlertsContent() { return()=>{current=false} }, [load]) - const flushAlertsConfig = useCallback(async (nextRules: AlertRule[], nextGroups: AlertGroup[]) => { - setRules(nextRules) - setGroups(nextGroups) - if (!isLive) return - setRulesSaveBusy(true) - setLoadError(null) - try { - const res = await apiFetch<{ rules: AlertsApiRule[]; groups: AlertsApiGroup[] }>("/api/alerts/rules", { - method: "PUT", - body: JSON.stringify({ - rules: nextRules.map(toApiRuleBody), - groups: nextGroups.map(toApiGroupBody), - }), - }) - setRules(mapApiRules(res.rules)) - setGroups(mapApiGroups(res.groups ?? [])) - } catch (e) { - setLoadError(e instanceof Error ? e.message : "Не удалось сохранить правила и группы") - await load() - } finally { - setRulesSaveBusy(false) - } - }, [apiFetch, isLive, load]) + const flushAlertsConfig=useCallback(async(nextRules:AlertRule[],nextGroups:AlertGroup[])=>{ + const receipt=await config.save({rules:nextRules.map(toApiRuleBody),groups:nextGroups.map(toApiGroupBody)}) + if(!receipt||!pageActive.current)return false + setRules(mapApiRules(receipt.rules));setGroups(mapApiGroups(receipt.groups));return true + },[config]) const testRuleTelegramFromForm = useCallback( async (p: RuleTelegramTestPayload) => { @@ -2398,8 +2396,8 @@ function AlertsContent() { (presetId: string) => { const def = ALERT_PRESETS.find((p) => p.id === presetId) if (!def) return - const built = def.build(serverNamesForPresets) - if (built.length === 0) return + const built = def.build(presetId==="rtt-8888-pack"?(isLive?(meta?.rttLossTargets??[]):TYPE_TARGETS.rtt).filter(target=>target.endsWith("→ 8.8.8.8")):serverNamesForPresets) + if(!built.length){setLoadError("Для пресета нет подтверждённых объектов. Список правил сохранён.");return} const newRows: AlertRule[] = built.map((b) => ({ ...b, id: @@ -2411,14 +2409,15 @@ function AlertsContent() { })) void flushAlertsConfig([...rules, ...newRows], groups) }, - [serverNamesForPresets, rules, groups, flushAlertsConfig], + [isLive,meta,serverNamesForPresets, rules, groups, flushAlertsConfig], ) const applyPresetReplace = useCallback( (presetId: string) => { const def = ALERT_PRESETS.find((p) => p.id === presetId) if (!def) return - const built = def.build(serverNamesForPresets) + const built = def.build(presetId==="rtt-8888-pack"?(isLive?(meta?.rttLossTargets??[]):TYPE_TARGETS.rtt).filter(target=>target.endsWith("→ 8.8.8.8")):serverNamesForPresets) + if(!built.length){setLoadError("Для пресета нет подтверждённых объектов. Список правил сохранён.");return} const newRows: AlertRule[] = built.map((b) => ({ ...b, id: @@ -2430,7 +2429,7 @@ function AlertsContent() { })) void flushAlertsConfig(newRows, groups) }, - [serverNamesForPresets, groups, flushAlertsConfig], + [isLive,meta,serverNamesForPresets, groups, flushAlertsConfig], ) const addGroup = useCallback(() => { @@ -2449,7 +2448,7 @@ function AlertsContent() { const patchGroup = useCallback( (id: string, patch: Partial) => { - void flushAlertsConfig( + return flushAlertsConfig( rules, groups.map((x) => (x.id === id ? { ...x, ...patch } : x)), ) @@ -2482,55 +2481,48 @@ function AlertsContent() { const handleToggle = (id: string, enabled: boolean) => { const next = rules.map(r => r.id === id ? { ...r, enabled } : r) - if (isLive) void flushAlertsConfig(next, groups) - else { - setRules(next) - } + void flushAlertsConfig(next, groups) } const handleDelete = (id: string) => { const next = rules.filter(r => r.id !== id) - if (isLive) void flushAlertsConfig(next, groups) - else { - setRules(next) - } + void flushAlertsConfig(next, groups) } const closeRuleSheet = useCallback(() => { + if(operationLock.current)return setSheetOpen(false) setSheetEditId(null) setSheetSnap(null) }, []) const openRuleSheetAdd = useCallback(() => { + if(!config.enabled||operationLock.current||config.unknown)return setSheetEditId(null) setSheetSnap(null) setSheetKey((k) => k + 1) setSheetOpen(true) - }, []) + }, [config.enabled,config.unknown]) const openRuleSheetEdit = useCallback((id: string) => { + if(!config.enabled||operationLock.current||config.unknown)return const r = rules.find((x) => x.id === id) if (!r) return setSheetSnap(ruleToFormSnapshot(r)) setSheetEditId(id) setSheetKey((k) => k + 1) setSheetOpen(true) - }, [rules]) + }, [rules,config.enabled,config.unknown]) const handleRuleSheetSave = useCallback( - (payload: Omit, editId: string | null) => { + async (payload: Omit, editId: string | null) => { if (editId) { const cur = rules.find((x) => x.id === editId) - if (!cur) return + if (!cur){setLoadError("Правило больше не существует. Обновите список и проверьте черновик.");return false} const next = rules.map((x) => x.id === editId ? { ...x, ...payload, id: editId, enabled: cur.enabled, lastFired: cur.lastFired } : x, ) - if (isLive) void flushAlertsConfig(next, groups) - else { - setRules(next) - } - return + return flushAlertsConfig(next, groups) } const row: AlertRule = { ...payload, @@ -2539,12 +2531,9 @@ function AlertsContent() { lastFired: null, } const next = [...rules, row] - if (isLive) void flushAlertsConfig(next, groups) - else { - setRules(next) - } + return flushAlertsConfig(next, groups) }, - [rules, groups, isLive, flushAlertsConfig], + [rules, groups, flushAlertsConfig], ) // Summary chip click handler @@ -2559,12 +2548,12 @@ function AlertsContent() { crumbs={[{ label: "Система" }, { label: "Оповещения" }]} actions={ <> - - @@ -2593,6 +2582,11 @@ function AlertsContent() { )} {isLive?

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

:null} + {!can("mm:alerts:write")?

Нет права изменять правила и группы.

:null} + {config.error?

{config.error}

:null} + {config.outcome?

{config.outcome}

:null} + {config.busy?

Ожидаем подтверждение правил и групп…

:null} + {config.unknown&&!config.busy?

Результат сохранения правил неизвестен. Запрос мог выполниться; проверьте правила и группы в БД перед повтором. Обновление списка не разрешает повтор автоматически.

:null} {isLive?:null} {known?<> @@ -2719,7 +2713,7 @@ function AlertsContent() { size="sm" variant="secondary" className="h-7 text-xs" - disabled={rulesSaveBusy || (isLive && backendStatus === false)} + disabled={writesDisabled} onClick={() => applyPresetAppend(p.id)} > Добавить к списку @@ -2729,7 +2723,7 @@ function AlertsContent() { size="sm" variant="outline" className="h-7 text-xs" - disabled={rulesSaveBusy || (isLive && backendStatus === false)} + disabled={writesDisabled} onClick={() => setPendingPresetReplaceId(p.id)} > Заменить все @@ -2747,7 +2741,7 @@ function AlertsContent() { size="sm" variant="secondary" className="h-7 text-xs shrink-0" - disabled={rulesSaveBusy || (isLive && backendStatus === false)} + disabled={writesDisabled} onClick={addGroup} > Группа @@ -2760,70 +2754,7 @@ function AlertsContent() { Групп пока нет. Создайте группу и назначьте правилам её в форме правила — движок отправит одно сообщение по логике ANY или ALL.

) : ( - groups.map((g) => ( -
-
- patchGroup(g.id, { name: e.target.value })} - className="h-8 text-xs flex-1 min-w-[140px]" - aria-label="Имя группы" - /> - - -
-
- Режим - - Cooldown группы - -
-
- )) + groups.map(g=>patchGroup(g.id,patch)} onDelete={()=>setPendingDelete({kind:"group",id:g.id})}/>) )} @@ -2866,9 +2797,9 @@ function AlertsContent() { key={r.id} rule={r} onToggle={handleToggle} - onDelete={handleDelete} + onDelete={id=>setPendingDelete({kind:"rule",id})} onEdit={openRuleSheetEdit} - interactionsDisabled={rulesSaveBusy} + interactionsDisabled={writesDisabled} /> ))} @@ -2883,6 +2814,7 @@ function AlertsContent() { :null} typeTargets={typeTargets} isLive={isLive} serversCatalog={serversCatalog} @@ -2916,9 +2849,12 @@ function AlertsContent() { initialForm={sheetSnap} editRuleId={sheetEditId} onTestRuleTelegram={isLive ? testRuleTelegramFromForm : undefined} - testTelegramDisabled={isLive && backendStatus === false} + testTelegramDisabled={true} /> + {if(!open&&!operationLock.current)setPendingDelete(null)}}> + {pendingDelete?.kind==="group"?"Удалить группу?":"Удалить правило?"}{pendingDelete?.kind==="group"?"Правила сохранятся, но больше не будут входить в эту группу.":"Правило перестанет участвовать в оповещениях после подтверждения сервера."}Отмена{if(!pendingDelete)return;if(pendingDelete.kind==="rule")handleDelete(pendingDelete.id);else removeGroup(pendingDelete.id);setPendingDelete(null)}}>Удалить + { @@ -2935,7 +2871,7 @@ function AlertsContent() { Отмена { if (pendingPresetReplaceId) applyPresetReplace(pendingPresetReplaceId) setPendingPresetReplaceId(null) diff --git a/components/alerts-group-form.tsx b/components/alerts-group-form.tsx new file mode 100644 index 00000000..4b5ae99c --- /dev/null +++ b/components/alerts-group-form.tsx @@ -0,0 +1,19 @@ +"use client" +import {useState} from "react" +import type {AlertGroup} from "@mmapp/contracts/alerts" +import {Input} from "@/components/ui/input" +import {Button} from "@/components/ui/button" +export function AlertsGroupForm({group,disabled,onSave,onDelete}:{group:AlertGroup;disabled:boolean;onSave:(patch:Partial)=>Promise;onDelete:()=>void}){ + const [draft,setDraft]=useState>({}) + const value={...group,...draft},dirty=Object.keys(draft).some(key=>value[key as keyof AlertGroup]!==group[key as keyof AlertGroup]) + return
+
+ setDraft(previous=>({...previous,name:e.target.value}))} className="text-xs"/> + + + +
+
+ {dirty?

Изменения группы ещё не сохранены.

:null} +
+} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index c4d15d28..9eb20bb8 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.4.3.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.3: правила и группы — strict receipt, права, общий замок, сохранение формы при ошибках и unknown review; затем GET/revision/outbox audit, общий dashboard permission reader, Releases/probe scheduling и приёмка 11Б/12. +Этап 11Б в работе; последний проверенный блок 11Б.4.3.3; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.3.1: защищённый тест Telegram из редактора правила с общим checkpoint и замком; затем согласованность GET/revision/outbox, общий dashboard permission reader, 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 4371c68b..bd380707 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.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.3.3; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11Б.4.3.3 — запись правил и групп по подтверждению API (2026-10-03) + +- Strict saveAlertRules подключён вместо generic PUT/оптимистичной подмены. Pending/failed/unknown оставляет последний подтверждённый список; editor ждёт receipt и закрывается только после success, error/unknown сохраняет draft. Исправлена потеря recoveryMode/recoveryStabilitySec при edit — форма передаёт сохранённую политику. +- Группы имеют отдельный локальный draft и явное сохранение: ввод имени/ANY/ALL/cooldown/enabled больше не пишет на каждый символ. Ошибка сохраняет draft. Удаление правила/группы требует dialog; group delete снимает membership, сохраняет rules. +- Общий synchronous lock правил/Telegram, caller abort/source lifetime, отмена primary GET до записи, права alerts:write/live/health и unknown checkpoint на источник (без секретов). Refresh/reload/ABA не разрешают повтор unknown автоматически. В редакторе есть explicit review, позволяющий сохранить draft перед повтором; storage-unavailable limitation тот же. +- Пресет без подтверждённых объектов не очищает список; live RTT preset использует подтверждённые rttLossTargets для 8.8.8.8 вместо выдуманной пробы по имени сервера. Демо mutation controls выключены. Действия строки имеют accessible names и видны с клавиатуры/на телефоне. +- 8 rules/groups browser + 2 source ABA, 10 Telegram regression, typecheck без incremental/lint, contracts/frontend production build и профильный ReUI audit пройдены. Первые selectors уточнены для двух Add entry points и Next route announcer. Весь Alerts ещё не принят: старый preview Telegram из editor временно выключен до подключения общего guard; оставшиеся picker scroll/a11y и backend consistency впереди. +- Следующий точный шаг 11Б.4.3.3.1: защищённый тест Telegram из редактора правила с общим checkpoint и замком; затем согласованность GET/revision/outbox, общий dashboard permission reader, Releases/probe scheduling и приёмка 11Б/12. + ## Блок 11Б.4.3.2 — подтверждённые параметры и тест Telegram (2026-10-03) - Новая форма на установленном OpsPanel/Frame хранит dirty поля отдельно от полученного snapshot. Refresh сохраняет черновик, untouched поля получают новые данные; PUT передаёт только изменённые поля. Токен не сохраняется в browser storage. Пустой thread отправляется явно null; некорректное число остаётся в форме и не отправляет API. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 288d94bd..290ab76e 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -258,3 +258,8 @@ Primary и три вторичных reader независимы, partial GRE с ### 11Б.4.3.2 — Telegram (2026-10-03) Dirty/selective save, strict receipt, общий lock, bounded source lifetime, unknown checkpoint без секретов и права alerts:write. 10 профильных browser сценариев + 4 source ABA, регрессия readers, lint/typecheck; форма 320px просмотрена. API acceptance не означает доставку. Rule preview и реальные Telegram/PG пока не приняты. Далее 11Б.4.3.3: правила и группы — strict receipt, права, общий замок, сохранение формы при ошибках и unknown review; затем GET/revision/outbox audit, общий dashboard permission reader, Releases/probe scheduling и приёмка 11Б/12. + + +### 11Б.4.3.3 — правила и группы (2026-10-03) + +Strict receipt вместо optimistic overwrite, draft editor/group формы, права/common lock/source/unknown, подтверждение delete и сохранение recovery policy. 8 browser + 2 source, Telegram regression, typecheck/lint/build пройдены. Preview Telegram временно disabled; picker и backend consistency ещё не приняты. Далее 11Б.4.3.3.1: защищённый тест Telegram из редактора правила с общим checkpoint и замком; затем согласованность GET/revision/outbox, общий dashboard permission reader, Releases/probe scheduling и приёмка 11Б/12. diff --git a/hooks/use-alerts-config-write.ts b/hooks/use-alerts-config-write.ts new file mode 100644 index 00000000..7d1f7204 --- /dev/null +++ b/hooks/use-alerts-config-write.ts @@ -0,0 +1,37 @@ +"use client" + +import {useEffect,useRef,useState} from "react" +import {normalizeAlertsConfig,type PutRulesBody} from "@mmapp/contracts/alerts" +import {can} from "@/lib/auth" +import {ApiClientError} from "@/shared/api/http-client" +import {saveAlertRules} from "@/shared/api/alerts" +const pending=new Set() +const key=(source:string)=>"mm:alerts-rules-pending:"+source +function unresolved(source:string){try{return pending.has(source)||sessionStorage.getItem(key(source))==="true"}catch{return pending.has(source)}} +function checkpoint(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(key(source),"true");else sessionStorage.removeItem(key(source))}catch{/* The memory guard survives section navigation. */}} + +export function useAlertsConfigWrite(base:string,live:boolean,available:boolean,onBegin:()=>boolean,onEnd:()=>void){ + const source=JSON.stringify([live,base]) + const [busy,setBusy]=useState(false),[error,setError]=useState(null),[outcome,setOutcome]=useState(null),[unknown,setUnknown]=useState(false),[ready,setReady]=useState(false) + const active=useRef(true),lock=useRef(false),controller=useRef(null) + useEffect(()=>{active.current=true;queueMicrotask(()=>{if(active.current){setReady(true);setUnknown(unresolved(source))}});return()=>{active.current=false;controller.current?.abort()}},[source]) + const enabled=ready&&live&&available&&can("mm:alerts:write") + async function save(payload:PutRulesBody){ + if(!enabled||lock.current||unknown||!active.current||!can("mm:alerts:write"))return null + let normalized:ReturnType + try{normalized=normalizeAlertsConfig(payload)}catch(e){setError(e instanceof Error?e.message:"Проверьте параметры правил");return null} + if(!onBegin())return null + lock.current=true;controller.current=new AbortController();setBusy(true);setError(null);setOutcome(null);checkpoint(source,true);setUnknown(true) + const current=()=>active.current&&can("mm:alerts:write") + try{ + const receipt=await saveAlertRules(base,normalized,{signal:controller.current.signal});if(!current())return null + checkpoint(source,false);setUnknown(false);setOutcome("Правила и группы сохранены в БД");return receipt + }catch(e){ + const rejected=e instanceof ApiClientError&&[400,401,403,409,422].includes(e.status) + if(rejected&¤t())checkpoint(source,false) + if(current()){setUnknown(!rejected);setError(e instanceof Error?e.message:"Результат сохранения правил не получен")};return null + }finally{lock.current=false;controller.current=null;if(active.current){setBusy(false);onEnd()}} + } + function review(){if(!enabled||busy)return;checkpoint(source,false);setUnknown(false)} + return {save,busy,error,outcome,unknown,enabled,review} +} diff --git a/tests/e2e/alerts-context.spec.ts b/tests/e2e/alerts-context.spec.ts index c69c9cb8..0cec7546 100644 --- a/tests/e2e/alerts-context.spec.ts +++ b/tests/e2e/alerts-context.spec.ts @@ -1,5 +1,5 @@ import {test,expect,type Page} from "@playwright/test" -import {emptyAlerts} from "./fixtures/alerts" +import {emptyAlerts,alertsWithRule} 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})=>{ @@ -23,3 +23,11 @@ for(const kind of ["save","test"] as const)for(const failed of [false,true])test await page.goto("/settings");await source(page,"http://localhost:8101");await page.goto("/alerts");await expect(form().getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeEnabled();await page.goto("/settings");await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(form()).toContainText("Результат запроса неизвестен");release();await expect.poll(()=>completed).toBe(true);await expect(form().getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();await expect(form()).not.toContainText("Параметры Telegram сохранены в БД");await expect(form()).not.toContainText("API подтвердил приём");await expect(form()).not.toContainText("OLD TELEGRAM FAILED");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue(configuredTelegram.chatId);expect(calls).toBe(1) }finally{release()} }) + +for(const failed of [false,true])test("Rules source: ABA ignores old save and retains scoped unknown checkpoint/"+failed,async({page})=>{ + await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?alertsWithRule:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) + let started=false,completed=false,calls=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve) + await page.route("http://localhost:8000/api/alerts/rules",async r=>{calls++;started=true;const body=r.request().postDataJSON();await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD RULES FAILED"}}:{headers,json:{ok:true,groups:body.groups,rules:body.rules.map((row:object)=>({...row,lastFiredAt:null}))}})}catch{}finally{completed=true}}) + const toggle=()=>page.getByRole("switch",{name:"Включить правило Fixture rule",exact:true}) + try{await page.goto("/alerts");await expect(toggle()).toBeEnabled();await toggle().click();await expect.poll(()=>started).toBe(true);await page.goto("/settings");await source(page,"http://localhost:8101");await page.goto("/alerts");await expect(toggle()).toBeEnabled();await page.goto("/settings");await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(page.getByText(/Результат сохранения правил неизвестен/)).toBeVisible();release();await expect.poll(()=>completed).toBe(true);await expect(toggle()).toBeChecked();await expect(toggle()).toBeDisabled();await expect(page.getByText("Правила и группы сохранены в БД",{exact:true})).toHaveCount(0);await expect(page.getByText("OLD RULES FAILED",{exact:true})).toHaveCount(0);expect(calls).toBe(1)}finally{release()} +}) diff --git a/tests/e2e/alerts-rules.spec.ts b/tests/e2e/alerts-rules.spec.ts new file mode 100644 index 00000000..edeb801d --- /dev/null +++ b/tests/e2e/alerts-rules.spec.ts @@ -0,0 +1,27 @@ +import {test,expect,type Page,type Route} from "@playwright/test" +import {alertsWithRule} from "./fixtures/alerts" +async function setup(page:Page,write:(route:Route)=>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:alertsWithRule});if(path==="/api/alerts/rules")return write(r);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");await expect(page.getByText("Всего: 1",{exact:true})).toBeVisible() +} +function receipt(r:Route){const body=r.request().postDataJSON();return {ok:true,groups:body.groups,rules:body.rules.map((rule:object)=>({...rule,lastFiredAt:null}))}} +const toggle=(page:Page)=>page.getByRole("switch",{name:"Включить правило Fixture rule",exact:true}) +test("Rules: pending toggle keeps confirmed state, excludes writes and commits only strict receipt",async({page})=>{ + let calls=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve) + try{await setup(page,async r=>{calls++;await gate;return r.fulfill({json:receipt(r)})});await toggle(page).click();await expect.poll(()=>calls).toBe(1);await expect(toggle(page)).toBeChecked();await expect(toggle(page)).toBeDisabled();await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeDisabled();for(const button of await page.getByRole("button",{name:"Добавить правило",exact:true}).all())await expect(button).toBeDisabled();release();await expect(toggle(page)).not.toBeChecked();await expect(page.getByText("Правила и группы сохранены в БД",{exact:true})).toBeVisible();expect(calls).toBe(1)}finally{release()} +}) +test("Rules: failed edit keeps form and recovery settings for explicit retry",async({page})=>{ + let calls=0,payload:unknown,reject=true;await setup(page,r=>{calls++;payload=r.request().postDataJSON();return r.fulfill(reject?{status:409,json:{error:"Fixture conflict"}}:{json:receipt(r)})});await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();const dialog=page.getByRole("dialog"),name=dialog.getByRole("textbox",{name:"Название правила",exact:true});await name.fill("Changed rule");await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog.getByRole("alert")).toHaveText("Fixture conflict");await expect(name).toHaveValue("Changed rule");await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();expect((payload as {rules:{recoveryMode:string;recoveryStabilitySec:number}[]}).rules[0]).toMatchObject({recoveryMode:"conditional",recoveryStabilitySec:17});reject=false;await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog).not.toBeVisible();await expect(page.getByText("Changed rule",{exact:true})).toBeVisible();expect(calls).toBe(2) +}) +test("Rules: malformed delete preserves row and unknown guard across refresh/reload",async({page})=>{ + let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:{ok:false}})});await page.getByRole("button",{name:"Удалить правило Fixture rule",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click();await expect(page.locator("#main-content").getByRole("alert")).toContainText("не подтверждено");await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();await expect(toggle(page)).toBeDisabled();await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(toggle(page)).toBeDisabled();await page.reload();await expect(page.getByText(/Результат сохранения правил неизвестен/)).toBeVisible();await expect(toggle(page)).toBeDisabled();expect(calls).toBe(1) +}) +test("Groups: typing is draft, failed save retains it and successful receipt updates group",async({page})=>{ + let calls=0,reject=true,payload:unknown;await setup(page,r=>{calls++;payload=r.request().postDataJSON();return r.fulfill(reject?{status:409,json:{error:"Fixture group conflict"}}:{json:receipt(r)})});await page.getByRole("button",{name:/Пресеты и группы/}).click();const group=page.getByRole("region",{name:"Группа Fixture group",exact:true});await group.getByRole("textbox",{name:"Имя группы",exact:true}).fill("New draft group");expect(calls).toBe(0);await group.getByRole("combobox",{name:"Режим группы",exact:true}).selectOption("all");await group.getByRole("button",{name:"Сохранить группу",exact:true}).click();await expect(page.locator("#main-content").getByRole("alert")).toContainText("Fixture group conflict");await expect(group.getByRole("textbox")).toHaveValue("New draft group");expect((payload as {groups:object[]}).groups[0]).toMatchObject({name:"New draft group",combineMode:"all"});reject=false;await group.getByRole("button",{name:"Сохранить группу",exact:true}).click();await expect(page.getByRole("region",{name:"Группа New draft group",exact:true})).toBeVisible();expect(calls).toBe(2) +}) +test("Rules: read permission keeps all mutation entry points disabled",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=>{calls++;return r.fulfill({json:receipt(r)})},true);await expect(toggle(page)).toBeDisabled();await expect(page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Удалить правило Fixture rule",exact:true})).toBeDisabled();for(const button of await page.getByRole("button",{name:"Добавить правило",exact:true}).all())await expect(button).toBeDisabled();await page.getByRole("button",{name:/Пресеты и группы/}).click();await expect(page.getByRole("button",{name:"Группа",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Добавить к списку",exact:true}).first()).toBeDisabled();await expect(page.getByRole("region",{name:"Группа Fixture group",exact:true}).getByRole("textbox")).toBeDisabled();expect(calls).toBe(0) +}) + +test("Rules: unknown edit keeps draft through explicit review and confirmed retry",async({page})=>{let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:calls===1?{ok:false}:receipt(r)})});await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();const dialog=page.getByRole("dialog"),input=dialog.getByRole("textbox",{name:"Название правила",exact:true});await input.fill("Changed unknown draft");await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog.getByRole("alert")).toContainText("не подтверждено");await expect(input).toHaveValue("Changed unknown draft");await expect(dialog.getByRole("button",{name:"Сохранить изменения",exact:true})).toBeDisabled();await dialog.getByRole("button",{name:"Я проверил правила — разрешить новый запрос",exact:true}).click();await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog).not.toBeVisible();await expect(page.getByText("Changed unknown draft",{exact:true})).toBeVisible();expect(calls).toBe(2)}) +test("Rules presets: no confirmed RTT targets cannot clear existing rules",async({page})=>{let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:receipt(r)})});await page.getByRole("button",{name:/Пресеты и группы/}).click();await page.getByRole("button",{name:"Заменить все",exact:true}).nth(1).click();await page.getByRole("alertdialog").getByRole("button",{name:"Заменить",exact:true}).click();await expect(page.locator("#main-content").getByRole("alert")).toHaveText("Для пресета нет подтверждённых объектов. Список правил сохранён.");await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();expect(calls).toBe(0)}) +test("Groups: confirmed delete removes group while retaining its rules without membership",async({page})=>{let payload:{groups:unknown[];rules:{groupId:string|null}[]}|undefined;await setup(page,r=>{payload=r.request().postDataJSON();return r.fulfill({json:receipt(r)})});await page.getByRole("button",{name:/Пресеты и группы/}).click();await page.getByRole("region",{name:"Группа Fixture group",exact:true}).getByRole("button",{name:"Удалить группу",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click();await expect(page.getByRole("region",{name:"Группа Fixture group",exact:true})).toHaveCount(0);await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();expect(payload?.groups).toEqual([]);expect(payload?.rules).toHaveLength(1);expect(payload?.rules[0]?.groupId).toBeNull()}) diff --git a/tests/e2e/fixtures/alerts.ts b/tests/e2e/fixtures/alerts.ts index 92947e6d..b38079cc 100644 --- a/tests/e2e/fixtures/alerts.ts +++ b/tests/e2e/fixtures/alerts.ts @@ -1 +1,4 @@ export const emptyAlerts={telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}} +export const alertsRule={id:"rule-fixture",name:"Fixture rule",type:"server",target:"router-fixture",targets:["router-fixture"],groupId:"group-fixture",condition:"перешёл в offline",conditions:["перешёл в offline"],severity:"critical",enabled:true,cooldown:"5м",confirmStabilitySec:5,recoveryMode:"conditional",recoveryStabilitySec:17,chatId:"",lastFiredAt:null} +export const alertsGroup={id:"group-fixture",name:"Fixture group",combineMode:"any",enabled:true,cooldownOverride:null} +export const alertsWithRule={...emptyAlerts,rules:[alertsRule],groups:[alertsGroup],meta:{...emptyAlerts.meta,servers:["router-fixture"],serversDetail:[{name:"router-fixture",host:"192.0.2.1",site:"",country:"",type:""}]}}