fix(ui): сохранять недоступные срезы Firewall
This commit is contained in:
1 parent
59440555cd
commit
5e6057842f
11 files changed
+228
-77
No files matched your search
@@ -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>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
|
||||
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
|
||||
"test:backups": "tsx src/services/s3-backup-client.test.ts",
|
||||
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts",
|
||||
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts",
|
||||
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync && npm run test:ros-file",
|
||||
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pollFirewallChunks, type FirewallChunkFailure } from "./firewall-poll.js"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
@@ -150,15 +151,6 @@ export function mapAddressList(
|
||||
}
|
||||
}
|
||||
|
||||
async function safeGet<T>(fn: () => Promise<T[]>, fallback: T[] = []): Promise<T[]> {
|
||||
try {
|
||||
const rows = await fn()
|
||||
return Array.isArray(rows) ? rows : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function rosYes(v: string | undefined): boolean {
|
||||
return v === "true" || v === "yes"
|
||||
}
|
||||
@@ -210,28 +202,17 @@ export function mapFirewallSnapshotList(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchFirewallState(server: ServerRow): Promise<{
|
||||
export async function fetchFirewallState(server: ServerRow, allowPartial = false): Promise<{
|
||||
rules: FirewallRuleDto[]
|
||||
addressLists: FirewallAddressListDto[]
|
||||
liveRules: FirewallLiveRule[]
|
||||
liveLists: FirewallLiveList[]
|
||||
snapshot: FirewallSnapshot
|
||||
failures: FirewallChunkFailure[]
|
||||
}> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const ruleJobs = FAMILIES.flatMap((family) =>
|
||||
TABLES.map(async (table) => {
|
||||
const raw = await safeGet(() => client.getFirewallRules(family, table))
|
||||
return { family, table, raw }
|
||||
}),
|
||||
)
|
||||
const listJobs = FAMILIES.map(async (family) => {
|
||||
const raw = await safeGet(() => client.getFirewallAddressList(family))
|
||||
return { family, raw }
|
||||
})
|
||||
const [ruleChunks, listChunks] = await Promise.all([
|
||||
Promise.all(ruleJobs),
|
||||
Promise.all(listJobs),
|
||||
])
|
||||
const { ruleChunks, listChunks, failures } = await pollFirewallChunks(client)
|
||||
if (!allowPartial && failures.length > 0) throw new Error("Не удалось полностью прочитать Firewall: " + failures.map(f => f.family + "/" + f.table + ": " + f.error).join("; "))
|
||||
|
||||
const rules: FirewallRuleDto[] = []
|
||||
const liveRules: FirewallLiveRule[] = []
|
||||
@@ -256,6 +237,7 @@ export async function fetchFirewallState(server: ServerRow): Promise<{
|
||||
addressLists,
|
||||
liveRules,
|
||||
liveLists,
|
||||
failures,
|
||||
snapshot: canonicalFirewallSnapshot({
|
||||
rules: liveRules.filter((r) => !r.dynamic),
|
||||
addressLists: liveLists.filter((e) => !e.dynamic),
|
||||
@@ -279,28 +261,22 @@ export async function captureFirewallSnapshot(server: ServerRow): Promise<Firewa
|
||||
export async function listFirewallAll(): Promise<{
|
||||
rules: FirewallRuleDto[]
|
||||
addressLists: FirewallAddressListDto[]
|
||||
failures: Array<Partial<FirewallChunkFailure> & { serverId: string; serverName: string; error: string }>
|
||||
}> {
|
||||
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
const perServer = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
const state = await fetchFirewallState(server)
|
||||
await captureAndAppendRevision({
|
||||
serverId: server.id,
|
||||
section: "firewall",
|
||||
source: "observed",
|
||||
capture: async () => state.snapshot,
|
||||
})
|
||||
return { rules: state.rules, addressLists: state.addressLists }
|
||||
} catch {
|
||||
return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] }
|
||||
const perServer = await Promise.all(allServers.map(async server => {
|
||||
const context = { serverId: String(server.id), serverName: server.name || server.host }
|
||||
try {
|
||||
const state = await fetchFirewallState(server, true)
|
||||
if (state.failures.length === 0) {
|
||||
await captureAndAppendRevision({ serverId: server.id, section: "firewall", source: "observed", capture: async () => state.snapshot })
|
||||
}
|
||||
}),
|
||||
)
|
||||
return {
|
||||
rules: perServer.flatMap((r) => r.rules),
|
||||
addressLists: perServer.flatMap((r) => r.addressLists),
|
||||
}
|
||||
return { rules: state.rules, addressLists: state.addressLists, failures: state.failures.map(f => ({ ...context, ...f })) }
|
||||
} catch (e) {
|
||||
return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[], failures: [{ ...context, error: e instanceof Error ? e.message : "Ошибка опроса Firewall" }] }
|
||||
}
|
||||
}))
|
||||
return { rules: perServer.flatMap(r => r.rules), addressLists: perServer.flatMap(r => r.addressLists), failures: perServer.flatMap(r => r.failures) }
|
||||
}
|
||||
|
||||
export { firewallRestPath, FAMILIES, TABLES }
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { pollFirewallChunks } from "./firewall-poll.js"
|
||||
import { fetchFirewallState, captureFirewallSnapshot } from "./firewall-live.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
const client = {
|
||||
getFirewallRules: async (family: string, table: string) => {
|
||||
if (family === "ip6" && table === "nat") throw new Error("slice unavailable")
|
||||
return [{ ".id": "*1", chain: "forward", action: "accept", packets: "1" }]
|
||||
},
|
||||
getFirewallAddressList: async () => [],
|
||||
}
|
||||
const partial = await pollFirewallChunks(client)
|
||||
assert.equal(partial.ruleChunks.length, 8)
|
||||
assert.equal(partial.listChunks.length, 2)
|
||||
assert.deepEqual(partial.failures, [{ family: "ip6", table: "nat", error: "slice unavailable" }])
|
||||
assert.equal(partial.ruleChunks.filter(chunk => chunk.raw.length === 1).length, 7)
|
||||
const empty = await pollFirewallChunks({ getFirewallRules: async () => [], getFirewallAddressList: async () => [] })
|
||||
assert.deepEqual(empty.failures, [])
|
||||
assert.ok(empty.ruleChunks.every(chunk => chunk.raw.length === 0))
|
||||
const failed = await pollFirewallChunks({ getFirewallRules: async () => { throw new Error("failed") }, getFirewallAddressList: async () => { throw new Error("failed") } })
|
||||
assert.equal(failed.failures.length, 10)
|
||||
const malformed = await pollFirewallChunks({ getFirewallRules: async () => null as never, getFirewallAddressList: async () => [] })
|
||||
assert.equal(malformed.failures.length, 8)
|
||||
assert.ok(malformed.failures.every(failure => failure.error.includes("некорректный список")))
|
||||
|
||||
const fromServer = MikrotikClient.fromServer
|
||||
try {
|
||||
MikrotikClient.fromServer = () => client as unknown as ReturnType<typeof fromServer>
|
||||
const server = { id: 1, name: "Router", host: "192.0.2.1" } as Parameters<typeof fetchFirewallState>[0]
|
||||
await assert.rejects(fetchFirewallState(server), /Не удалось полностью прочитать Firewall/)
|
||||
await assert.rejects(captureFirewallSnapshot(server), /Не удалось полностью прочитать Firewall/)
|
||||
const state = await fetchFirewallState(server, true)
|
||||
assert.equal(state.rules.length, 7)
|
||||
assert.deepEqual(state.failures, partial.failures)
|
||||
} finally { MikrotikClient.fromServer = fromServer }
|
||||
console.log("Firewall polling: mixed/empty/all-failed/malformed and strict snapshot rejection passed")
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FirewallFamily, FirewallTable, RosFirewallFilter, RosFirewallAddressList } from "../types/server.js"
|
||||
|
||||
export type FirewallChunkFailure = { family: FirewallFamily; table: FirewallTable | "address-list"; error: string }
|
||||
export async function pollFirewallChunks(client: {
|
||||
getFirewallRules: (family: FirewallFamily, table: FirewallTable) => Promise<RosFirewallFilter[]>
|
||||
getFirewallAddressList: (family: FirewallFamily) => Promise<RosFirewallAddressList[]>
|
||||
}) {
|
||||
const failures: FirewallChunkFailure[] = []
|
||||
async function read<T>(family: FirewallFamily, table: FirewallChunkFailure["table"], fetch: () => Promise<T[]>): Promise<T[]> {
|
||||
try {
|
||||
const rows = await fetch()
|
||||
if (!Array.isArray(rows)) throw new Error("RouterOS вернул некорректный список")
|
||||
return rows
|
||||
} catch (e) {
|
||||
failures.push({ family, table, error: e instanceof Error ? e.message : "Ошибка опроса Firewall" })
|
||||
return []
|
||||
}
|
||||
}
|
||||
const families: FirewallFamily[] = ["ip", "ip6"]
|
||||
const tables: FirewallTable[] = ["filter", "nat", "mangle", "raw"]
|
||||
const [ruleChunks, listChunks] = await Promise.all([
|
||||
Promise.all(families.flatMap(family => tables.map(async table => ({ family, table, raw: await read(family, table, () => client.getFirewallRules(family, table)) })))),
|
||||
Promise.all(families.map(async family => ({ family, raw: await read(family, "address-list", () => client.getFirewallAddressList(family)) }))),
|
||||
])
|
||||
return { ruleChunks, listChunks, failures }
|
||||
}
|
||||
@@ -290,7 +290,7 @@ function FirewallRulesDataGrid({
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(r)}>
|
||||
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onEdit(r)}>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
|
||||
+2
-2
@@ -80,7 +80,7 @@ flowchart LR
|
||||
|
||||
## Текущая точка продолжения
|
||||
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.31 — IPsec/GRE/VXLAN и переключение Firewall); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.32 — IPsec/GRE/VXLAN и переключение Firewall); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
|
||||
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.md).
|
||||
|
||||
@@ -88,7 +88,7 @@ flowchart LR
|
||||
|
||||
Ближайший оставшийся объём:
|
||||
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. Далее — частичные сбои опроса Firewall на backend и сохранение данных по устройству, затем дополнительные проверки контекста и Filters; ограничения счётчика, свежести и записи VXLAN остаются открытыми; загрузка данных IPsec и общая приёмка остаются открытыми.
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. Далее — свежесть и дополнительные проверки контекста Firewall, затем Filters; ограничения счётчика, свежести и записи VXLAN остаются открытыми; загрузка данных IPsec и общая приёмка остаются открытыми.
|
||||
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||
|
||||
|
||||
+10
-1
@@ -25,12 +25,21 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.31 — IPsec/GRE/VXLAN и переключение Firewall |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.32 — IPsec/GRE/VXLAN и переключение Firewall |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.32 — частичные сбои Firewall (2026-09-30)
|
||||
|
||||
- pollFirewallChunks опрашивает восемь таблиц правил и два address-list, сохраняет успешные срезы и возвращает failures с family/table/error. Повреждённый контейнер также считается сбоем. listFirewallAll добавляет устройство и сообщает общий сбой вместо маскировки успешным пустым списком.
|
||||
- fetchFirewallState по умолчанию отклоняет неполное чтение: captureFirewallSnapshot и восстановление не используют неполный снимок. Для обзора явно разрешён partial; observed-ревизия записывается только при полном чтении. Ошибка записи ревизии возвращается как общий failure устройства. Реальная БД и RouterOS не проверены. Подтверждение применения после операции, если последующее чтение снимка не удалось, требует внешней приёмки: write-endpoints по-прежнему могут сообщить ошибку после фактического изменения устройства.
|
||||
- UI валидирует failures, заменяет успешные срезы и сохраняет последние правила/address-list недоступных срезов включённых устройств. Сводка неполного выбранного IP-семейства неизвестна; предупреждение содержит устройство/срез/ошибку. Запись в этом срезе заблокирована до повторного успеха, восстановление — при любом failure выбранного устройства. Успешное пустое чтение удаляет старые строки; первоначальный сбой не выглядит успешным пустым списком.
|
||||
- Серверный тест прошёл: mixed/empty/all-failed/malformed, strict snapshot rejection и сохранение семи успешных таблиц при одном сбое. Тест подключён к test:live-maps. Прошли 35 разных E2E в двух прогонах: 32 прежних и три новых частичных сценария. Первый прогон нового сценария остановился на проверке скрытого комментария; проверка заменена видимым портом, профильные три теста прошли. TypeScript frontend/backend и сборка backend прошли. Новые frontend-файлы/Grid lint-чистые; исходные 4 ошибки/2 предупреждения страницы сохранены. Diff-check проверен перед коммитом. Последний frontend production build — 9.31.
|
||||
- Далее — timestamps свежести и дополнительные гонки Firewall при смене backend/режима и позднем завершении мутации; затем Filters. Полная валидация сырой записи RouterOS и интеграция записи observed-ревизий с реальной БД не проверены. Этап 9 открыт.
|
||||
- Квота: 38% пятичасовой/6% недельной на старте, 42%/7% после проверок. Локальный коммит без push; чужие изменения сохранены.
|
||||
|
||||
## Блок 9.31 — проверка и загрузка данных Firewall (2026-09-30)
|
||||
|
||||
- Общие Zod-схемы проверяют контейнер, обязательные поля правил/address-list, IP-семейство, таблицу, boolean/числовые поля, ID устройств и дубли строк/серверов. Ответы обрабатываются как unknown до валидации; повреждённые данные не попадают в UI.
|
||||
|
||||
@@ -165,3 +165,7 @@ POST требует ok=true, ошибка возвращает исходный
|
||||
## Блок 9.31 — данные Firewall (2026-09-30)
|
||||
|
||||
Схемы отклоняют повреждённые ответы и дубли. Ошибка сохраняет последний список, первоначальные KPI неизвестны. Поколение запросов отменяет старое чтение при начале записи. Все 32 E2E, TypeScript и build (34 страницы) прошли; новых замечаний lint нет. Далее — частичные сбои опроса backend: safeGet/listFirewallAll всё ещё маскируют ошибку пустым списком. Свежесть, дополнительные гонки backend/режима/мутации и реальные устройства ещё не проверены.
|
||||
|
||||
## Блок 9.32 — частичные сбои Firewall (2026-09-30)
|
||||
|
||||
Backend сообщает failures по срезу, неполный snapshot отклоняется и не записывается как observed. UI сохраняет недоступные срезы, показывает неизвестную сводку и блокирует изменения до повторного успеха. Серверный тест, 35 разных E2E в двух прогонах, TypeScript frontend/backend и backend build прошли; новых frontend lint замечаний нет. Далее — свежесть/гонки контекста Firewall, затем Filters. Реальные RouterOS/БД и последствия ошибки снимка после записи ещё требуют приёмки; последний frontend build — 9.31.
|
||||
@@ -1,7 +1,11 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const context = { id: z.string().min(1), rosId: z.string().min(1), serverId: z.string().min(1), serverName: z.string(), family: z.enum(["ip", "ip6"]) }
|
||||
export const firewallFailureSchema = z.object({ serverId: z.string().min(1), serverName: z.string().optional(), family: z.enum(["ip", "ip6"]).optional(), table: z.enum(["filter", "nat", "mangle", "raw", "address-list"]).optional(), error: z.string() })
|
||||
export type FirewallFailure = z.infer<typeof firewallFailureSchema>
|
||||
|
||||
export const firewallResponseSchema = z.object({
|
||||
failures: z.array(firewallFailureSchema).default([]),
|
||||
rules: z.array(z.object({
|
||||
...context, table: z.enum(["filter", "nat", "mangle", "raw"]), chain: z.string(), action: z.string(),
|
||||
proto: z.string(), src: z.string(), dst: z.string(), port: z.string(), iface: z.string(),
|
||||
@@ -20,3 +24,13 @@ export const firewallServersSchema = z.array(z.object({
|
||||
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length)
|
||||
|
||||
export type FirewallBackendServer = z.infer<typeof firewallServersSchema>[number]
|
||||
|
||||
export function matchesFirewallFailure(failure: FirewallFailure, row: { serverId?: string; family?: string; table?: string }, addressList = false) {
|
||||
return failure.serverId === row.serverId && (!failure.family || failure.family === row.family)
|
||||
&& (!failure.table || failure.table === (addressList ? "address-list" : row.table))
|
||||
}
|
||||
|
||||
export function mergeFirewallRows<T extends { serverId?: string; family?: string; table?: string }>(previous: T[], incoming: T[], failures: FirewallFailure[], enabledServerIds: Set<string>, addressList = false): T[] {
|
||||
return [...incoming.filter(row => !failures.some(f => matchesFirewallFailure(f, row, addressList))),
|
||||
...previous.filter(row => !!row.serverId && enabledServerIds.has(row.serverId) && failures.some(f => matchesFirewallFailure(f, row, addressList)))]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { test, expect, type Page } from "@playwright/test"
|
||||
|
||||
const server = { id: 1, name: "Первый роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }
|
||||
const rule = { id: "rule-1", rosId: "*1", serverId: "1", serverName: server.name, family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Первое правило", enabled: true, hits: 1 }
|
||||
const second = { ...rule, id: "rule-2", rosId: "*2", serverId: "2", serverName: "Второй роутер", comment: "Второе правило" }
|
||||
const address = { id: "address-1", rosId: "*A", serverId: "1", serverName: server.name, family: "ip", list: "trusted", address: "192.0.2.10", comment: "Адрес тест", disabled: false }
|
||||
async function setup(page: Page) {
|
||||
const control: { payload: unknown } = { payload: { rules: [rule, second], addressLists: [address] } }
|
||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
return route.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/firewall/all" ? control.payload
|
||||
: path === "/api/servers" ? [server, { ...server, id: 2, name: "Второй роутер", host: "192.0.2.2" }] : {} })
|
||||
})
|
||||
return control
|
||||
}
|
||||
|
||||
test("Firewall: сохраняет недоступную таблицу, обновляет другое устройство и восстанавливает пустой срез", async ({ page }) => {
|
||||
const control = await setup(page)
|
||||
await page.goto("/firewall")
|
||||
const rows = page.locator("tr[data-row-id]")
|
||||
await expect(rows).toHaveCount(2)
|
||||
control.payload = { rules: [{ ...second, port: "8443" }], addressLists: [], failures: [{ serverId: "1", serverName: server.name, family: "ip", table: "filter", error: "Таблица недоступна тест" }] }
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Не все срезы Firewall получены" })).toContainText("Таблица недоступна тест")
|
||||
await expect(rows).toHaveCount(2)
|
||||
await expect(page.locator('tr[data-row-id="rule-1"]')).toContainText("443")
|
||||
await expect(page.locator('tr[data-row-id="rule-2"]')).toContainText("8443")
|
||||
await expect(page.getByLabel("Сводка Firewall")).toContainText("—")
|
||||
await page.getByRole("option").filter({ hasText: "Второй роутер" }).click()
|
||||
await expect(rows).toHaveCount(1)
|
||||
await expect(rows.getByRole("switch")).toBeEnabled()
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toHaveCount(0)
|
||||
await page.getByRole("option").filter({ hasText: "Первый роутер" }).click()
|
||||
await expect(rows.getByRole("switch")).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "История", exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeDisabled()
|
||||
control.payload = { rules: [second], addressLists: [] }
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(rows).toHaveCount(0)
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toHaveCount(0)
|
||||
await expect(page.getByLabel("Сводка Firewall")).not.toContainText("—")
|
||||
})
|
||||
|
||||
test("Firewall: address-list сохраняется при сбое своего среза", async ({ page }) => {
|
||||
const control = await setup(page)
|
||||
await page.goto("/firewall?server=1")
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
control.payload = { rules: [rule, second], addressLists: [], failures: [{ serverId: "1", family: "ip", table: "address-list", error: "Адреса недоступны тест" }] }
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toBeVisible()
|
||||
await page.getByRole("button", { name: /Адр. листы/ }).click()
|
||||
await expect(page.getByText(address.address, { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("switch")).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeDisabled()
|
||||
control.payload = { rules: [rule, second], addressLists: [] }
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(page.getByText(address.address, { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
test("Firewall: первичный сбой устройства не выглядит успешным пустым списком", async ({ page }) => {
|
||||
const control = await setup(page)
|
||||
control.payload = { rules: [], addressLists: [], failures: [{ serverId: "1", error: "Устройство недоступно тест" }] }
|
||||
await page.goto("/firewall?server=1")
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toContainText("Устройство недоступно тест")
|
||||
await expect(page.getByRole("status").filter({ hasText: "Данные Firewall пока недоступны" })).toBeVisible()
|
||||
await expect(page.getByLabel("Сводка Firewall")).toContainText("—")
|
||||
control.payload = { rules: [rule], addressLists: [] }
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
await expect(page.getByRole("switch")).toBeEnabled()
|
||||
})
|
||||
Reference in new issue
Block a user