fix(alerts): подтверждать запись правил и сохранять формы при сбое

This commit is contained in:
Denozordec committed 2026-10-03 18:13:57 +07:00
1 parent 875819c577
commit 9c1d14635a
9 files changed
+198 -154

No files matched your search

+87 -151
View File
@@ -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)