fix(alerts): сохранить черновик Telegram и подтвердить операции

This commit is contained in:
Denozordec committed 2026-10-03 18:00:01 +07:00
1 parent a5c8e614bd
commit 875819c577
7 files changed
+170 -216

No files matched your search

+11 -214
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {useAlertsTargets} from "@/hooks/use-alerts-targets"
import {AlertsTelegramForm} from "@/components/alerts-telegram-form"
import {AlertsTargetsStatus} from "@/components/alerts-targets-status"
import { useDataSource } from "@/lib/data-source"
import { PageHeader } from "@/components/page-header"
@@ -28,7 +29,7 @@ import {
BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon, LoaderCircleIcon,
CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon,
TimerIcon, WifiOffIcon, TrendingDownIcon,
EyeIcon, EyeOffIcon, CopyIcon, SendIcon, CheckIcon, XIcon,
SendIcon, CheckIcon,
Trash2Icon,
PencilIcon,
ChevronsUpDownIcon,
@@ -815,142 +816,6 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }
// ─── telegram config card ─────────────────────────────────────────────────────
function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTelegram, onTestTelegram }: {
cfg: TelegramConfig
onChange: (c: TelegramConfig) => void
/** В режиме live: токен в БД есть, в поле не подставляем */
tokenConfigured?: boolean
liveSaveBusy?: boolean
onSaveTelegram?: () => void | Promise<void>
onTestTelegram?: () => void | Promise<void>
}) {
const [showToken, setShowToken] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null)
const handleTest = async () => {
setTesting(true)
setTestResult(null)
try {
if (onTestTelegram) {
await onTestTelegram()
setTestResult("ok")
} else {
await new Promise((r) => setTimeout(r, 450))
setTestResult(cfg.connected ? "ok" : "fail")
}
} catch {
setTestResult("fail")
} finally {
setTesting(false)
setTimeout(() => setTestResult(null), 4000)
}
}
return (
<OpsPanel
title={<span className="flex items-center gap-2"><span className="text-base">✈️</span> Telegram</span>}
headerRight={
<span className={cn(
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
cfg.connected
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
: "bg-muted text-muted-foreground",
)}>
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
{cfg.connected ? "Подключён" : "Не настроен"}
</span>
}
contentClassName="px-4 pb-4 flex flex-col gap-3"
>
{/* bot token */}
<div>
<FieldLabel>Bot Token</FieldLabel>
<div className="flex gap-1.5">
<Input
type={showToken ? "text" : "password"}
value={cfg.token}
onChange={e => onChange({ ...cfg, token: e.target.value })}
autoComplete="new-password"
name="telegram-bot-token"
className="text-xs font-mono h-8"
placeholder={tokenConfigured && !cfg.token ? "Токен сохранён в БД — введите новый для замены" : "1234567890:AAF..."}
/>
<button
onClick={() => setShowToken(v => !v)}
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
{showToken ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
<button
onClick={() => navigator.clipboard.writeText(cfg.token)}
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
<CopyIcon className="size-3.5" />
</button>
</div>
</div>
{/* default chat id */}
<div>
<FieldLabel>Chat ID по умолчанию</FieldLabel>
<Input
value={cfg.chatId}
onChange={e => onChange({ ...cfg, chatId: e.target.value })}
className="text-xs font-mono h-8"
placeholder="-100xxxxxxxxxx"
/>
<p className="text-[10px] text-muted-foreground mt-1">
Для групп используйте отрицательный ID. Каждое правило может переопределить.
</p>
</div>
<div>
<FieldLabel>ID темы (message_thread_id)</FieldLabel>
<Input
value={cfg.messageThreadId}
onChange={e => onChange({ ...cfg, messageThreadId: e.target.value.replace(/\D/g, "") })}
className="text-xs font-mono h-8"
inputMode="numeric"
placeholder="Напр. 12345 — только для супергрупп с темами"
/>
<FieldHint>
Опционально: номер топика в форум-супергруппе. Пусто — сообщение в общий чат группы.
</FieldHint>
</div>
{onSaveTelegram && (
<Button
size="sm" className="w-full h-8 text-xs"
disabled={!!liveSaveBusy}
onClick={() => { void onSaveTelegram() }}
>
{liveSaveBusy ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
{liveSaveBusy ? "Сохранение…" : "Сохранить в БД"}
</Button>
)}
{/* test button */}
<Button
size="sm" variant="outline" className="w-full h-8 text-xs gap-2"
onClick={() => { void handleTest() }} disabled={testing}>
{testing ? (
<RefreshCwIcon className="size-3.5 animate-spin" />
) : testResult === "ok" ? (
<CheckIcon className="size-3.5 text-emerald-500" />
) : testResult === "fail" ? (
<XIcon className="size-3.5 text-destructive" />
) : (
<SendIcon className="size-3.5" />
)}
{testing ? "Отправка…"
: testResult === "ok" ? "Сообщение отправлено"
: testResult === "fail" ? "Ошибка отправки"
: "Отправить тестовое сообщение"}
</Button>
</OpsPanel>
)
}
// ─── history card ─────────────────────────────────────────────────────────────
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
@@ -2391,7 +2256,8 @@ function AlertsContent() {
const [loadBusy, setLoadBusy] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const [rulesSaveBusy, setRulesSaveBusy] = useState(false)
const [telegramSaveBusy, setTelegramSaveBusy] = useState(false)
const telegramLock=useRef(false)
const [telegramBusy,setTelegramBusy]=useState(false)
/** Пресеты и группы ANY/ALL — по умолчанию свёрнуты, чтобы не занимать место */
const [extrasExpanded, setExtrasExpanded] = useState(false)
const [hintsExpanded, setHintsExpanded] = useState(false)
@@ -2423,7 +2289,7 @@ function AlertsContent() {
}, [isLive, meta])
const load = useCallback(async () => {
if(!pageActive.current)return
if(!pageActive.current||telegramLock.current)return
if (!isLive) {
setRules(INIT_RULES)
setGroups(INIT_GROUPS)
@@ -2494,71 +2360,6 @@ function AlertsContent() {
}
}, [apiFetch, isLive, load])
const saveTelegram = useCallback(async () => {
if (!isLive) return
const threadRaw = tg.messageThreadId.trim()
let messageThreadId: number | null = null
if (threadRaw) {
const n = Number.parseInt(threadRaw, 10)
if (!Number.isFinite(n) || n < 1) {
setLoadError("ID темы: укажите положительное целое число или оставьте поле пустым")
return
}
messageThreadId = n
}
setTelegramSaveBusy(true)
setLoadError(null)
try {
const res = await apiFetch<{
telegram: {
chatId: string
tokenConfigured: boolean
connected: boolean
messageThreadId: number | null
}
}>(
"/api/alerts/telegram",
{
method: "PUT",
body: JSON.stringify({
chatId: tg.chatId,
messageThreadId,
...(tg.token.trim() ? { token: tg.token.trim() } : {}),
}),
},
)
setTokenConfigured(res.telegram.tokenConfigured)
setTg({
token: "",
chatId: res.telegram.chatId ?? "",
messageThreadId:
res.telegram.messageThreadId != null ? String(res.telegram.messageThreadId) : "",
connected: res.telegram.connected,
})
} catch (e) {
setLoadError(e instanceof Error ? e.message : "Не удалось сохранить Telegram")
} finally {
setTelegramSaveBusy(false)
}
}, [apiFetch, isLive, tg.chatId, tg.token, tg.messageThreadId])
const testTelegram = useCallback(async () => {
if (!isLive) return
const threadRaw = tg.messageThreadId.trim()
const messageThreadId =
threadRaw ? Number.parseInt(threadRaw, 10) : undefined
await apiFetch("/api/alerts/telegram/test", {
method: "POST",
body: JSON.stringify({
token: tg.token.trim() || undefined,
chatId: tg.chatId.trim() || undefined,
...(messageThreadId != null && Number.isFinite(messageThreadId) && messageThreadId >= 1
? { messageThreadId }
: {}),
}),
})
}, [apiFetch, isLive, tg.token, tg.chatId, tg.messageThreadId])
const testRuleTelegramFromForm = useCallback(
async (p: RuleTelegramTestPayload) => {
if (!isLive) return
@@ -2758,7 +2559,7 @@ function AlertsContent() {
crumbs={[{ label: "Система" }, { label: "Оповещения" }]}
actions={
<>
<Button variant="outline" size="sm" disabled={loadBusy || (isLive && backendStatus === false)}
<Button variant="outline" size="sm" disabled={loadBusy || telegramBusy || (isLive && backendStatus === false)}
onClick={() => { void load(); catalogs.refresh() }}>
{loadBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}
Обновить
@@ -2869,7 +2670,7 @@ function AlertsContent() {
: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
)}>
<span className={cn("size-1.5 rounded-full", tg.connected ? "bg-emerald-500" : "bg-red-500")} />
{tg.connected ? "Telegram: Подключён" : "Telegram: Не настроен"}
{tg.connected ? "Telegram: Параметры заполнены" : "Telegram: Не настроен"}
</span>
</div>
@@ -3091,14 +2892,10 @@ function AlertsContent() {
{/* ── right sidebar ── */}
<div className="flex flex-col gap-5">
<TelegramCard
cfg={tg}
onChange={setTg}
tokenConfigured={isLive ? tokenConfigured : false}
liveSaveBusy={telegramSaveBusy}
onSaveTelegram={isLive ? saveTelegram : undefined}
onTestTelegram={isLive ? testTelegram : undefined}
/>
<AlertsTelegramForm initial={tg} tokenConfigured={tokenConfigured} base={backendUrl} live={isLive} available={backendStatus===true}
onBegin={()=>{if(!pageActive.current||telegramLock.current)return false;telegramLock.current=true;reader.current?.abort();revision.current++;setLoadBusy(false);setTelegramBusy(true);return true}}
onEnd={()=>{telegramLock.current=false;setTelegramBusy(false)}}
onSaved={(value,configured)=>{setTg(value);setTokenConfigured(configured)}}/>
<HistoryCard entries={history} />
</div>
</div>
+81
View File
@@ -0,0 +1,81 @@
"use client"
import {useEffect,useRef,useState} from "react"
import {EyeIcon,EyeOffIcon} from "lucide-react"
import type {PutTelegramBody} from "@mmapp/contracts/alerts"
import {can} from "@/lib/auth"
import {ApiClientError} from "@/shared/api/http-client"
import {saveAlertsTelegram,testAlertsTelegram} from "@/shared/api/alerts"
import {OpsPanel} from "@/components/ops-panel"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
export type TelegramDraft={token:string;chatId:string;messageThreadId:string;connected:boolean}
const pending=new Set<string>()
const storageKey=(source:string)=>"mm:alerts-telegram-pending:"+source
function unresolved(source:string){try{return pending.has(source)||sessionStorage.getItem(storageKey(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(storageKey(source),"true");else sessionStorage.removeItem(storageKey(source))}catch{/* The memory guard remains active during this browser lifetime. */}}
function thread(value:string){if(!value.trim())return null;if(!/^\d+$/.test(value.trim()))throw Error("ID темы: укажите положительное целое число или оставьте поле пустым");const n=Number(value);if(!Number.isSafeInteger(n)||n<1||n>2147483647)throw Error("ID темы должен быть от 1 до 2147483647");return n}
export function AlertsTelegramForm({initial,tokenConfigured,base,live,available,onBegin,onEnd,onSaved}:{initial:TelegramDraft;tokenConfigured:boolean;base:string;live:boolean;available:boolean;onBegin:()=>boolean;onEnd:()=>void;onSaved:(value:TelegramDraft,configured:boolean)=>void}){
const source=JSON.stringify([live,base])
const [draft,setDraft]=useState<Partial<Pick<TelegramDraft,"token"|"chatId"|"messageThreadId">>>({})
const [showToken,setShowToken]=useState(false),[busy,setBusy]=useState<"save"|"test"|null>(null),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null),[unknown,setUnknown]=useState(false),[ready,setReady]=useState(false)
const active=useRef(true),lock=useRef(false),controller=useRef<AbortController|null>(null)
useEffect(()=>{active.current=true;queueMicrotask(()=>{if(active.current){setUnknown(unresolved(source));setReady(true)}});return()=>{active.current=false;controller.current?.abort()}},[source])
const value={...initial,...draft},allowed=can("mm:alerts:write"),enabled=ready&&live&&available&&allowed
const dirty=Object.keys(draft).some(key=>value[key as keyof typeof draft]!==initial[key as keyof typeof draft])
function current(){return active.current&&can("mm:alerts:write")}
function change(key:keyof typeof draft,value:string){setDraft(previous=>({...previous,[key]:value}));setError(null);setOutcome(null)}
async function execute(kind:"save"|"test"){
if(!enabled||lock.current||unknown||!current())return
const payload:PutTelegramBody={}
try{
const messageThreadId=thread(value.messageThreadId)
if(kind==="save"){
if(draft.chatId!==undefined&&value.chatId!==initial.chatId)payload.chatId=value.chatId
if(draft.messageThreadId!==undefined&&value.messageThreadId!==initial.messageThreadId)payload.messageThreadId=messageThreadId
if(value.token.trim())payload.token=value.token.trim()
if(!Object.keys(payload).length){setError("Нет изменённых параметров Telegram");return}
}else{
if(!value.chatId.trim())throw Error("Укажите Chat ID для тестового сообщения")
if(!value.token.trim()&&!tokenConfigured)throw Error("Укажите токен или сохраните его в БД")
if(draft.messageThreadId!==undefined&&!messageThreadId&&initial.messageThreadId)throw Error("Сначала сохраните очистку ID темы: тестовый API использует сохранённую тему, если ID не указан")
}
}catch(e){setError(e instanceof Error?e.message:"Проверьте параметры Telegram");return}
if(!onBegin())return
lock.current=true;controller.current=new AbortController();setBusy(kind);setError(null);setOutcome(null)
checkpoint(source,true);setUnknown(true)
try{
if(kind==="save"){
const result=await saveAlertsTelegram(base,payload,{signal:controller.current.signal});if(!current())return
const saved=result.telegram
onSaved({token:"",chatId:saved.chatId,messageThreadId:saved.messageThreadId===null?"":String(saved.messageThreadId),connected:saved.connected},saved.tokenConfigured)
setDraft({});setOutcome("Параметры Telegram сохранены в БД")
}else{
const messageThreadId=thread(value.messageThreadId)
await testAlertsTelegram(base,{chatId:value.chatId.trim(),...(value.token.trim()?{token:value.token.trim()}:{}),...(messageThreadId!==null?{messageThreadId}:{})},{signal:controller.current.signal});if(!current())return
setOutcome("Telegram API подтвердил приём тестового сообщения. Проверьте его в чате.")
}
checkpoint(source,false);setUnknown(false)
}catch(e){
const rejected=e instanceof ApiClientError&&[400,401,403,409,422].includes(e.status)
if(rejected&&current())checkpoint(source,false)
if(current()){setUnknown(!rejected);setError(e instanceof Error?e.message:"Результат Telegram не получен")}
}finally{lock.current=false;controller.current=null;if(active.current){setBusy(null);onEnd()}}
}
return <section aria-label="Параметры Telegram" className="min-w-0"><OpsPanel title="Telegram" description={initial.connected?"Параметры заполнены. Доставка проверяется отдельно.":"Параметры не заполнены."} contentClassName="flex min-w-0 flex-col gap-3 px-5 py-4">
<div className="space-y-1"><label htmlFor="alerts-bot-token" className="text-xs font-medium">Bot Token</label><div className="flex gap-2"><Input id="alerts-bot-token" type={showToken?"text":"password"} value={value.token} disabled={!enabled||!!busy} onChange={e=>change("token",e.target.value)} autoComplete="new-password" placeholder={tokenConfigured?"Токен сохранён; введите новый для замены":"Токен Telegram бота"} className="min-w-0 font-mono text-xs"/><Button type="button" variant="outline" size="icon" aria-label={showToken?"Скрыть токен":"Показать токен"} onClick={()=>setShowToken(previous=>!previous)}>{showToken?<EyeOffIcon aria-hidden/>:<EyeIcon aria-hidden/>}</Button></div></div>
<div className="space-y-1"><label htmlFor="alerts-chat-id" className="text-xs font-medium">Chat ID по умолчанию</label><Input id="alerts-chat-id" value={value.chatId} disabled={!enabled||!!busy} onChange={e=>change("chatId",e.target.value)} className="min-w-0 font-mono text-xs"/><p className="text-xs text-muted-foreground">Для групп используйте отрицательный ID. Правило может задать свой чат.</p></div>
<div className="space-y-1"><label htmlFor="alerts-thread-id" className="text-xs font-medium">ID темы (message_thread_id)</label><Input id="alerts-thread-id" value={value.messageThreadId} disabled={!enabled||!!busy} inputMode="numeric" onChange={e=>change("messageThreadId",e.target.value)} className="min-w-0 font-mono text-xs"/><p className="text-xs text-muted-foreground">Пустое поле при сохранении очищает тему. Только для форум-супергрупп.</p></div>
{dirty?<p role="status" className="text-xs text-muted-foreground">Есть несохранённые изменения. Обновление страницы данных сохранит черновик.</p>:null}
{!live?<p className="text-xs text-muted-foreground">Демо: сохранение и отправка недоступны.</p>:!allowed?<p role="status" className="text-xs text-muted-foreground">Нет права изменять оповещения.</p>:null}
{error?<p role="alert" className="text-xs text-destructive break-words">{error}</p>:null}
{outcome?<p role="status" className="text-xs break-words">{outcome}</p>:null}
{busy?<p role="status" className="text-xs text-muted-foreground">Ожидаем подтверждение сервера…</p>:null}
{unknown&&!busy?<div className="space-y-2"><p role="status" className="text-xs break-words">Результат запроса неизвестен. Он мог выполниться. Перед повтором проверьте параметры в БД или сообщение в Telegram; обновление данных не подтверждает отправку.</p><Button type="button" size="sm" variant="outline" className="h-auto whitespace-normal" disabled={!!busy||!enabled} onClick={()=>{checkpoint(source,false);setUnknown(false)}}>Я проверил результат — разрешить новый запрос</Button></div>:null}
<Button type="button" size="sm" disabled={!enabled||!!busy||unknown||!dirty} onClick={()=>void execute("save")}>{busy==="save"?"Сохранение…":"Сохранить в БД"}</Button>
<Button type="button" size="sm" variant="outline" disabled={!enabled||!!busy||unknown} onClick={()=>void execute("test")}>{busy==="test"?"Отправка…":"Отправить тестовое сообщение"}</Button>
<Button type="button" size="sm" variant="ghost" disabled={!dirty||!!busy} onClick={()=>{setDraft({});setError(null);setOutcome(null)}}>Сбросить черновик</Button>
</OpsPanel></section>
}
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-03
Этап 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.
Этап 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.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+10 -1
View File
@@ -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.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.3.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,15 @@
## Блок 11Б.4.3.2 — подтверждённые параметры и тест Telegram (2026-10-03)
- Новая форма на установленном OpsPanel/Frame хранит dirty поля отдельно от полученного snapshot. Refresh сохраняет черновик, untouched поля получают новые данные; PUT передаёт только изменённые поля. Токен не сохраняется в browser storage. Пустой thread отправляется явно null; некорректное число остаётся в форме и не отправляет API.
- Строгие save/test helpers подключены к UI; общий synchronous lock и source AbortController защищают от повторного save/test. Начало записи отменяет pending primary GET и его revision: поздний overview не заменяет подтверждённый save. Controls требуют alerts:write, live и подтверждённую доступность; demo ничего не отправляет.
- Неизвестный результат сохраняет checkpoint без секретов на источник в памяти/sessionStorage, переживает refresh/reload/ABA, требует явной проверки перед повтором. Подтверждённый 400/401/403/409/422 снимает checkpoint, сохраняет draft; network/5xx/malformed остаются unknown. Поздний receipt старого lifetime не снимает новый guard. При недоступности sessionStorage остаётся только memory guard до reload.
- «Подключён» заменён на «Параметры заполнены»; test success обозначает приём Telegram API и просит проверить чат, не доказывает доставку. Очистка draft thread перед тестом требует сначала save: legacy test API при omitted thread использует сохранённое значение.
- 10 Telegram browser сценариев, 4 Telegram source ABA, 7 primary/catalog regression и 2 reader ABA пройдены; typecheck без incremental/lint/ReUI audit. Форма проверена и просмотрена на 320px. Реальные Telegram/PG не вызывались. Rule preview test пока старый и не принят; правила и GET/outbox consistency впереди.
- Следующий точный шаг 11Б.4.3.3: правила и группы — strict receipt, права, общий замок, сохранение формы при ошибках и unknown review; затем GET/revision/outbox audit, общий dashboard permission reader, Releases/probe scheduling и приёмка 11Б/12.
## Блок 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 не отображается как пустой каталог.
+5
View File
@@ -253,3 +253,8 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
### 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.
### 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.
+12
View File
@@ -11,3 +11,15 @@ for(const failed of [false,true])test("Alerts source: ABA discards old primary a
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()}
})
const configuredTelegram={chatId:"-100fixture",tokenConfigured:true,connected:true,messageThreadId:9}
for(const kind of ["save","test"] as const)for(const failed of [false,true])test("Telegram source: ABA retains unknown checkpoint and ignores old receipt/"+kind+"/"+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"?{...emptyAlerts,telegram:configuredTelegram}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
let started=false,completed=false,calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
await page.route("http://localhost:8000/api/alerts/telegram"+(kind==="test"?"/test":""),async r=>{calls++;started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD TELEGRAM FAILED"}}:{headers,json:kind==="test"?{ok:true}:{ok:true,telegram:{...configuredTelegram,chatId:"-100draft"}}})}catch{}finally{completed=true}})
const form=()=>page.getByRole("region",{name:"Параметры Telegram",exact:true})
try{await page.goto("/alerts");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue(configuredTelegram.chatId);if(kind==="save")await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");await form().getByRole("button",{name:kind==="save"?"Сохранить в БД":"Отправить тестовое сообщение",exact:true}).click();await expect.poll(()=>started).toBe(true)
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()}
})
+50
View File
@@ -0,0 +1,50 @@
import {test,expect,type Page,type Route} from "@playwright/test"
import {emptyAlerts} from "./fixtures/alerts"
const telegram={chatId:"-100fixture",tokenConfigured:true,connected:true,messageThreadId:9}
async function setup(page:Page,handler:(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"||path.startsWith("/api/alerts/telegram"))return handler(r,path);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
await page.goto("/alerts");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue(telegram.chatId)
}
const form=(page:Page)=>page.getByRole("region",{name:"Параметры Telegram",exact:true})
test("Telegram: dirty fields survive refresh and selective save leaves refreshed fields untouched",async({page})=>{
let current={...telegram},payload:unknown,calls=0
await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram:current}});calls++;payload=r.request().postDataJSON();current={...current,...payload as object};return r.fulfill({json:{ok:true,telegram:current}})})
await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");current={...current,chatId:"-100external",messageThreadId:17};await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByRole("textbox",{name:"ID темы (message_thread_id)",exact:true})).toHaveValue("17");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue("-100draft")
await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByText("Параметры Telegram сохранены в БД",{exact:true})).toBeVisible();expect(payload).toEqual({chatId:"-100draft"});expect(calls).toBe(1);await expect(form(page).getByRole("button",{name:"Сохранить в БД",exact:true})).toBeDisabled();await expect(form(page)).not.toContainText("Подключён")
})
test("Telegram: invalid thread is preserved and no PUT or POST is sent",async({page})=>{
let calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({json:{ok:true}})})
const input=page.getByRole("textbox",{name:"ID темы (message_thread_id)",exact:true});for(const value of ["12wrong","1.5","0","2147483648"]){await input.fill(value);await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("ID темы");await expect(input).toHaveValue(value);await form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("ID темы")};expect(calls).toBe(0)
})
test("Telegram: malformed save remains unknown across refresh and reload without secret storage",async({page})=>{
let calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({json:{ok:false}})})
await page.locator("#alerts-bot-token").fill("fixture-secret");await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("не подтверждено");await expect(page.locator("#alerts-bot-token")).toHaveValue("fixture-secret");await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.locator("#alerts-bot-token")).toHaveValue("fixture-secret");expect(await page.evaluate(()=>JSON.stringify(sessionStorage))).not.toContain("fixture-secret");await page.reload();await expect(form(page)).toContainText("Результат запроса неизвестен");await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();expect(calls).toBe(1)
})
test("Telegram: confirmed rejection preserves draft and allows corrected save",async({page})=>{
let calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({status:403,json:{error:"Fixture denied"}})})
await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByRole("alert")).toHaveText("Fixture denied");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue("-100draft");await expect(form(page).getByRole("button",{name:"Сохранить в БД",exact:true})).toBeEnabled();await expect(form(page)).not.toContainText("Результат запроса неизвестен");expect(calls).toBe(1)
})
test("Telegram: one pending write excludes save/test and refresh until strict receipt",async({page})=>{
let calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
try{await setup(page,async(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;await gate;return r.fulfill({json:{ok:true,telegram:{...telegram,chatId:"-100draft"}}})});await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect.poll(()=>calls).toBe(1);await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeDisabled();await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toBeDisabled();release();await expect(form(page).getByText("Параметры Telegram сохранены в БД",{exact:true})).toBeVisible();expect(calls).toBe(1)}finally{release()}
})
test("Telegram: strict test receipt acknowledges API acceptance without claiming delivery",async({page})=>{
let calls=0,payload:unknown;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;payload=r.request().postDataJSON();return r.fulfill({json:{ok:true}})})
await form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true}).click();await expect(form(page)).toContainText("Telegram API подтвердил приём тестового сообщения. Проверьте его в чате.");expect(payload).toEqual({chatId:telegram.chatId,messageThreadId:9});expect(calls).toBe(1);await expect(form(page)).not.toContainText("Сообщение отправлено")
})
test("Telegram: alerts read does not grant config save or test",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(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({json:{ok:true}})},true);await expect(form(page)).toContainText("Нет права изменять оповещения");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toBeDisabled();await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();expect(calls).toBe(0)
})
test("Telegram: compact form remains within 320px viewport",async({page})=>{await page.setViewportSize({width:320,height:900});await setup(page,r=>r.fulfill({json:{...emptyAlerts,telegram}}));await form(page).scrollIntoViewIfNeeded();const box=await form(page).boundingBox();expect(box).not.toBeNull();expect(box!.x).toBeGreaterThanOrEqual(0);expect(box!.x+box!.width).toBeLessThanOrEqual(320);await page.screenshot({path:"test-results/alerts-telegram-320.png",fullPage:true})})
test("Telegram: clearing thread is explicit and test cannot silently use the saved topic",async({page})=>{
let payload:unknown,calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;payload=r.request().postDataJSON();return r.fulfill({json:{ok:true,telegram:{...telegram,messageThreadId:null}}})})
await page.getByRole("textbox",{name:"ID темы (message_thread_id)",exact:true}).fill("");await form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("Сначала сохраните очистку ID темы");expect(calls).toBe(0);await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page)).toContainText("Параметры Telegram сохранены в БД");expect(payload).toEqual({messageThreadId:null});expect(calls).toBe(1)
})
test("Telegram: late overview GET cannot replace a confirmed save",async({page})=>{
let delayed=false,started=false,completed=false,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
try{await setup(page,async(r,path)=>{if(path==="/api/alerts"){if(delayed){started=true;await gate;try{await r.fulfill({json:{...emptyAlerts,telegram:{...telegram,chatId:"OLD CHAT"}}})}catch{}finally{completed=true};return}return r.fulfill({json:{...emptyAlerts,telegram}})}return r.fulfill({json:{ok:true,telegram:{...telegram,chatId:"-100draft"}}})});await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");delayed=true;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect.poll(()=>started).toBe(true);await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page)).toContainText("Параметры Telegram сохранены в БД");release();await expect.poll(()=>completed).toBe(true);await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue("-100draft")}finally{release()}
})