fix(alerts): подтверждать запись правил и сохранять формы при сбое
This commit is contained in:
1 parent
875819c577
commit
9c1d14635a
9 files changed
+198
-154
No files matched your search
+87
-151
@@ -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 */}
|
||||
<FormToggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
|
||||
<FormToggle ariaLabel={"Включить правило "+rule.name} checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
|
||||
|
||||
{/* severity dot */}
|
||||
<SeverityDot severity={rule.severity} />
|
||||
@@ -790,14 +793,14 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }
|
||||
</div>
|
||||
|
||||
{/* actions (visible on hover) */}
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<div className="flex gap-1 opacity-100 lg:opacity-0 lg:group-hover:opacity-100 focus-within:opacity-100 transition-opacity shrink-0">
|
||||
{onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={interactionsDisabled}
|
||||
onClick={() => onEdit(rule.id)}
|
||||
className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
|
||||
title="Изменить"
|
||||
title="Изменить" aria-label={"Изменить правило "+rule.name}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</button>
|
||||
@@ -805,6 +808,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }
|
||||
<button
|
||||
type="button"
|
||||
disabled={interactionsDisabled}
|
||||
aria-label={"Удалить правило "+rule.name}
|
||||
onClick={() => onDelete(rule.id)}
|
||||
className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40">
|
||||
<Trash2Icon className="size-3.5" />
|
||||
@@ -1535,12 +1539,16 @@ function AddRuleSheet({
|
||||
initialForm,
|
||||
editRuleId,
|
||||
onTestRuleTelegram,
|
||||
testTelegramDisabled,
|
||||
testTelegramDisabled, saving,saveDisabled,saveError,review,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** editRuleId задан — обновление существующего правила */
|
||||
onSave: (rule: Omit<AlertRule, "id" | "lastFired" | "enabled">, editRuleId: string | null) => void
|
||||
onSave: (rule: Omit<AlertRule, "id" | "lastFired" | "enabled">, editRuleId: string | null) => Promise<boolean>
|
||||
saving:boolean
|
||||
saveDisabled:boolean
|
||||
saveError:string|null
|
||||
review:React.ReactNode
|
||||
typeTargets: Record<AlertType, string[]>
|
||||
isLive: boolean
|
||||
serversCatalog: AlertsServerRow[]
|
||||
@@ -1682,12 +1690,12 @@ function AddRuleSheet({
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!canSave) return
|
||||
const handleSave = async () => {
|
||||
if (!canSave||saveDisabled||saving) return
|
||||
const uq = uniqStrings(form.targets)
|
||||
const targetSummary = uq.join(", ").slice(0, 240) || "—"
|
||||
const condResolved = uniqStrings(expandedConditionLines.map((c) => c.trim()).filter(Boolean))
|
||||
onSave({
|
||||
const saved=await onSave({
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
target: targetSummary,
|
||||
@@ -1698,9 +1706,10 @@ function AddRuleSheet({
|
||||
severity: form.severity,
|
||||
cooldown: form.cooldown,
|
||||
confirmStabilitySec: form.confirmStabilitySec,
|
||||
recoveryMode:form.recoveryMode??"always", recoveryStabilitySec:form.recoveryStabilitySec??null,
|
||||
chatId: form.chatId.trim(),
|
||||
}, editRuleId)
|
||||
onClose()
|
||||
if(saved)onClose()
|
||||
}
|
||||
|
||||
const applySuggestedName = useCallback(() => {
|
||||
@@ -1731,7 +1740,7 @@ function AddRuleSheet({
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(o) => { if (!o) onClose() }}>
|
||||
{/* p-0 + gap-0: we control all spacing internally */}
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-lg">
|
||||
<SheetContent className="min-h-0 flex flex-col overflow-hidden p-0 gap-0 sm:max-w-lg">
|
||||
|
||||
{/* ── fixed header ── */}
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
@@ -1742,7 +1751,7 @@ function AddRuleSheet({
|
||||
</SheetHeader>
|
||||
|
||||
{/* ── scrollable body ── */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<fieldset disabled={saving} className="min-h-0 min-w-0 flex-1 overflow-y-auto px-4 sm:px-6 py-5 flex flex-col gap-5">
|
||||
|
||||
{/* name */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -1762,7 +1771,7 @@ function AddRuleSheet({
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Подставится при выборе объекта или условия и будет обновляться при каждом шаге"
|
||||
aria-label="Название правила" placeholder="Подставится при выборе объекта или условия и будет обновляться при каждом шаге"
|
||||
value={form.name}
|
||||
onFocus={revealObjectPicker}
|
||||
onChange={e => {
|
||||
@@ -2170,11 +2179,11 @@ function AddRuleSheet({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* ── fixed footer ── */}
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-stretch">
|
||||
<Button variant="outline" className="w-full sm:flex-1 min-h-9" onClick={onClose}>
|
||||
<Button type="button" variant="outline" disabled={saving} className="w-full sm:flex-1 min-h-9" onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
{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
|
||||
</Button>
|
||||
) : null}
|
||||
<Button className="w-full sm:flex-1 min-h-9" onClick={handleSave} disabled={!canSave}>
|
||||
{review}
|
||||
{saveError?<p role="alert" className="w-full text-xs text-destructive break-words">{saveError}</p>:null}
|
||||
<Button type="button" className="w-full sm:flex-1 min-h-9" onClick={()=>void handleSave()} disabled={!canSave||saveDisabled||saving}>
|
||||
{editRuleId ? "Сохранить изменения" : "Сохранить правило"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
@@ -2255,9 +2266,14 @@ function AlertsContent() {
|
||||
|
||||
const [loadBusy, setLoadBusy] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(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<AlertGroup>) => {
|
||||
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<AlertRule, "id" | "lastFired" | "enabled">, editId: string | null) => {
|
||||
async (payload: Omit<AlertRule, "id" | "lastFired" | "enabled">, 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={
|
||||
<>
|
||||
<Button variant="outline" size="sm" disabled={loadBusy || telegramBusy || (isLive && backendStatus === false)}
|
||||
<Button variant="outline" size="sm" disabled={loadBusy || operationBusy || (isLive && backendStatus === false)}
|
||||
onClick={() => { void load(); catalogs.refresh() }}>
|
||||
{loadBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" onClick={openRuleSheetAdd} disabled={!known || (isLive && backendStatus !== true)}>
|
||||
<Button size="sm" onClick={openRuleSheetAdd} disabled={writesDisabled}>
|
||||
<PlusIcon className="size-4" />Добавить правило
|
||||
</Button>
|
||||
</>
|
||||
@@ -2593,6 +2582,11 @@ function AlertsContent() {
|
||||
)}
|
||||
|
||||
{isLive?<p role="status" className="text-xs text-muted-foreground">{known?"Последние данные получены: "+new Date(receivedAt!).toLocaleString("ru-RU"):loadBusy?"Загрузка оповещений…":"Данные оповещений ещё не получены."}</p>:null}
|
||||
{!can("mm:alerts:write")?<p role="status" className="text-xs text-muted-foreground">Нет права изменять правила и группы.</p>:null}
|
||||
{config.error?<p role="alert" className="text-xs text-destructive">{config.error}</p>:null}
|
||||
{config.outcome?<p role="status" className="text-xs">{config.outcome}</p>:null}
|
||||
{config.busy?<p role="status" className="text-xs">Ожидаем подтверждение правил и групп…</p>:null}
|
||||
{config.unknown&&!config.busy?<div className="space-y-2"><p role="status" className="text-xs">Результат сохранения правил неизвестен. Запрос мог выполниться; проверьте правила и группы в БД перед повтором. Обновление списка не разрешает повтор автоматически.</p><Button type="button" size="sm" variant="outline" className="h-auto whitespace-normal" disabled={!config.enabled||operationBusy} onClick={config.review}>Я проверил правила — разрешить новый запрос</Button></div>:null}
|
||||
{isLive?<AlertsTargetsStatus catalogs={catalogs}/>:null}
|
||||
{known?<>
|
||||
<Collapsible open={hintsExpanded} onOpenChange={setHintsExpanded} className="rounded-lg border border-dashed border-border bg-muted/15">
|
||||
@@ -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}
|
||||
>
|
||||
<PlusIcon className="size-3.5" />Группа
|
||||
@@ -2760,70 +2754,7 @@ function AlertsContent() {
|
||||
Групп пока нет. Создайте группу и назначьте правилам её в форме правила — движок отправит одно сообщение по логике ANY или ALL.
|
||||
</p>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
className="rounded-lg border border-border/80 p-3 flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={g.name}
|
||||
onChange={(e) => patchGroup(g.id, { name: e.target.value })}
|
||||
className="h-8 text-xs flex-1 min-w-[140px]"
|
||||
aria-label="Имя группы"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={g.enabled ? "secondary" : "outline"}
|
||||
className="h-7 text-xs"
|
||||
onClick={() => patchGroup(g.id, { enabled: !g.enabled })}
|
||||
>
|
||||
{g.enabled ? "Вкл." : "Выкл."}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => removeGroup(g.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">Режим</span>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[100px]"
|
||||
value={g.combineMode}
|
||||
onChange={(e) =>
|
||||
patchGroup(g.id, { combineMode: e.target.value === "all" ? "all" : "any" })
|
||||
}
|
||||
>
|
||||
<option value="any">ANY (или)</option>
|
||||
<option value="all">ALL (и)</option>
|
||||
</select>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">Cooldown группы</span>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[100px]"
|
||||
value={g.cooldownOverride ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
patchGroup(g.id, {
|
||||
cooldownOverride: v === "" ? null : (v as AlertCooldown),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="">По умолчанию</option>
|
||||
{COOLDOWNS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
groups.map(g=><AlertsGroupForm key={g.id} group={g} disabled={writesDisabled} onSave={patch=>patchGroup(g.id,patch)} onDelete={()=>setPendingDelete({kind:"group",id:g.id})}/>)
|
||||
)}
|
||||
</OpsPanel>
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -2883,6 +2814,7 @@ function AlertsContent() {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={writesDisabled}
|
||||
onClick={openRuleSheetAdd}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||||
<PlusIcon className="size-3" />Добавить правило
|
||||
@@ -2892,9 +2824,8 @@ function AlertsContent() {
|
||||
|
||||
{/* ── right sidebar ── */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<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)}}
|
||||
<AlertsTelegramForm initial={tg} tokenConfigured={tokenConfigured} base={backendUrl} live={isLive} available={backendStatus===true&&!operationBusy}
|
||||
onBegin={beginOperation} onEnd={endOperation}
|
||||
onSaved={(value,configured)=>{setTg(value);setTokenConfigured(configured)}}/>
|
||||
<HistoryCard entries={history} />
|
||||
</div>
|
||||
@@ -2908,6 +2839,8 @@ function AlertsContent() {
|
||||
open={sheetOpen}
|
||||
onClose={closeRuleSheet}
|
||||
onSave={handleRuleSheetSave}
|
||||
saving={rulesSaveBusy} saveDisabled={writesDisabled} saveError={config.error??loadError}
|
||||
review={config.unknown&&!config.busy?<div className="w-full space-y-2"><p role="status" className="text-xs">Результат записи неизвестен. Проверьте правила в БД перед повтором; черновик сохранён в этой форме.</p><Button type="button" size="sm" variant="outline" className="h-auto whitespace-normal" disabled={!config.enabled||operationBusy} onClick={config.review}>Я проверил правила — разрешить новый запрос</Button></div>: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}
|
||||
/>
|
||||
|
||||
<AlertDialog open={pendingDelete!==null} onOpenChange={open=>{if(!open&&!operationLock.current)setPendingDelete(null)}}>
|
||||
<AlertDialogContent size="sm"><AlertDialogHeader><AlertDialogTitle>{pendingDelete?.kind==="group"?"Удалить группу?":"Удалить правило?"}</AlertDialogTitle><AlertDialogDescription>{pendingDelete?.kind==="group"?"Правила сохранятся, но больше не будут входить в эту группу.":"Правило перестанет участвовать в оповещениях после подтверждения сервера."}</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel disabled={operationBusy}>Отмена</AlertDialogCancel><AlertDialogAction disabled={writesDisabled} variant="destructive" onClick={()=>{if(!pendingDelete)return;if(pendingDelete.kind==="rule")handleDelete(pendingDelete.id);else removeGroup(pendingDelete.id);setPendingDelete(null)}}>Удалить</AlertDialogAction></AlertDialogFooter></AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialog
|
||||
open={pendingPresetReplaceId != null}
|
||||
onOpenChange={(open) => {
|
||||
@@ -2935,7 +2871,7 @@ function AlertsContent() {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
variant="destructive" disabled={writesDisabled}
|
||||
onClick={() => {
|
||||
if (pendingPresetReplaceId) applyPresetReplace(pendingPresetReplaceId)
|
||||
setPendingPresetReplaceId(null)
|
||||
|
||||
@@ -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<AlertGroup>)=>Promise<boolean>;onDelete:()=>void}){
|
||||
const [draft,setDraft]=useState<Partial<AlertGroup>>({})
|
||||
const value={...group,...draft},dirty=Object.keys(draft).some(key=>value[key as keyof AlertGroup]!==group[key as keyof AlertGroup])
|
||||
return <section aria-label={"Группа "+group.name} className="rounded-lg border border-border p-3 space-y-3">
|
||||
<fieldset disabled={disabled} className="space-y-3 min-w-0">
|
||||
<Input aria-label="Имя группы" value={value.name} onChange={e=>setDraft(previous=>({...previous,name:e.target.value}))} className="text-xs"/>
|
||||
<label className="flex flex-wrap items-center gap-2 text-xs">Режим<select aria-label="Режим группы" value={value.combineMode} onChange={e=>setDraft(previous=>({...previous,combineMode:e.target.value==="all"?"all":"any"}))} className="h-8 border border-input rounded-md bg-background px-2"><option value="any">ANY (или)</option><option value="all">ALL (и)</option></select></label>
|
||||
<label className="flex flex-wrap items-center gap-2 text-xs">Cooldown<select aria-label="Cooldown группы" value={value.cooldownOverride??""} onChange={e=>setDraft(previous=>({...previous,cooldownOverride:e.target.value===""?null:e.target.value as AlertGroup["cooldownOverride"]}))} className="h-8 border border-input rounded-md bg-background px-2"><option value="">По умолчанию</option>{["1м","5м","15м","1ч","4ч","24ч"].map(c=><option key={c} value={c}>{c}</option>)}</select></label>
|
||||
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={value.enabled} onChange={e=>setDraft(previous=>({...previous,enabled:e.target.checked}))}/>Группа включена</label>
|
||||
<div className="flex flex-wrap gap-2"><Button type="button" size="sm" disabled={!dirty||!value.name.trim()} onClick={async()=>{if(await onSave(draft))setDraft({})}}>Сохранить группу</Button><Button type="button" size="sm" variant="ghost" disabled={!dirty} onClick={()=>setDraft({})}>Сбросить</Button><Button type="button" size="sm" variant="outline" onClick={onDelete}>Удалить группу</Button></div>
|
||||
</fieldset>
|
||||
{dirty?<p role="status" className="text-xs text-muted-foreground">Изменения группы ещё не сохранены.</p>:null}
|
||||
</section>
|
||||
}
|
||||
+1
-1
@@ -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).
|
||||
|
||||
|
||||
+10
-1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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<string>()
|
||||
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<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){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<typeof normalizeAlertsConfig>
|
||||
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}
|
||||
}
|
||||
@@ -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<void>(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()}
|
||||
})
|
||||
@@ -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<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: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<void>(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()})
|
||||
@@ -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:""}]}}
|
||||
Reference in new issue
Block a user