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)
|
||||
|
||||
Reference in new issue
Block a user