fix(ui): сохранять недоступные срезы Firewall

This commit is contained in:
Denozordec committed 2026-09-30 10:46:36 +07:00
1 parent 59440555cd
commit 5e6057842f
11 files changed
+228 -77

No files matched your search

+41 -29
View File
@@ -21,7 +21,7 @@ import {
type Server,
} from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { firewallResponseSchema, firewallServersSchema, type FirewallBackendServer } from "@/lib/firewall-response"
import { firewallResponseSchema, firewallServersSchema, mergeFirewallRows, type FirewallFailure, type FirewallBackendServer } from "@/lib/firewall-response"
import { requestJson } from "@/shared/api/http-client"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
@@ -792,7 +792,7 @@ function AddressListsTab({
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} записей</span>
<Button size="sm" onClick={onAdd}><PlusIcon className="size-4" />Добавить</Button>
<Button size="sm" disabled={mutationsDisabled} onClick={onAdd}><PlusIcon className="size-4" />Добавить</Button>
</div>
<div className="flex flex-col gap-3">
@@ -805,7 +805,7 @@ function AddressListsTab({
<span className="text-sm font-mono font-medium">{listName}</span>
<span className="text-xs text-muted-foreground">({listEntries.length})</span>
</div>
<Button variant="ghost" size="sm" className="h-6 text-xs gap-1" onClick={() => onAddToList(listName)}>
<Button variant="ghost" size="sm" className="h-6 text-xs gap-1" disabled={mutationsDisabled} onClick={() => onAddToList(listName)}>
<PlusIcon className="size-3" />Адрес
</Button>
</div>
@@ -834,7 +834,7 @@ function AddressListsTab({
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onEdit(e)}><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onEdit(e)}><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onToggle(e)}>
<PowerIcon className="size-4" />{e.disabled ? "Включить" : "Отключить"}
</DropdownMenuItem>
@@ -1930,6 +1930,7 @@ function FirewallPageInner() {
const [dataLoading, setDataLoading] = useState(false)
const [dataError, setDataError] = useState<string | null>(null)
const [hasLoaded, setHasLoaded] = useState(false)
const [failures, setFailures] = useState<FirewallFailure[]>([])
const loadEpoch = useRef(0)
useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive])
const [busy, setBusy] = useState(false)
@@ -1980,8 +1981,10 @@ function FirewallPageInner() {
const firewall = firewallResponseSchema.safeParse(fw)
if (!servers.success || !firewall.success) throw new Error("Сервер вернул некорректные данные Firewall")
setLiveServers(servers.data.map(mapBackendToServer))
setLiveRules(firewall.data.rules)
setLiveAddrLists(firewall.data.addressLists)
const enabledIds = new Set(servers.data.filter(server => server.enabled).map(server => String(server.id)))
setLiveRules(previous => mergeFirewallRows(previous, firewall.data.rules, firewall.data.failures, enabledIds))
setLiveAddrLists(previous => mergeFirewallRows(previous, firewall.data.addressLists, firewall.data.failures, enabledIds, true))
setFailures(firewall.data.failures)
setHasLoaded(true)
} catch (e) {
if (epoch !== loadEpoch.current) return
@@ -2017,6 +2020,7 @@ function FirewallPageInner() {
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) throw new Error("Выберите устройство в режиме Live")
if (mutationLock.current) throw new Error("Дождитесь завершения текущей операции Firewall")
if (failures.some(f => f.serverId === historyServerId)) throw new Error("Обновите полный набор данных устройства перед восстановлением")
mutationLock.current = true
loadEpoch.current += 1
setDataLoading(false)
@@ -2036,7 +2040,7 @@ function FirewallPageInner() {
mutationLock.current = false
setHistoryRestoring(false)
}
}, [isLive, historyServerId, apiFetch, loadLive, loadRevisions])
}, [isLive, historyServerId, apiFetch, loadLive, loadRevisions, failures])
useEffect(() => {
queueMicrotask(() => {
@@ -2044,6 +2048,7 @@ function FirewallPageInner() {
setLiveRules([])
setLiveAddrLists([])
setHasLoaded(false)
setFailures([])
setDataError(null)
setDataLoading(false)
if (isLive) void loadLive()
@@ -2138,6 +2143,8 @@ function FirewallPageInner() {
return counts
}, [groupRules])
const scopedFailures = isLive ? failures.filter(f => (selectedServerId === ALL_SERVERS_ID || f.serverId === selectedServerId) && (!f.family || f.family === ipFamily)) : []
const firewallIncomplete = scopedFailures.length > 0
const totalEnabled = familyRules.filter((r) => r.enabled).length
const totalHits = familyRules.reduce((s, r) => s + r.hits, 0)
const dropRules = familyRules.filter((r) => r.action === "drop" || r.action === "reject").length
@@ -2151,7 +2158,7 @@ function FirewallPageInner() {
async function toggleRule(id: string) {
const r = displayRules.find((x) => x.id === id)
if (!r || mutationLock.current || busy || historyRestoring) return
if (!r || mutationLock.current || busy || historyRestoring || firewallIncomplete) return
if (isLive) {
const key = ruleKey(r)
if (!key) return
@@ -2181,13 +2188,13 @@ function FirewallPageInner() {
}
function requestDelete(target: NonNullable<typeof pendingDelete>) {
if (mutationLock.current || busy || historyRestoring) return
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setDeleteError(null)
setPendingDelete(target)
}
async function confirmDelete() {
if (!pendingDelete || mutationLock.current || busy || historyRestoring) return
if (!pendingDelete || mutationLock.current || busy || historyRestoring || firewallIncomplete) return
const target = pendingDelete
mutationLock.current = true
loadEpoch.current += 1
@@ -2225,7 +2232,7 @@ function FirewallPageInner() {
}, [filteredRules])
async function reorderRules(activeId: string, overId: string) {
if (mutationLock.current || busy || historyRestoring) return
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
const source = isLive ? liveRules : mockRules
const next = reorderWithinGroup(source, activeId, overId)
if (!next) {
@@ -2273,7 +2280,7 @@ function FirewallPageInner() {
}
function openAdd() {
if (mutationLock.current || busy || historyRestoring) return
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setFormError(null)
if (!requireWriteServer()) return
const table = tableOfGroup(chainGroup)
@@ -2285,14 +2292,14 @@ function FirewallPageInner() {
}
function openEdit(r: FirewallRule) {
if (mutationLock.current || busy || historyRestoring) return
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setFormError(null)
setEditingRule(r)
setRuleSheetOpen(true)
}
async function saveRule(payload: RuleWritePayload) {
if (mutationLock.current || busy || historyRestoring) return
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
const table = (editingRule?.table ?? tableOfGroup(chainGroup) ?? "filter") as FirewallTable
const family = editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily
const serverId = editingRule?.serverId ?? requireWriteServer()
@@ -2361,7 +2368,7 @@ function FirewallPageInner() {
}
function openAddAddr(list?: string) {
if (mutationLock.current || busy || historyRestoring) return
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setFormError(null)
if (!requireWriteServer()) return
setEditingAddr(list ? { list } : null)
@@ -2369,14 +2376,14 @@ function FirewallPageInner() {
}
function openEditAddr(e: AddressListEntry) {
if (mutationLock.current || busy || historyRestoring) return
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setFormError(null)
setEditingAddr(e)
setAddrSheetOpen(true)
}
async function toggleAddr(e: AddressListEntry) {
if (mutationLock.current || busy || historyRestoring) return
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
if (isLive) {
const key = addressKey(e)
if (!key) return
@@ -2412,7 +2419,7 @@ function FirewallPageInner() {
timeout?: string
disabled?: boolean
}) {
if (mutationLock.current || busy || historyRestoring) return
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
const family = editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily
const serverId = editingAddr?.serverId ?? requireWriteServer()
if (!serverId) return
@@ -2511,7 +2518,7 @@ function FirewallPageInner() {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={!isLive || !historyServerId || dataLoading || busy || togglePending || deletePending || movePending || historyRestoring}
disabled={!isLive || !historyServerId || dataLoading || busy || togglePending || deletePending || movePending || historyRestoring || failures.some(f => f.serverId === historyServerId)}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
>
<HistoryIcon className="size-4" />
@@ -2520,7 +2527,7 @@ function FirewallPageInner() {
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
<CodeXmlIcon className="size-4" />Экспорт .rsc
</Button>
<Button size="sm" onClick={openAdd}>
<Button size="sm" disabled={firewallIncomplete} onClick={openAdd}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
@@ -2550,6 +2557,11 @@ function FirewallPageInner() {
</span>
</div>
{firewallIncomplete && <div role="alert" className="rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm">
<p className="font-medium">Не все срезы Firewall получены</p>
<p>Сохранены последние данные недоступных срезов; их состояние неизвестно. Обновите данные перед изменениями.</p>
{scopedFailures.map((failure, index) => <p key={index}>{[failure.serverName || failure.serverId, failure.family, failure.table, failure.error].filter(Boolean).join(" · ")}</p>)}
</div>}
{moveStatus && <p role="status" className="text-sm text-muted-foreground">{moveStatus}</p>}
{moveError && <p role="alert" className="text-sm text-destructive">{moveError}</p>}
{toggleStatus && <p role="status" className="text-sm text-muted-foreground">{toggleStatus}</p>}
@@ -2560,28 +2572,28 @@ function FirewallPageInner() {
{
id: "rules",
label: "Всего правил",
value: isLive && !hasLoaded ? "—" : familyRules.length,
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : familyRules.length,
icon: <ShieldIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "enabled",
label: "Активных",
value: isLive && !hasLoaded ? "—" : totalEnabled,
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : totalEnabled,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "drop",
label: "Блокирующих",
value: isLive && !hasLoaded ? "—" : dropRules,
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : dropRules,
icon: <ShieldOffIcon className="size-4" />,
iconClassName: "text-destructive",
},
{
id: "hits",
label: "Срабатываний",
value: isLive && !hasLoaded ? "—" : fmtHits(totalHits),
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : fmtHits(totalHits),
icon: <ListFilterIcon className="size-4" />,
iconClassName: "text-info",
},
@@ -2612,12 +2624,12 @@ function FirewallPageInner() {
))}
</div>
{isLive && !hasLoaded ? (
{isLive && (!hasLoaded || (firewallIncomplete && (chainGroup === "address-lists" ? familyAddrLists.length === 0 : filteredRules.length === 0))) ? (
<p role="status" className="text-sm text-muted-foreground">{dataLoading ? "Загрузка данных Firewall…" : "Данные Firewall пока недоступны. Повторите загрузку."}</p>
) : chainGroup === "address-lists" ? (
<AddressListsTab
entries={familyAddrLists}
mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring}
mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring || firewallIncomplete}
onAdd={() => openAddAddr()}
onAddToList={(list) => openAddAddr(list)}
onToggle={(e) => { void toggleAddr(e) }}
@@ -2659,11 +2671,11 @@ function FirewallPageInner() {
<FirewallRulesDataGrid
rules={filteredRules}
mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring}
mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring || firewallIncomplete}
onToggle={(id) => { void toggleRule(id) }}
onEdit={openEdit}
onDelete={(item) => requestDelete({ kind: "rule", item })}
onReorder={canReorder && !togglePending && !deletePending && !movePending && !busy && !historyRestoring ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
onReorder={canReorder && !togglePending && !deletePending && !movePending && !busy && !historyRestoring && !firewallIncomplete ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
showServer={showServerCol}
/>
</DataPageCard>
@@ -2758,7 +2770,7 @@ function FirewallPageInner() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePending}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={deletePending || togglePending || busy || historyRestoring} onClick={e => { e.preventDefault(); void confirmDelete() }}>Удалить</AlertDialogAction>
<AlertDialogAction variant="destructive" disabled={deletePending || togglePending || busy || historyRestoring || firewallIncomplete} onClick={e => { e.preventDefault(); void confirmDelete() }}>Удалить</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>