fix(ui): подтверждать удаление объектов Firewall

This commit is contained in:
Denozordec committed 2026-09-30 10:07:07 +07:00
1 parent 0c460d4767
commit 14a9e6d26c
6 files changed
+154 -38

No files matched your search

+66 -34
View File
@@ -40,6 +40,10 @@ import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from "@/components/ui/alert-dialog"
import { cn } from "@/lib/utils"
import {
PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon,
@@ -821,7 +825,7 @@ function AddressListsTab({
<FormToggle checked={!e.disabled} disabled={mutationsDisabled} onChange={() => onToggle(e)} />
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7 shrink-0">
<Button variant="ghost" size="icon" className="size-7 shrink-0" aria-label={"Действия: " + e.address}>
<MoreHorizontalIcon className="size-4" />
</Button>
} />
@@ -831,7 +835,7 @@ function AddressListsTab({
<PowerIcon className="size-4" />{e.disabled ? "Включить" : "Отключить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDelete(e)}>
<DropdownMenuItem disabled={mutationsDisabled} variant="destructive" onClick={() => onDelete(e)}>
<Trash2Icon className="size-4" />Удалить
</DropdownMenuItem>
</DropdownMenuContent>
@@ -1941,6 +1945,9 @@ function FirewallPageInner() {
const [togglePending, setTogglePending] = useState(false)
const [toggleStatus, setToggleStatus] = useState<string | null>(null)
const [toggleError, setToggleError] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<{ kind: "rule"; item: FirewallRule } | { kind: "address"; item: AddressListEntry } | null>(null)
const [deletePending, setDeletePending] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [selectedServerId, setSelectedServerId] = useState(serverFromQuery ?? ALL_SERVERS_ID)
const appliedQueryRef = useRef(false)
@@ -2169,20 +2176,40 @@ function FirewallPageInner() {
setMockRules((rs) => rs.map((x) => x.id === id ? { ...x, enabled: !x.enabled } : x))
}
async function deleteRule(r: FirewallRule) {
if (isLive) {
const key = ruleKey(r)
if (!key) return
try {
await apiFetch("/api/firewall/rules", { method: "DELETE", body: JSON.stringify(key) })
toast.success("Правило удалено")
function requestDelete(target: NonNullable<typeof pendingDelete>) {
if (toggleLock.current || busy || historyRestoring) return
setDeleteError(null)
setPendingDelete(target)
}
async function confirmDelete() {
if (!pendingDelete || toggleLock.current || busy || historyRestoring) return
const target = pendingDelete
toggleLock.current = true
setDeletePending(true)
setDeleteError(null)
try {
if (isLive) {
const key = target.kind === "rule" ? ruleKey(target.item) : addressKey(target.item)
if (!key) throw new Error("Не удалось определить устройство и ID объекта")
const result = await apiFetch<{ ok: boolean }>(target.kind === "rule" ? "/api/firewall/rules" : "/api/firewall/address-lists", {
method: "DELETE", body: JSON.stringify(key),
})
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось удалить правило")
} else if (target.kind === "rule") {
setMockRules(rows => rows.filter(x => x.id !== target.item.id))
} else {
setMockAddrLists(rows => rows.filter(x => x.id !== target.item.id))
}
return
setPendingDelete(null)
toast.success(target.kind === "rule" ? "Правило удалено" : "Запись удалена")
} catch (e) {
setDeleteError(e instanceof Error ? e.message : "Не удалось удалить объект")
} finally {
toggleLock.current = false
setDeletePending(false)
}
setMockRules((rs) => rs.filter((x) => x.id !== r.id))
}
const canReorder = useMemo(() => {
@@ -2342,22 +2369,6 @@ function FirewallPageInner() {
setMockAddrLists((rows) => rows.map((x) => x.id === e.id ? { ...x, disabled: !x.disabled } : x))
}
async function deleteAddr(e: AddressListEntry) {
if (isLive) {
const key = addressKey(e)
if (!key) return
try {
await apiFetch("/api/firewall/address-lists", { method: "DELETE", body: JSON.stringify(key) })
toast.success("Запись удалена")
await loadLive()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось удалить запись")
}
return
}
setMockAddrLists((rows) => rows.filter((x) => x.id !== e.id))
}
async function saveAddr(payload: {
list: string
address: string
@@ -2556,12 +2567,12 @@ function FirewallPageInner() {
{chainGroup === "address-lists" ? (
<AddressListsTab
entries={familyAddrLists}
mutationsDisabled={togglePending || busy || historyRestoring}
mutationsDisabled={togglePending || deletePending || busy || historyRestoring}
onAdd={() => openAddAddr()}
onAddToList={(list) => openAddAddr(list)}
onToggle={(e) => { void toggleAddr(e) }}
onEdit={openEditAddr}
onDelete={(e) => { void deleteAddr(e) }}
onDelete={(item) => requestDelete({ kind: "address", item })}
showServer={showServerCol}
/>
) : chainGroup === "simulator" ? (
@@ -2598,11 +2609,11 @@ function FirewallPageInner() {
<FirewallRulesDataGrid
rules={filteredRules}
mutationsDisabled={togglePending || busy || historyRestoring}
mutationsDisabled={togglePending || deletePending || busy || historyRestoring}
onToggle={(id) => { void toggleRule(id) }}
onEdit={openEdit}
onDelete={(r) => { void deleteRule(r) }}
onReorder={canReorder && !togglePending ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
onDelete={(item) => requestDelete({ kind: "rule", item })}
onReorder={canReorder && !togglePending && !deletePending ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
showServer={showServerCol}
/>
</DataPageCard>
@@ -2679,6 +2690,27 @@ function FirewallPageInner() {
rules={familyRules}
/>
<AlertDialog open={!!pendingDelete} onOpenChange={open => {
if (!open && !toggleLock.current) { setPendingDelete(null); setDeleteError(null) }
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{pendingDelete?.kind === "rule" ? "Удалить правило Firewall?" : "Удалить запись address-list?"}</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete && (pendingDelete.kind === "rule"
? [pendingDelete.item.comment || pendingDelete.item.rosId || pendingDelete.item.id, pendingDelete.item.serverName || pendingDelete.item.serverId, ruleFamily(pendingDelete.item), pendingDelete.item.table, pendingDelete.item.chain].join(" · ")
: [pendingDelete.item.address, pendingDelete.item.list, pendingDelete.item.serverName || pendingDelete.item.serverId, addressFamily(pendingDelete.item)].join(" · "))}
</AlertDialogDescription>
{deletePending && <p role="status" className="text-sm text-muted-foreground">Ожидание результата удаления…</p>}
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePending}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={deletePending || togglePending || busy || historyRestoring} onClick={e => { e.preventDefault(); void confirmDelete() }}>Удалить</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}