"use client"
import { can } from "@/lib/auth"
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useSearchParams } from "next/navigation"
import { PageHeader } from "@/components/page-header"
import {
FirewallRulesDataGrid,
ActionBadge,
ChainBadge,
} from "@/components/data-grids/firewall-rules-data-grid"
import { FirewallScenarioRulesDataGrid } from "@/components/data-grids/firewall-scenario-rules-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbarFrame } from "@/components/data-page-toolbar"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import {
firewallRules,
servers as mockServers,
type FirewallAddressListEntry,
type FirewallRule,
type FirewallTable,
type Server,
} from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { firewallResponseSchema, firewallServersSchema, mergeFirewallRows, type FirewallFailure, type FirewallBackendServer } from "@/lib/firewall-response"
import { updateFirewallSliceTimes, firewallTimeRange, type FirewallSliceTime } from "@/lib/firewall-freshness"
import { requestJson } from "@/shared/api/http-client"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
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,
ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon,
PencilIcon, Trash2Icon, CodeXmlIcon,
MoreHorizontalIcon,
PowerIcon, CheckCircleIcon,
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
SlidersHorizontalIcon, RefreshCwIcon, HistoryIcon,
} from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { toast } from "sonner"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
// ─── Types ────────────────────────────────────────────────────────────────────
type ChainGroup = "filter" | "nat" | "mangle" | "raw" | "address-lists" | "simulator"
type ChainFilter = "all" | string
// ─── Simulator types ──────────────────────────────────────────────────────────
interface PacketDef {
chain: string
proto: string
srcAddr: string
dstAddr: string
srcPort: string
dstPort: string
inIface: string
outIface: string
connState: string
srcAddrList: string
dstAddrList: string
tlsHost: string
}
type SimSpeed = "slow" | "normal" | "fast" | "instant"
interface SimCheck {
field: string
ruleVal: string
packetVal: string
passed: boolean
}
interface SimStep {
rule: FirewallRule
index: number
status: "pending" | "evaluating" | "match" | "skip" | "disabled" | "passthrough"
checks: SimCheck[]
failedAt?: string
}
// ─── Scenario types ───────────────────────────────────────────────────────────
interface ScenarioRule {
id: string
chain: string
action: string
proto: string
src: string
dst: string
port: string
iface: string
comment: string
enabled: boolean
}
interface SimScenario {
id: string
name: string
description: string
packet: PacketDef
rules: ScenarioRule[]
createdAt: number
}
// ─── Packet presets ───────────────────────────────────────────────────────────
const DEFAULT_PKT: PacketDef = {
chain: "forward", proto: "tcp",
srcAddr: "10.10.0.100", dstAddr: "142.250.74.110",
srcPort: "54321", dstPort: "443",
inIface: "lan", outIface: "wan-msk",
connState: "new", srcAddrList: "", dstAddrList: "", tlsHost: "",
}
// ─── RouterOS traversal paths ────────────────────────────────────────────────
// Maps packet "flow type" (= packet.chain value) → ordered chain names to evaluate.
// Mirrors the real RouterOS pipeline so Mangle, Raw, NAT rules are all included.
const DIR_PATHS: Record = {
// Forwarded traffic (LAN → WAN, etc.)
forward: ["prerouting", "forward", "postrouting", "srcnat"],
// Traffic destined for the router itself
input: ["prerouting", "input"],
// Traffic originating from the router
output: ["output", "postrouting", "srcnat"],
// DNAT / port-forward
dstnat: ["prerouting", "dstnat", "forward", "postrouting", "srcnat"],
}
// Human-readable table hint shown next to each chain header in the trace
const CHAIN_TABLE_HINT: Record = {
prerouting: "Raw + Mangle",
forward: "Mangle + Filter",
input: "Mangle + Filter",
output: "Mangle + Filter",
postrouting: "Mangle",
srcnat: "NAT",
dstnat: "NAT",
}
// ─── Packet presets ───────────────────────────────────────────────────────────
const SIM_PRESETS: Array<{
id: string; label: string; desc: string; emoji: string; pkt: Partial
}> = [
{ id: "https", label: "HTTPS → интернет", desc: "TCP 443, forward, новый", emoji: "🌐",
pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "142.250.74.110", inIface: "lan", connState: "new" } },
{ id: "http", label: "HTTP → интернет", desc: "TCP 80, forward", emoji: "📡",
pkt: { chain: "forward", proto: "tcp", dstPort: "80", srcAddr: "10.10.0.100", dstAddr: "93.184.216.34", inIface: "lan", connState: "new" } },
{ id: "ssh-lan", label: "SSH управление", desc: "TCP 22, input, с LAN", emoji: "🔐",
pkt: { chain: "input", proto: "tcp", dstPort: "22", srcAddr: "10.10.0.5", dstAddr: "10.0.0.1", inIface: "lan", connState: "new" } },
{ id: "ssh-wan", label: "SSH с WAN", desc: "TCP 22, input, внешний", emoji: "🚨",
pkt: { chain: "input", proto: "tcp", dstPort: "22", srcAddr: "185.220.101.45", dstAddr: "10.0.0.1", inIface: "wan-msk", connState: "new" } },
{ id: "winbox", label: "WinBox", desc: "TCP 8291, input, с LAN", emoji: "🖥️",
pkt: { chain: "input", proto: "tcp", dstPort: "8291",srcAddr: "10.10.0.5", dstAddr: "10.0.0.1", inIface: "lan", connState: "new" } },
{ id: "youtube", label: "YouTube bypass", desc: "TCP 443, dst-list youtube", emoji: "▶️",
pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "142.250.74.110", dstAddrList: "youtube-bypass", inIface: "lan", connState: "new" } },
{ id: "cdn", label: "CDN mark-routing", desc: "TCP, dst-list cdn-bypass", emoji: "🚀",
pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "104.16.132.229", dstAddrList: "cdn-bypass", inIface: "lan", connState: "new" } },
{ id: "nat-out", label: "Masquerade", desc: "srcnat, LAN → WAN", emoji: "🔄",
pkt: { chain: "srcnat", proto: "tcp", srcAddr: "10.10.0.100", dstAddr: "8.8.8.8", dstPort: "443", outIface: "wan-msk" } },
{ id: "icmp", label: "Ping ICMP", desc: "ICMP echo, forward", emoji: "📶",
pkt: { chain: "forward", proto: "icmp", srcAddr: "10.10.0.100", dstAddr: "8.8.8.8", inIface: "lan" } },
{ id: "established", label: "Established flow", desc: "TCP established, reverse", emoji: "↩️",
pkt: { chain: "forward", proto: "tcp", dstPort: "54321", srcAddr: "142.250.74.110", dstAddr: "10.10.0.100", inIface: "wan-msk", connState: "established" } },
]
// ─── Matching engine ──────────────────────────────────────────────────────────
function _ipNum(ip: string): number {
return ip.split(".").reduce((a, o) => (a << 8) + parseInt(o, 10), 0) >>> 0
}
function _inCidr(ip: string, cidr: string): boolean {
if (!ip) return false
if (!cidr.includes("/")) return ip === cidr
const [net, b] = cidr.split("/"); const bits = parseInt(b)
if (bits === 0) return true
const mask = bits >= 32 ? 0xFFFFFFFF : (~(0xFFFFFFFF >>> bits)) >>> 0
return (_ipNum(ip) & mask) === (_ipNum(net) & mask)
}
function _mAddr(pAddr: string, rAddr: string): boolean {
if (!rAddr || rAddr === "—" || rAddr === "") return true
return rAddr.includes("/") ? _inCidr(pAddr, rAddr) : pAddr === rAddr
}
function _mPort(pPort: string, rPort: string): boolean {
if (!rPort || rPort === "—") return true
if (!pPort) return false
const n = parseInt(pPort)
for (const p of rPort.split(",")) {
const t = p.trim()
if (t.includes("-")) { const [lo, hi] = t.split("-").map(Number); if (n >= lo && n <= hi) return true }
else if (Number(t) === n) return true
}
return false
}
function _mList(pList: string, rList: string): boolean {
return !rList || rList === "—" || pList === rList
}
function _mIface(pI: string, rI: string): boolean {
return !rI || rI === "—" || !pI || pI === rI || rI === "*"
}
function evalRule(rule: FirewallRule, pkt: PacketDef): {
verdict: SimStep["status"]; checks: SimCheck[]; failedAt?: string
} {
if (!rule.enabled) return { verdict: "disabled", checks: [] }
const checks: SimCheck[] = []
const fail = (c: SimCheck) => { checks.push(c); return { verdict: "skip" as const, checks, failedAt: c.field } }
if (rule.chain !== pkt.chain) return fail({ field: "chain", ruleVal: rule.chain, packetVal: pkt.chain, passed: false })
checks.push({ field: "chain", ruleVal: rule.chain, packetVal: pkt.chain, passed: true })
if (rule.proto && rule.proto !== "all") {
const ok = pkt.proto === rule.proto
if (!ok) return fail({ field: "protocol", ruleVal: rule.proto, packetVal: pkt.proto, passed: false })
checks.push({ field: "protocol", ruleVal: rule.proto, packetVal: pkt.proto, passed: true })
}
if (rule.src && rule.src !== "—") {
const ok = _mAddr(pkt.srcAddr, rule.src) || _mList(pkt.srcAddrList, rule.src)
if (!ok) return fail({ field: "src-address", ruleVal: rule.src, packetVal: pkt.srcAddr || "—", passed: false })
checks.push({ field: "src-address", ruleVal: rule.src, packetVal: pkt.srcAddr || pkt.srcAddrList, passed: true })
}
if (rule.dst && rule.dst !== "—") {
const ok = _mAddr(pkt.dstAddr, rule.dst) || _mList(pkt.dstAddrList, rule.dst)
if (!ok) return fail({ field: "dst-address", ruleVal: rule.dst, packetVal: pkt.dstAddr || "—", passed: false })
checks.push({ field: "dst-address", ruleVal: rule.dst, packetVal: pkt.dstAddr || pkt.dstAddrList, passed: true })
}
if (rule.port && rule.port !== "—") {
const ok = _mPort(pkt.dstPort, rule.port)
if (!ok) return fail({ field: "dst-port", ruleVal: rule.port, packetVal: pkt.dstPort || "—", passed: false })
checks.push({ field: "dst-port", ruleVal: rule.port, packetVal: pkt.dstPort, passed: true })
}
if (rule.iface && rule.iface !== "—") {
const ok = _mIface(pkt.inIface, rule.iface)
if (!ok) return fail({ field: "in-interface", ruleVal: rule.iface, packetVal: pkt.inIface || "—", passed: false })
checks.push({ field: "in-interface", ruleVal: rule.iface, packetVal: pkt.inIface, passed: true })
}
if (rule.tlsHost && pkt.tlsHost) {
const pat = "^" + rule.tlsHost.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$"
const ok = new RegExp(pat).test(pkt.tlsHost)
if (!ok) return fail({ field: "tls-host", ruleVal: rule.tlsHost, packetVal: pkt.tlsHost, passed: false })
checks.push({ field: "tls-host", ruleVal: rule.tlsHost, packetVal: pkt.tlsHost, passed: true })
}
return { verdict: rule.action === "passthrough" ? "passthrough" : "match", checks }
}
type AddressListEntry = FirewallAddressListEntry
// ─── Mock address-list data ───────────────────────────────────────────────────
const INIT_ADDRESS_LISTS: AddressListEntry[] = [
{ id: "al1", list: "youtube-bypass", address: "142.250.0.0/15", comment: "Google/YouTube AS15169", disabled: false, family: "ip", serverId: "srv1", rosId: "*1" },
{ id: "al2", list: "youtube-bypass", address: "172.217.0.0/16", comment: "Google/YouTube AS15169", disabled: false, family: "ip", serverId: "srv1", rosId: "*2" },
{ id: "al3", list: "cdn-bypass", address: "104.16.0.0/12", comment: "Cloudflare AS13335", disabled: false, family: "ip", serverId: "srv1", rosId: "*3" },
{ id: "al4", list: "cdn-bypass", address: "23.32.0.0/11", comment: "Akamai AS20940", disabled: false, family: "ip", serverId: "srv1", rosId: "*4" },
{ id: "al5", list: "cdn-bypass", address: "151.101.0.0/16", comment: "Fastly AS54113", disabled: false, family: "ip", serverId: "srv1", rosId: "*5" },
{ id: "al6", list: "streaming-eu", address: "54.246.0.0/16", comment: "Netflix AS2906", disabled: false, family: "ip", serverId: "srv1", rosId: "*6" },
{ id: "al7", list: "streaming-eu", address: "45.57.0.0/16", comment: "Netflix AS2906 NL", disabled: false, family: "ip", serverId: "srv1", rosId: "*7" },
{ id: "al8", list: "social-block", address: "157.240.0.0/17", comment: "Facebook/Meta AS32934", disabled: false, family: "ip", serverId: "srv1", rosId: "*8" },
{ id: "al9", list: "social-block", address: "31.13.64.0/18", comment: "Facebook/Meta AS32934", disabled: false, family: "ip", serverId: "srv1", rosId: "*9" },
{ id: "al10", list: "gaming-low-latency",address: "162.159.128.0/19", comment: "Discord/Cloudflare AS13335", disabled: false, family: "ip", serverId: "srv1", rosId: "*10" },
{ id: "al11", list: "management-access", address: "10.10.0.0/16", comment: "LAN", disabled: false, family: "ip", serverId: "srv1", rosId: "*11" },
{ id: "al12", list: "management-access", address: "192.168.100.0/24", comment: "MGMT VLAN", disabled: false, family: "ip", serverId: "srv1", rosId: "*12" },
{ id: "al13", list: "blocklist-dynamic", address: "185.220.101.45", comment: "Tor exit — dynamic ban", disabled: false, timeout: "01:00:00", family: "ip", serverId: "srv1", rosId: "*13" },
{ id: "al14", list: "blocklist-dynamic", address: "80.82.70.118", comment: "Shodan scanner — dynamic ban", disabled: false, timeout: "00:42:18", family: "ip", serverId: "srv1", rosId: "*14" },
{ id: "al15", list: "v6-allow", address: "2a01:4f8::/32", comment: "Hetzner v6", disabled: false, family: "ip6", serverId: "srv1", rosId: "*1" },
]
// ─── IP family filter ─────────────────────────────────────────────────────────
type IpFamily = "ip" | "ip6"
const IP_FAMILY_LABELS: Record = {
ip: "IPv4",
ip6: "IPv6",
}
// ─── Chain groups config ─────────────────────────────────────────────────────
const CHAIN_GROUPS: {
id: ChainGroup; label: string; icon: React.ReactNode; chains: string[]
}[] = [
{
id: "filter", label: "Filter", icon: ,
chains: ["input","forward","output"],
},
{ id: "nat", label: "NAT", icon: , chains: ["srcnat","dstnat"] },
{ id: "mangle", label: "Mangle", icon: , chains: ["prerouting","postrouting","forward","input","output"] },
{ id: "raw", label: "Raw", icon: , chains: ["prerouting","output"] },
{ id: "address-lists", label: "Адр. листы", icon: , chains: [] },
{ id: "simulator", label: "Симулятор", icon: , chains: [] },
]
// ─── Default form ─────────────────────────────────────────────────────────────
const defaultForm = {
chain: "forward", action: "accept", proto: "all",
src: "", dst: "", srcPort: "", dstPort: "", iface: "",
inIface: "", outIface: "", connState: "",
srcAddrList: "", dstAddrList: "",
comment: "", enabled: true,
log: false, logPrefix: "",
}
type RuleForm = typeof defaultForm
function looksLikeAddress(value: string): boolean {
const v = value.trim()
if (!v || v === "—") return false
return v.includes(".") || v.includes(":") || v.includes("/")
}
function tableOfGroup(group: ChainGroup): FirewallTable | null {
if (group === "filter" || group === "nat" || group === "mangle" || group === "raw") return group
return null
}
interface RuleWritePayload {
chain: string
action: string
protocol?: string
srcAddress?: string
dstAddress?: string
srcAddressList?: string
dstAddressList?: string
srcPort?: string
dstPort?: string
inInterface?: string
outInterface?: string
connectionState?: string
comment?: string
disabled?: boolean
log?: boolean
logPrefix?: string
}
function formToWritePayload(form: RuleForm): RuleWritePayload {
const src = form.srcAddrList.trim()
const dst = form.dstAddrList.trim()
return {
chain: form.chain,
action: form.action,
protocol: form.proto,
srcAddress: src && looksLikeAddress(src) ? src : undefined,
srcAddressList: src && !looksLikeAddress(src) ? src : undefined,
dstAddress: dst && looksLikeAddress(dst) ? dst : undefined,
dstAddressList: dst && !looksLikeAddress(dst) ? dst : undefined,
srcPort: form.srcPort.trim() || undefined,
dstPort: form.dstPort.trim() || undefined,
inInterface: form.inIface.trim() || undefined,
outInterface: form.outIface.trim() || undefined,
connectionState: form.connState.trim() || undefined,
comment: form.comment.trim() || undefined,
disabled: !form.enabled,
log: form.log,
logPrefix: form.log && form.logPrefix.trim() ? form.logPrefix.trim() : undefined,
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmtHits(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}М`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}к`
return String(n)
}
// ActionBadge, ChainBadge — из firewall-rules-data-grid
function NativeSelect({ value, onChange, children, className }: {
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
}) {
return (
)
}
// ─── RSC Export generator ─────────────────────────────────────────────────────
function generateRsc(rules: FirewallRule[], timestamp?: string): string {
const lines: string[] = []
lines.push(`# MikrotikManager Firewall Export`)
if (timestamp) lines.push(`# Сгенерировано: ${timestamp}`)
lines.push(`# Правил: ${rules.length}`)
lines.push("")
const byChain: Record = {}
for (const r of rules) {
if (!byChain[r.chain]) byChain[r.chain] = []
byChain[r.chain].push(r)
}
const TABLE_MAP: Record = {
input: "filter", forward: "filter", output: "filter",
srcnat: "nat", dstnat: "nat",
prerouting: "mangle", postrouting: "mangle",
}
for (const [chain, chainRules] of Object.entries(byChain)) {
const table = chainRules[0]?.table ?? TABLE_MAP[chain] ?? "filter"
const familyPath = chainRules[0]?.family === "ip6" ? "/ipv6 firewall" : "/ip firewall"
lines.push(`# ── ${familyPath} ${table} chain=${chain} ──────────────────────────────`)
for (const r of chainRules) {
const parts = [`${familyPath} ${table} add`]
parts.push(`chain=${r.chain}`)
parts.push(`action=${r.action}`)
if (r.proto && r.proto !== "all") parts.push(`protocol=${r.proto}`)
if (r.src && r.src !== "—") parts.push(`src-address-list=${r.src}`)
if (r.dst && r.dst !== "—") parts.push(`dst-address-list=${r.dst}`)
if (r.port && r.port !== "—") parts.push(`dst-port=${r.port}`)
if (r.iface && r.iface !== "—") parts.push(`in-interface=${r.iface}`)
if (r.comment) parts.push(`comment="${r.comment}"`)
if (!r.enabled) parts.push(`disabled=yes`)
lines.push(parts.join(" \\\n "))
lines.push("")
}
}
return lines.join("\n")
}
// ─── Rule Sheet ───────────────────────────────────────────────────────────────
function RuleSheet({ open, onClose, initialRule, chainGroup, family, onSave, saving = false, error, returnFocus }: {
open: boolean
onClose: () => void
initialRule: Partial | null
chainGroup: ChainGroup
family: IpFamily
onSave: (payload: RuleWritePayload) => void
saving?: boolean
error?: string | null
returnFocus?: () => HTMLElement | null
}) {
const isNew = !initialRule?.id
const [form, setForm] = useState(defaultForm)
const [formContext, setFormContext] = useState({ open: false, initialRule, chainGroup })
// Adjust this component before committing children with a stale draft.
if (formContext.open !== open || formContext.initialRule !== initialRule || formContext.chainGroup !== chainGroup) {
setFormContext({ open, initialRule, chainGroup })
if (open) {
setForm({
...defaultForm,
chain: initialRule?.chain ?? (CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains[0] ?? "forward"),
action: (initialRule?.action as string) ?? defaultForm.action,
proto: initialRule?.proto ?? defaultForm.proto,
comment: initialRule?.comment ?? "",
enabled: initialRule?.enabled ?? true,
srcAddrList: initialRule?.src && initialRule.src !== "—" ? initialRule.src : "",
dstAddrList: initialRule?.dst && initialRule.dst !== "—" ? initialRule.dst : "",
dstPort: initialRule?.port && initialRule.port !== "—" ? initialRule.port : "",
inIface: initialRule?.iface && initialRule.iface !== "—" ? initialRule.iface : "",
log: initialRule?.log ?? false,
logPrefix: initialRule?.logPrefix ?? "",
})
}
}
const set = (k: K, v: RuleForm[K]) =>
setForm((f) => ({ ...f, [k]: v }))
const chainsForGroup = CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains ?? ["forward"]
const tableName = tableOfGroup(chainGroup) ?? "filter"
const familyPath = family === "ip6" ? "/ipv6 firewall" : "/ip firewall"
const showNatActions = chainGroup === "nat"
const actions = showNatActions
? ["masquerade", "dst-nat", "src-nat", "netmap", "same", "passthrough"]
: chainGroup === "mangle"
? ["mark-routing", "mark-conn", "mark-packet", "strip-ipv4-options", "passthrough", "add-src-to-address-list", "add-dst-to-address-list"]
: ["accept", "drop", "reject", "fasttrack-connection", "add-src-to-address-list", "add-dst-to-address-list", "passthrough"]
return (
{ if (!v && !saving) onClose() }}>
{isNew ? "Новое правило" : "Редактировать правило"}
{familyPath} {tableName} · {IP_FAMILY_LABELS[family]} · RouterOS 7
{saving && Ожидание результата сохранения…
}
{error && {error}
}
}>Отмена
)
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, onClose, rules }: {
open: boolean; onClose: () => void; rules: FirewallRule[]
}) {
const timestamp = useMemo(
() => open ? new Date().toLocaleString("ru") : undefined,
[open],
)
const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp])
return (
)
}
// ─── Address List tab ─────────────────────────────────────────────────────────
function AddressListsTab({
entries,
onAdd,
onAddToList,
onToggle,
onEdit,
onDelete,
showServer = false,
mutationsDisabled = false,
}: {
entries: AddressListEntry[]
onAdd: () => void
onAddToList: (list: string) => void
onToggle: (entry: AddressListEntry) => void
onEdit: (entry: AddressListEntry) => void
onDelete: (entry: AddressListEntry) => void
showServer?: boolean
mutationsDisabled?: boolean
}) {
const [search, setSearch] = useState("")
const [listFilter, setListFilter] = useState("all")
const lists = useMemo(() => {
const s = new Set(entries.map((e) => e.list))
return ["all", ...Array.from(s)]
}, [entries])
if (listFilter !== "all" && !lists.includes(listFilter)) setListFilter("all")
const filtered = useMemo(() => {
return entries.filter((e) => {
if (listFilter !== "all" && e.list !== listFilter) return false
if (!search) return true
const q = search.toLowerCase()
return e.address.includes(q) || e.list.includes(q) || e.comment.toLowerCase().includes(q)
|| (e.serverName ?? "").toLowerCase().includes(q)
})
}, [entries, search, listFilter])
const grouped = useMemo(() => {
if (listFilter !== "all") return { [listFilter]: filtered }
const g: Record = {}
for (const e of filtered) {
if (!g[e.list]) g[e.list] = []
g[e.list].push(e)
}
return g
}, [filtered, listFilter])
return (
{Object.entries(grouped).map(([listName, listEntries]) => (
{listName}
({listEntries.length})
{listEntries.map((e) => (
{e.address}
{e.comment}
{showServer && e.serverName && (
{e.serverName}
)}
{e.timeout && (
⏱ {e.timeout}
)}
onToggle(e)} />
} />
onEdit(e)}>Редактировать
onToggle(e)}>
{e.disabled ? "Включить" : "Отключить"}
onDelete(e)}>
Удалить
))}
))}
)
}
const defaultAddrForm = {
list: "",
address: "",
comment: "",
timeout: "",
enabled: true,
}
function AddressListSheet({
open, onClose, initial, family, onSave, saving = false, error, returnFocus,
}: {
open: boolean
onClose: () => void
initial: Partial | null
family: IpFamily
onSave: (payload: { list: string; address: string; comment?: string; timeout?: string; disabled?: boolean }) => void
saving?: boolean
error?: string | null
returnFocus?: () => HTMLElement | null
}) {
const isNew = !initial?.id
const [form, setForm] = useState(defaultAddrForm)
const [formContext, setFormContext] = useState({ open: false, initial })
if (formContext.open !== open || formContext.initial !== initial) {
setFormContext({ open, initial })
if (open) {
setForm({
list: initial?.list ?? "",
address: initial?.address ?? "",
comment: initial?.comment ?? "",
timeout: initial?.timeout ?? "",
enabled: !(initial?.disabled ?? false),
})
}
}
return (
{ if (!v && !saving) onClose() }}>
{isNew ? "Запись address-list" : "Редактировать запись"}
{family === "ip6" ? "/ipv6 firewall address-list" : "/ip firewall address-list"} · {IP_FAMILY_LABELS[family]}
{saving && Ожидание результата сохранения…
}
{error && {error}
}
}>Отмена
)
}
// ─── Verdict helpers ──────────────────────────────────────────────────────────
function getVerdictIcon(action: string) {
if (action === "drop" || action === "reject") return
if (action === "fasttrack-connection") return
if (action.startsWith("mark-")) return
return
}
function getVerdictColors(action: string) {
if (action === "drop" || action === "reject")
return { bg: "bg-red-500/10", border: "border-red-500/30", text: "text-red-600 dark:text-red-400" }
if (action === "fasttrack-connection")
return { bg: "bg-violet-500/10", border: "border-violet-500/30", text: "text-violet-600 dark:text-violet-400" }
if (action === "masquerade" || action.endsWith("-nat"))
return { bg: "bg-blue-500/10", border: "border-blue-500/30", text: "text-blue-600 dark:text-blue-400" }
if (action.startsWith("mark-"))
return { bg: "bg-amber-500/10", border: "border-amber-500/30", text: "text-amber-600 dark:text-amber-400" }
return { bg: "bg-emerald-500/10", border: "border-emerald-500/30", text: "text-emerald-600 dark:text-emerald-400" }
}
// ─── SimStepRow ───────────────────────────────────────────────────────────────
function SimStepRow({ step, index }: { step: SimStep; index: number }) {
const [manualExpanded, setExpanded] = useState(false)
// Auto-expand on match/evaluating; user can also toggle manually
const expanded = manualExpanded || step.status === "match" || step.status === "evaluating"
const isClickable = step.checks.length > 0
return (
{/* Expanded checks */}
{expanded && step.checks.length > 0 && (
{step.checks.map((c, ci) => (
{c.passed
?
: }
{c.field}:
{c.ruleVal}
{!c.passed && (
<>
≠
{c.packetVal}
>
)}
))}
)}
)
}
// ─── Scenario constants + helpers ────────────────────────────────────────────
const SCENARIO_STORAGE_KEY = "routerlists-fw-scenarios"
const DEFAULT_SRULE: Omit = {
chain: "forward", action: "accept", proto: "all",
src: "", dst: "", port: "", iface: "", comment: "", enabled: true,
}
function scenarioRuleToFw(r: ScenarioRule): FirewallRule {
return { ...r, hits: 0, action: r.action as FirewallRule["action"] }
}
// ─── Scenario Sheet ───────────────────────────────────────────────────────────
function ScenarioSheet({ open, onClose, initial, onSave }: {
open: boolean
onClose: () => void
initial: SimScenario | null
onSave: (s: SimScenario) => void
}) {
const isNew = !initial
const [name, setName] = useState(initial?.name ?? "")
const [desc, setDesc] = useState(initial?.description ?? "")
const [pkt, setPkt2] = useState(initial?.packet ?? DEFAULT_PKT)
const [rules, setRules] = useState(initial?.rules ?? [])
const [addOpen, setAddOpen]= useState(false)
const [addForm, setAddForm]= useState>(DEFAULT_SRULE)
// Reset form fields each time the sheet opens — standard modal initialization pattern
useEffect(() => {
if (!open) return
// eslint-disable-next-line react-hooks/set-state-in-effect
setName(initial?.name ?? ""); setDesc(initial?.description ?? "")
setPkt2(initial?.packet ?? DEFAULT_PKT); setRules(initial?.rules ?? [])
setAddForm(DEFAULT_SRULE); setAddOpen(false)
}, [open, initial])
const setP = (k: K, v: PacketDef[K]) =>
setPkt2(p => ({ ...p, [k]: v }))
const setAF = >(k: K, v: Omit[K]) =>
setAddForm(f => ({ ...f, [k]: v }))
function addRule() {
setRules(prev => [...prev, { ...addForm, id: `sr-${Date.now()}` }])
setAddForm(DEFAULT_SRULE)
setAddOpen(false)
}
function removeRule(id: string) { setRules(prev => prev.filter(r => r.id !== id)) }
function toggleEnabled(id: string) {
setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r))
}
function moveRule(id: string, dir: -1 | 1) {
setRules(prev => {
const idx = prev.findIndex(r => r.id === id)
const swap = idx + dir
if (idx < 0 || swap < 0 || swap >= prev.length) return prev
const next = [...prev];
[next[idx], next[swap]] = [next[swap], next[idx]]
return next
})
}
function handleSave() {
if (!name.trim()) return
onSave({
id: initial?.id ?? `scenario-${Date.now()}`,
name: name.trim(), description: desc.trim(),
packet: pkt, rules,
createdAt: initial?.createdAt ?? Date.now(),
})
onClose()
}
const CHAIN_OPTS = ["forward","input","output","dstnat","srcnat","prerouting","postrouting"]
const PROTO_OPTS = ["all","tcp","udp","icmp","gre","esp"]
const ACTION_OPTS = [
"accept","drop","reject","mark-routing","mark-conn","masquerade",
"fasttrack-connection","passthrough","add-src-to-address-list","add-dst-to-address-list",
]
return (
{ if (!v) onClose() }}>
{isNew ? "Новый сценарий" : `Редактировать: ${initial?.name}`}
Набор правил firewall/mangle/nat + тестовый пакет
{/* Meta */}
{/* Default packet */}
Тестовый пакет по умолчанию
setP("chain", v)}>
setP("proto", v)}>
{PROTO_OPTS.map(p => )}
setP("connState", e.target.value)} />
setP("srcAddr", e.target.value)} />
setP("dstAddr", e.target.value)} />
setP("dstPort", e.target.value)} />
setP("inIface", e.target.value)} />
setP("outIface", e.target.value)} />
setP("dstAddrList", e.target.value)} />
{/* Rules */}
Правила сценария
({rules.length})
{/* Add-rule form */}
{addOpen && (
Новое правило
setAF("enabled", v)} />
Включено
)}
{/* Rules table */}
{rules.length > 0 ? (
moveRule(id, -1)}
onMoveDown={(id) => moveRule(id, 1)}
onRemove={removeRule}
/>
) : (
!addOpen && (
Нет правил — нажмите «Добавить правило»
)
)}
}>Отмена
)
}
// ─── Simulator Tab ────────────────────────────────────────────────────────────
const SIM_SPEED_MS: Record = { slow: 700, normal: 350, fast: 120, instant: 0 }
const SIM_SPEED_LABELS: Record = {
slow: "Медленно", normal: "Нормально", fast: "Быстро", instant: "Мгновенно",
}
function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
// ── Core sim state ──────────────────────────────────────────────────────────
const [packet, setPacket] = useState(DEFAULT_PKT)
const [activePreset, setPreset] = useState("https")
const [simState, setSimState] = useState<"idle" | "running" | "done">("idle")
const [steps, setSteps] = useState([])
const [tracePage, setTracePage] = useState(0)
const [speed, setSpeed] = useState("normal")
const [verdict, setVerdict] = useState<{ action: string; ruleIndex: number | null } | null>(null)
const cancelRef = useRef(false)
const runEpoch = useRef(0)
useEffect(() => () => { runEpoch.current += 1; cancelRef.current = true }, [])
// ── Scenarios state ─────────────────────────────────────────────────────────
const [scenarios, setScenarios] = useState(() => {
if (typeof window === "undefined") return []
try { return JSON.parse(localStorage.getItem(SCENARIO_STORAGE_KEY) ?? "[]") }
catch { return [] }
})
const [activeScenarioId, setActiveScenarioId] = useState(null)
const [scenarioSheetOpen, setScenarioSheetOpen] = useState(false)
const [editingScenario, setEditingScenario] = useState(null)
useEffect(() => {
localStorage.setItem(SCENARIO_STORAGE_KEY, JSON.stringify(scenarios))
}, [scenarios])
const activeScenario = scenarios.find(s => s.id === activeScenarioId) ?? null
// Live rules vs scenario rules
const effectiveRules: FirewallRule[] = useMemo(
() => activeScenario ? activeScenario.rules.map(scenarioRuleToFw) : allRules,
[activeScenario, allRules],
)
const setPkt = (k: K, v: PacketDef[K]) =>
setPacket(p => ({ ...p, [k]: v }))
// All rules in traversal order across every chain the packet touches
const simRules = useMemo(() => {
const path = DIR_PATHS[packet.chain] ?? [packet.chain]
return path.flatMap(chain => effectiveRules.filter(r => r.chain === chain))
}, [effectiveRules, packet.chain])
// ── Handlers ────────────────────────────────────────────────────────────────
function resetSim() {
runEpoch.current += 1
cancelRef.current = true
setSimState("idle")
setSteps([])
setTracePage(0)
setVerdict(null)
}
function applyPreset(p: typeof SIM_PRESETS[number]) {
setActiveScenarioId(null)
setPreset(p.id)
setPacket({ ...DEFAULT_PKT, ...p.pkt })
runEpoch.current += 1
cancelRef.current = true
setSimState("idle")
setSteps([])
setTracePage(0)
setVerdict(null)
}
function activateScenario(id: string | null) {
setActiveScenarioId(id)
setPreset(null)
runEpoch.current += 1
cancelRef.current = true
setSimState("idle")
setSteps([])
setTracePage(0)
setVerdict(null)
if (id) {
const sc = scenarios.find(s => s.id === id)
if (sc) setPacket(sc.packet)
}
}
function saveScenario(sc: SimScenario) {
setScenarios(prev => {
const idx = prev.findIndex(x => x.id === sc.id)
if (idx >= 0) { const next = [...prev]; next[idx] = sc; return next }
return [...prev, sc]
})
activateScenario(sc.id)
}
function deleteScenario(id: string) {
setScenarios(prev => prev.filter(s => s.id !== id))
if (activeScenarioId === id) { setActiveScenarioId(null); resetSim() }
}
async function runSimulation() {
const path = DIR_PATHS[packet.chain] ?? [packet.chain]
const rules = path.flatMap(chain => effectiveRules.filter(r => r.chain === chain))
if (rules.length === 0) {
setVerdict({ action: "accept (default)", ruleIndex: null })
setSimState("done")
return
}
const epoch = ++runEpoch.current
cancelRef.current = false
setTracePage(0)
setSimState("running")
setVerdict(null)
setSteps(rules.map((r, i) => ({ rule: r, index: i, status: "pending", checks: [] })))
const delay = SIM_SPEED_MS[speed]
let finalVerdict: { action: string; ruleIndex: number | null } | null = null
for (let i = 0; i < rules.length; i++) {
if (cancelRef.current || epoch !== runEpoch.current) return
setSteps(prev => prev.map((s, idx) => idx === i ? { ...s, status: "evaluating" } : s))
if (delay > 0) await new Promise(res => setTimeout(res, delay))
if (cancelRef.current || epoch !== runEpoch.current) return
const result = evalRule(rules[i], packet)
setSteps(prev => prev.map((s, idx) =>
idx === i ? { ...s, status: result.verdict, checks: result.checks, failedAt: result.failedAt } : s,
))
if (result.verdict === "match") {
finalVerdict = { action: rules[i].action, ruleIndex: i }
if (delay > 0) await new Promise(res => setTimeout(res, Math.max(delay * 0.5, 200)))
break
}
if (delay > 0) await new Promise(res => setTimeout(res, Math.max(delay * 0.25, 40)))
}
if (!cancelRef.current && epoch === runEpoch.current) {
setVerdict(finalVerdict ?? { action: "accept (default)", ruleIndex: null })
setSimState("done")
}
}
const tracePageCount = Math.max(1, Math.ceil(steps.length / 50))
const currentTracePage = Math.min(tracePage, tracePageCount - 1)
const traceOffset = currentTracePage * 50
const traceChainCounts = useMemo(() => {
const counts = new Map()
for (const step of steps) counts.set(step.rule.chain, (counts.get(step.rule.chain) ?? 0) + 1)
return counts
}, [steps])
const verdictColors = verdict ? getVerdictColors(verdict.action) : null
return (
{/* ── Presets + Scenarios card ─────────────────────────────────────────── */}
{SIM_PRESETS.map(p => (
))}
{/* Saved scenarios */}
Мои сценарии
{scenarios.length === 0 ? (
Нет сохранённых сценариев — создайте набор правил firewall/mangle/nat для тестирования
) : (
{scenarios.map(sc => {
const isActive = activeScenarioId === sc.id
return (
{sc.name}
{sc.rules.length} правил · {sc.packet.chain}
{sc.description ? ` · ${sc.description}` : ""}
)
})}
)}
{/* ── Packet editor ───────────────────────────────────────────────────── */}
{activeScenario ? `🎭 ${activeScenario.name}` : "🔴 Живые правила"}
{simRules.length} правил ·{" "}
{packet.chain}
{DIR_PATHS[packet.chain] && (
{DIR_PATHS[packet.chain].join(" → ")}
)}
{/* Row 1 */}
{ setPkt("chain", v); resetSim() }}>
setPkt("proto", v)}>
{["tcp","udp","icmp","gre","esp","ah"].map(p => )}
setPkt("srcAddr", e.target.value)} />
setPkt("dstAddr", e.target.value)} />
setPkt("srcPort", e.target.value)} />
setPkt("dstPort", e.target.value)} />
{/* Row 2 */}
{/* Speed + Run controls */}
{(["slow","normal","fast","instant"] as SimSpeed[]).map(s => (
))}
{simState === "running" ? (
) : (
)}
{/* ── Verdict banner ──────────────────────────────────────────────────── */}
{verdict && simState === "done" && verdictColors && (
{getVerdictIcon(verdict.action)}
{verdict.action === "drop" || verdict.action === "reject"
? "Пакет заблокирован"
: verdict.ruleIndex === null ? "Пакет принят (по умолчанию)" : "Пакет принят"}
{verdict.ruleIndex !== null && (
— правило #{verdict.ruleIndex + 1}
)}
Действие: {verdict.action}
{verdict.ruleIndex === null && · ни одно правило не совпало}
)}
{/* ── Trace list ──────────────────────────────────────────────────────── */}
{steps.length > 0 && (
Трассировка · цепочка: {packet.chain}
{activeScenario && (
· {activeScenario.name}
)}
{steps.filter(s => s.status === "match").length} совпало
{" · "}{steps.filter(s => s.status === "skip").length} пропущено
{" · "}{steps.filter(s => s.status === "disabled").length} выкл
{steps.slice(traceOffset, traceOffset + 50).map((step, pageIndex) => {
const i = traceOffset + pageIndex
const prevChain = i > 0 ? steps[i - 1].rule.chain : null
const showHeader = pageIndex === 0 || step.rule.chain !== prevChain
return (
{showHeader && (
{step.rule.chain}
{CHAIN_TABLE_HINT[step.rule.chain] && (
· {CHAIN_TABLE_HINT[step.rule.chain]}
)}
{traceChainCounts.get(step.rule.chain)} правил
)}
)
})}
Шаги {traceOffset + 1}–{Math.min(traceOffset + 50, steps.length)} из {steps.length}
{verdict?.ruleIndex != null && }
)}
{/* ── Empty state ─────────────────────────────────────────────────────── */}
{simState === "idle" && simRules.length === 0 && (
Нет правил в цепочке «{packet.chain}»
{activeScenario
? "Добавьте правила в сценарий через кнопку «Редактировать»"
: "Выберите другой пресет или измените цепочку"}
)}
{/* ── Scenario Sheet ───────────────────────────────────────────────────── */}
setScenarioSheetOpen(false)}
initial={editingScenario}
onSave={saveScenario}
/>
)
}
// ─── Live API ─────────────────────────────────────────────────────────────────
function makeApiFetch(backendUrl: string) {
return async function apiFetch(path: string, init?: RequestInit): Promise {
return requestJson(backendUrl, path, init)
}
}
function mapBackendToServer(s: FirewallBackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site,
country: s.country || "UN",
asn: s.asn,
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}
}
function ruleFamily(r: Pick): IpFamily {
return r.family === "ip6" ? "ip6" : "ip"
}
function addressFamily(e: Pick): IpFamily {
return e.family === "ip6" ? "ip6" : "ip"
}
function ruleKey(r: FirewallRule) {
if (!r.serverId || !r.rosId || !r.table) return null
return { serverId: r.serverId, family: ruleFamily(r), table: r.table, rosId: r.rosId }
}
function sameRuleGroup(a: FirewallRule, b: FirewallRule): boolean {
return (
a.serverId === b.serverId
&& ruleFamily(a) === ruleFamily(b)
&& (a.table ?? "filter") === (b.table ?? "filter")
)
}
function moveInArray(arr: T[], from: number, to: number): T[] {
if (from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr
const copy = [...arr]
const [moved] = copy.splice(from, 1)
if (moved === undefined) return arr
copy.splice(to, 0, moved)
return copy
}
function reorderWithinGroup(list: FirewallRule[], activeId: string, overId: string): FirewallRule[] | null {
const active = list.find((r) => r.id === activeId)
const over = list.find((r) => r.id === overId)
if (!active || !over || !sameRuleGroup(active, over)) return null
const same = (r: FirewallRule) => sameRuleGroup(r, active)
const group = list.filter(same)
const from = group.findIndex((r) => r.id === activeId)
const to = group.findIndex((r) => r.id === overId)
if (from < 0 || to < 0 || from === to) return null
const nextGroup = moveInArray(group, from, to)
let i = 0
return list.map((r) => (same(r) ? nextGroup[i++]! : r))
}
/** RouterOS move: insert-before destination. Omit dest = конец таблицы. */
function rosMoveDestination(list: FirewallRule[], activeId: string, overId: string): { dest?: string } | null {
const active = list.find((r) => r.id === activeId)
const over = list.find((r) => r.id === overId)
if (!active || !over || !sameRuleGroup(active, over)) return null
const group = list.filter((r) => sameRuleGroup(r, active))
const from = group.findIndex((r) => r.id === activeId)
const to = group.findIndex((r) => r.id === overId)
if (from < 0 || to < 0 || from === to) return null
if (from < to) {
const dest = group[to + 1]?.rosId
return dest ? { dest } : {}
}
const dest = group[to]?.rosId
return dest ? { dest } : {}
}
function addressKey(e: AddressListEntry) {
if (!e.serverId || !e.rosId) return null
return { serverId: e.serverId, family: addressFamily(e), rosId: e.rosId }
}
function payloadToMockRule(
payload: RuleWritePayload,
opts: { id: string; table: FirewallTable; family: IpFamily; serverId: string; serverName: string; rosId: string },
): FirewallRule {
return {
id: opts.id,
table: opts.table,
family: opts.family,
serverId: opts.serverId,
serverName: opts.serverName,
rosId: opts.rosId,
chain: payload.chain,
action: payload.action,
proto: payload.protocol ?? "all",
src: payload.srcAddress ?? payload.srcAddressList ?? "—",
dst: payload.dstAddress ?? payload.dstAddressList ?? "—",
port: payload.dstPort ?? payload.srcPort ?? "—",
iface: payload.inInterface ?? payload.outInterface ?? "—",
comment: payload.comment ?? "",
enabled: !payload.disabled,
hits: 0,
log: payload.log,
logPrefix: payload.logPrefix,
}
}
// ════════════════════════════════════════════════════════════════════════════
function FirewallPageInner() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const searchParams = useSearchParams()
const serverFromQuery = searchParams.get("server")
const [liveServers, setLiveServers] = useState([])
const [liveRules, setLiveRules] = useState([])
const [liveAddrLists, setLiveAddrLists] = useState([])
const [mockRules, setMockRules] = useState(firewallRules)
const [mockAddrLists, setMockAddrLists] = useState(INIT_ADDRESS_LISTS)
const [dataLoading, setDataLoading] = useState(false)
const [dataError, setDataError] = useState(null)
const [hasLoaded, setHasLoaded] = useState(false)
const [readTrusted, setReadTrusted] = useState(false)
const [sliceTimes, setSliceTimes] = useState([])
const [failures, setFailures] = useState([])
const loadEpoch = useRef(0)
useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive])
const [busy, setBusy] = useState(false)
const [formError, setFormError] = useState(null)
const mutationLock = useRef(false)
const [togglePending, setTogglePending] = useState(false)
const [movePending, setMovePending] = useState(false)
const [moveStatus, setMoveStatus] = useState(null)
const [moveError, setMoveError] = useState(null)
const [toggleStatus, setToggleStatus] = useState(null)
const [toggleError, setToggleError] = useState(null)
const [pendingDelete, setPendingDelete] = useState<{ kind: "rule"; item: FirewallRule } | { kind: "address"; item: AddressListEntry } | null>(null)
const [deletePending, setDeletePending] = useState(false)
const [deleteError, setDeleteError] = useState(null)
const [selectedServerId, setSelectedServerId] = useState(serverFromQuery ?? ALL_SERVERS_ID)
const [queryApplied, setQueryApplied] = useState(false)
const [chainGroup, setChainGroup] = useState("filter")
const [chainFilter, setChainFilter] = useState("all")
const [ipFamily, setIpFamily] = useState("ip")
const [search, setSearch] = useState("")
const [editingRule, setEditingRule] = useState | null>(null)
const [ruleSheetOpen, setRuleSheetOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [editingAddr, setEditingAddr] = useState | null>(null)
const [addrSheetOpen, setAddrSheetOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState(null)
const historyEpoch = useRef(0)
const [historyRestoring, setHistoryRestoring] = useState(false)
const loadLive = useCallback(async () => {
if (!isLive || !mounted.current) return
const epoch = ++loadEpoch.current
setDataLoading(true)
setDataError(null)
try {
const [backendServers, fw] = await Promise.all([
apiFetch("/api/servers"),
apiFetch("/api/firewall/all"),
])
if (epoch !== loadEpoch.current) return
const servers = firewallServersSchema.safeParse(backendServers)
const firewall = firewallResponseSchema.safeParse(fw)
if (!servers.success || !firewall.success) throw new Error("Сервер вернул некорректные данные Firewall")
setLiveServers(servers.data.map(mapBackendToServer))
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)
const receivedAt = new Date().toISOString()
setSliceTimes(previous => updateFirewallSliceTimes(previous, [...enabledIds], firewall.data.failures, receivedAt))
setHasLoaded(true)
setReadTrusted(true)
} catch (e) {
if (epoch !== loadEpoch.current) return
setReadTrusted(false)
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
} finally {
if (epoch === loadEpoch.current) setDataLoading(false)
}
}, [isLive, apiFetch])
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
const loadRevisions = useCallback(async () => {
if (!isLive || !historyServerId || !mounted.current) return
const epoch = ++historyEpoch.current
setHistoryLoading(true)
setHistoryError(null)
try {
const res = await apiFetch(
`/api/firewall/revisions?serverId=${encodeURIComponent(historyServerId)}`,
)
if (epoch !== historyEpoch.current) return
setRevisions(parseConfigRevisions(res, historyServerId, "firewall"))
} catch (error) {
if (epoch !== historyEpoch.current) return
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
} finally {
if (epoch === historyEpoch.current) setHistoryLoading(false)
}
}, [isLive, historyServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение конфигурации")
if (!isLive || !historyServerId) throw new Error("Выберите устройство в режиме Live")
if (mutationLock.current) throw new Error("Дождитесь завершения текущей операции Firewall")
if (!readTrusted) throw new Error("Обновите актуальные данные Firewall перед восстановлением")
if (failures.some(f => f.serverId === historyServerId)) throw new Error("Обновите полный набор данных устройства перед восстановлением")
mutationLock.current = true
loadEpoch.current += 1
setDataLoading(false)
setHistoryRestoring(true)
try {
const res = await apiFetch<{ ok: boolean }>(
`/api/firewall/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
)
if (!mounted.current) return
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
} catch (err) {
throw err
} finally {
mutationLock.current = false
setHistoryRestoring(false)
}
}, [isLive, historyServerId, apiFetch, loadLive, loadRevisions, failures, readTrusted])
useEffect(() => {
queueMicrotask(() => {
setLiveServers([])
setLiveRules([])
setLiveAddrLists([])
setHasLoaded(false)
setSliceTimes([])
setFailures([])
setDataError(null)
setDataLoading(false)
if (isLive) void loadLive()
})
}, [isLive, loadLive])
useEffect(() => {
if (dataError) toast.error(dataError)
}, [dataError])
const displayRules = isLive ? liveRules : mockRules
const displayAddrLists = isLive ? liveAddrLists : mockAddrLists
const displayServers = isLive ? liveServers : mockServers
if (!queryApplied && serverFromQuery && displayServers.length > 0) {
if (displayServers.some(server => server.id === serverFromQuery)) setSelectedServerId(serverFromQuery)
setQueryApplied(true)
}
const scopedRules = useMemo(() => {
if (selectedServerId === ALL_SERVERS_ID) return displayRules
return displayRules.filter((r) => r.serverId === selectedServerId)
}, [displayRules, selectedServerId])
const scopedAddrLists = useMemo(() => {
if (selectedServerId === ALL_SERVERS_ID) return displayAddrLists
return displayAddrLists.filter((e) => e.serverId === selectedServerId)
}, [displayAddrLists, selectedServerId])
const familyRules = useMemo(
() => scopedRules.filter((r) => ruleFamily(r) === ipFamily),
[scopedRules, ipFamily],
)
const familyAddrLists = useMemo(
() => scopedAddrLists.filter((e) => addressFamily(e) === ipFamily),
[scopedAddrLists, ipFamily],
)
const firewallRailItems = useMemo(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayRules.filter((r) => r.serverId === s.id).length),
}))
), [displayServers, displayRules])
const writeServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
const writeServer = displayServers.find((s) => s.id === writeServerId)
const chainsInGroup = useMemo(() => {
if (chainGroup === "address-lists" || chainGroup === "simulator") return []
return CHAIN_GROUPS.find((g) => g.id === chainGroup)?.chains ?? []
}, [chainGroup])
const groupRules = useMemo(() => {
const table = tableOfGroup(chainGroup)
if (!table) return []
return familyRules.filter((r) => r.table === table)
}, [familyRules, chainGroup])
const filteredRules = useMemo(() => {
return groupRules.filter((r) => {
if (chainFilter !== "all" && r.chain !== chainFilter) return false
if (!search) return true
const q = search.toLowerCase()
return (
r.chain.toLowerCase().includes(q) ||
r.action.toLowerCase().includes(q) ||
r.src.toLowerCase().includes(q) ||
r.dst.toLowerCase().includes(q) ||
r.comment.toLowerCase().includes(q) ||
(r.serverName ?? "").toLowerCase().includes(q)
)
})
}, [groupRules, chainFilter, search])
const chainCounts = useMemo(() => {
const counts: Record = { all: groupRules.length }
for (const r of groupRules) {
counts[r.chain] = (counts[r.chain] ?? 0) + 1
}
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 firewallWriteBlocked = (isLive && !can("mm:network:write")) || firewallIncomplete || (isLive && !readTrusted)
const scopedSliceTimes = sliceTimes.filter(slice => (selectedServerId === ALL_SERVERS_ID || slice.serverId === selectedServerId) && slice.family === ipFamily)
const freshness = firewallTimeRange(scopedSliceTimes)
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
const showServerCol = selectedServerId === ALL_SERVERS_ID
function requireWriteServer(): string | null {
if (writeServerId) return writeServerId
toast.info("Выберите сервер в панели слева")
return null
}
async function toggleRule(id: string) {
const r = displayRules.find((x) => x.id === id)
if (!r || mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
if (isLive) {
const key = ruleKey(r)
if (!key) return
mutationLock.current = true
loadEpoch.current += 1
setDataLoading(false)
setTogglePending(true)
setToggleError(null)
setToggleStatus(`Ожидание изменения правила ${r.comment || r.rosId || r.id}…`)
try {
const result = await apiFetch<{ ok: boolean }>("/api/firewall/rules", {
method: "PATCH",
body: JSON.stringify({ ...key, disabled: r.enabled }),
})
if (!mounted.current) return
if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение правила")
await loadLive()
} catch (e) {
setToggleError(`${r.comment || r.rosId || r.id}: ${e instanceof Error ? e.message : "Не удалось переключить правило"}`)
} finally {
mutationLock.current = false
setTogglePending(false)
setToggleStatus(null)
}
return
}
setMockRules((rs) => rs.map((x) => x.id === id ? { ...x, enabled: !x.enabled } : x))
}
function requestDelete(target: NonNullable) {
if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
setDeleteError(null)
setPendingDelete(target)
}
async function confirmDelete() {
if (!pendingDelete || mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
const target = pendingDelete
mutationLock.current = true
loadEpoch.current += 1
setDataLoading(false)
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 (!mounted.current) return
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
await loadLive()
} 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))
}
if (!mounted.current) return
setPendingDelete(null)
toast.success(target.kind === "rule" ? "Правило удалено" : "Запись удалена")
} catch (e) {
setDeleteError(e instanceof Error ? e.message : "Не удалось удалить объект")
} finally {
mutationLock.current = false
setDeletePending(false)
}
}
const canReorder = useMemo(() => {
if (filteredRules.length < 2) return false
const servers = new Set(filteredRules.map((r) => r.serverId).filter(Boolean))
return servers.size === 1
}, [filteredRules])
async function reorderRules(activeId: string, overId: string) {
if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
const source = isLive ? liveRules : mockRules
const next = reorderWithinGroup(source, activeId, overId)
if (!next) {
toast.info("Правила можно переставлять только в пределах одного сервера и таблицы")
return
}
if (!isLive) {
setMockRules(next)
return
}
const active = source.find((r) => r.id === activeId)
const key = active ? ruleKey(active) : null
if (!key) {
toast.error("У правила нет идентификатора RouterOS")
return
}
const dest = rosMoveDestination(source, activeId, overId)
if (!dest) return
const prev = source
mutationLock.current = true
loadEpoch.current += 1
setDataLoading(false)
setMovePending(true)
setMoveError(null)
setMoveStatus("Ожидание перемещения правила " + (active?.comment || active?.rosId || activeId) + "…")
setLiveRules(next)
try {
const result = await apiFetch<{ ok: boolean }>("/api/firewall/rules/move", {
method: "POST",
body: JSON.stringify({
...key,
...(dest.dest ? { destinationRosId: dest.dest } : {}),
}),
})
if (!mounted.current) return
if (result?.ok !== true) throw new Error("Сервер не подтвердил перемещение правила")
await loadLive()
} catch (e) {
setLiveRules(prev)
setMoveError((active?.comment || active?.rosId || activeId) + ": " + (e instanceof Error ? e.message : "Не удалось переместить правило"))
} finally {
mutationLock.current = false
setMovePending(false)
setMoveStatus(null)
}
}
function openAdd() {
if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
setFormError(null)
if (!requireWriteServer()) return
const table = tableOfGroup(chainGroup)
if (!table) {
setChainGroup("filter")
}
setEditingRule(null)
setRuleSheetOpen(true)
}
function openEdit(r: FirewallRule) {
if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
setFormError(null)
setEditingRule(r)
setRuleSheetOpen(true)
}
async function saveRule(payload: RuleWritePayload) {
if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
const table = (editingRule?.table ?? tableOfGroup(chainGroup) ?? "filter") as FirewallTable
const family = editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily
const serverId = editingRule?.serverId ?? requireWriteServer()
if (!serverId) return
const serverName = editingRule?.serverName
?? displayServers.find((s) => s.id === serverId)?.name
?? writeServer?.name
?? serverId
if (isLive) {
mutationLock.current = true
loadEpoch.current += 1
setDataLoading(false)
setBusy(true)
setFormError(null)
try {
if (editingRule?.rosId) {
const result = await apiFetch<{ ok: boolean }>("/api/firewall/rules", {
method: "PUT",
body: JSON.stringify({ serverId, family, table, rosId: editingRule.rosId, ...payload }),
})
if (!mounted.current) return
if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение")
toast.success("Правило сохранено")
} else {
const result = await apiFetch<{ ok: boolean }>("/api/firewall/rules", {
method: "POST",
body: JSON.stringify({ serverId, family, table, ...payload }),
})
if (!mounted.current) return
if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение")
toast.success("Правило создано")
}
setRuleSheetOpen(false)
await loadLive()
} catch (e) {
setFormError(e instanceof Error ? e.message : "Не удалось сохранить правило")
} finally {
mutationLock.current = false
setBusy(false)
}
return
}
if (editingRule?.id) {
setMockRules((rs) => rs.map((x) => x.id === editingRule.id
? { ...x, ...payloadToMockRule(payload, {
id: x.id,
table,
family,
serverId,
serverName,
rosId: x.rosId ?? "*0",
}) }
: x))
} else {
const id = `fw-${Date.now()}`
setMockRules((rs) => [...rs, payloadToMockRule(payload, {
id,
table,
family,
serverId,
serverName,
rosId: `*${Date.now()}`,
})])
}
setRuleSheetOpen(false)
}
function openAddAddr(list?: string) {
if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
setFormError(null)
if (!requireWriteServer()) return
setEditingAddr(list ? { list } : null)
setAddrSheetOpen(true)
}
function openEditAddr(e: AddressListEntry) {
if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
setFormError(null)
setEditingAddr(e)
setAddrSheetOpen(true)
}
async function toggleAddr(e: AddressListEntry) {
if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
if (isLive) {
const key = addressKey(e)
if (!key) return
mutationLock.current = true
loadEpoch.current += 1
setDataLoading(false)
setTogglePending(true)
setToggleError(null)
setToggleStatus(`Ожидание изменения адреса ${e.address} (${e.list})…`)
try {
const result = await apiFetch<{ ok: boolean }>("/api/firewall/address-lists", {
method: "PATCH",
body: JSON.stringify({ ...key, disabled: !e.disabled }),
})
if (!mounted.current) return
if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение адреса")
await loadLive()
} catch (err) {
setToggleError(`${e.address} (${e.list}): ${err instanceof Error ? err.message : "Не удалось переключить запись"}`)
} finally {
mutationLock.current = false
setTogglePending(false)
setToggleStatus(null)
}
return
}
setMockAddrLists((rows) => rows.map((x) => x.id === e.id ? { ...x, disabled: !x.disabled } : x))
}
async function saveAddr(payload: {
list: string
address: string
comment?: string
timeout?: string
disabled?: boolean
}) {
if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
const family = editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily
const serverId = editingAddr?.serverId ?? requireWriteServer()
if (!serverId) return
const serverName = editingAddr?.serverName
?? displayServers.find((s) => s.id === serverId)?.name
?? writeServer?.name
?? serverId
if (isLive) {
mutationLock.current = true
loadEpoch.current += 1
setDataLoading(false)
setBusy(true)
setFormError(null)
try {
if (editingAddr?.rosId) {
const result = await apiFetch<{ ok: boolean }>("/api/firewall/address-lists", {
method: "PUT",
body: JSON.stringify({ serverId, family, rosId: editingAddr.rosId, ...payload }),
})
if (!mounted.current) return
if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение")
toast.success("Запись сохранена")
} else {
const result = await apiFetch<{ ok: boolean }>("/api/firewall/address-lists", {
method: "POST",
body: JSON.stringify({ serverId, family, ...payload }),
})
if (!mounted.current) return
if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение")
toast.success("Запись создана")
}
setAddrSheetOpen(false)
await loadLive()
} catch (err) {
setFormError(err instanceof Error ? err.message : "Не удалось сохранить запись")
} finally {
mutationLock.current = false
setBusy(false)
}
return
}
if (editingAddr?.id) {
setMockAddrLists((rows) => rows.map((x) => x.id === editingAddr.id
? { ...x, ...payload, family, serverId, serverName }
: x))
} else {
setMockAddrLists((rows) => [...rows, {
id: `al-${Date.now()}`,
list: payload.list,
address: payload.address,
comment: payload.comment ?? "",
timeout: payload.timeout,
disabled: payload.disabled ?? false,
family,
serverId,
serverName,
rosId: `*${Date.now()}`,
}])
}
setAddrSheetOpen(false)
}
const sheetChainGroup: ChainGroup =
chainGroup === "address-lists" || chainGroup === "simulator" ? "filter" : chainGroup
return (
<>
Режим просмотра. Изменение конфигурации недоступно.
: undefined}
header={
>
}
/>
}
>
{dataError && isLive && (
{dataError}
{hasLoaded && Показаны последние успешно загруженные данные.}
)}
{isLive && hasLoaded && !readTrusted &&
Изменения Firewall заблокированы до успешного чтения актуальных данных. Последний список сохранён.
}
{ setIpFamily(v); setChainFilter("all") }}
options={(["ip", "ip6"] as IpFamily[]).map((f) => ({
value: f,
label: IP_FAMILY_LABELS[f],
}))}
/>
{ipFamily === "ip6" ? "/ipv6 firewall" : "/ip firewall"}
{isLive &&
{dataLoading &&
{hasLoaded ? "Обновление данных Firewall…" : "Загрузка данных Firewall…"}
}
{freshness.oldest ? <>Получено в приложении:
{freshness.newest !== freshness.oldest && <> — >}
> : hasLoaded && liveServers.every(server => !server.enabled) ? "Нет включённых устройств для опроса" : "Время получения срезов неизвестно"}
{freshness.unknown > 0 && <> · Не получено срезов: {freshness.unknown}>}
}
{firewallIncomplete &&
Не все срезы Firewall получены
Сохранены последние данные недоступных срезов; их состояние неизвестно. Обновите данные перед изменениями.
{scopedFailures.map((failure, index) => {
const range = firewallTimeRange(scopedSliceTimes.filter(slice => slice.serverId === failure.serverId && (!failure.family || slice.family === failure.family) && (!failure.table || slice.table === failure.table)))
return
{[failure.serverName || failure.serverId, failure.family, failure.table, failure.error].filter(Boolean).join(" · ")} · Последние данные: {range.oldest
? <>
{range.newest !== range.oldest && <> — >}
> : "ещё не получены"}{range.unknown > 0 && <> · Не получено срезов: {range.unknown}>}
})}
}
{moveStatus &&
{moveStatus}
}
{moveError &&
{moveError}
}
{toggleStatus &&
{toggleStatus}
}
{toggleError &&
{toggleError}
}
,
iconClassName: "text-muted-foreground",
},
{
id: "enabled",
label: "Активных",
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : totalEnabled,
icon:
,
iconClassName: "text-success",
},
{
id: "drop",
label: "Блокирующих",
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : dropRules,
icon:
,
iconClassName: "text-destructive",
},
{
id: "hits",
label: "Срабатываний",
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : fmtHits(totalHits),
icon:
,
iconClassName: "text-info",
},
]}
/>
{CHAIN_GROUPS.map((g) => (
))}
{isLive && (!hasLoaded || (firewallIncomplete && (chainGroup === "address-lists" ? familyAddrLists.length === 0 : filteredRules.length === 0))) ? (
{dataLoading ? "Загрузка данных Firewall…" : "Данные Firewall пока недоступны. Повторите загрузку."}
) : chainGroup === "address-lists" ? (
openAddAddr()}
onAddToList={(list) => openAddAddr(list)}
onToggle={(e) => { void toggleAddr(e) }}
onEdit={openEditAddr}
onDelete={(item) => requestDelete({ kind: "address", item })}
showServer={showServerCol}
/>
) : chainGroup === "simulator" ? (
) : (
({
value: c,
label: c,
count: chainCounts[c] ?? 0,
})),
]}
/>
setSearch(e.target.value)}
/>
{filteredRules.length} правил
{ void toggleRule(id) }}
onEdit={openEdit}
onDelete={(item) => requestDelete({ kind: "rule", item })}
onReorder={canReorder && !togglePending && !deletePending && !movePending && !busy && !historyRestoring && !firewallWriteBlocked ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
showServer={showServerCol}
/>
)}
{[
{
title: "/ip firewall filter",
lines: ["input — трафик на роутер", "forward — транзит", "output — от роутера"],
},
{
title: "/ipv6 firewall filter",
lines: ["input / forward / output", "без префикса ip6-", "семейство — отдельный сегмент"],
},
{
title: "Новые матчеры 7.x",
lines: [
"tls-host=*.google.com",
"connection-rate=100/s",
"layer7-protocol=...",
],
},
{
title: "Новые действия 7.x",
lines: [
"fasttrack-connection",
"nfqueue (queue-num=0)",
"return",
"passthrough",
],
},
].map((t) => (
{t.title}
{t.lines.map((c) => (
-
›{c}
))}
))}
{ if (!mutationLock.current) setRuleSheetOpen(false) }}
returnFocus={() => (editingRule?.id ? document.querySelector(`tr[data-row-id="${CSS.escape(editingRule.id)}"] button[aria-haspopup="menu"]`) : null) ?? document.querySelector("[data-firewall-new-rule]")}
initialRule={editingRule}
chainGroup={sheetChainGroup}
family={editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily}
onSave={(payload) => { void saveRule(payload) }}
saving={busy}
error={formError}
/>
{ if (!mutationLock.current) setAddrSheetOpen(false) }}
returnFocus={() => (editingAddr?.id ? document.querySelector(`[data-firewall-address-id="${CSS.escape(editingAddr.id)}"] button[aria-haspopup="menu"]`) : null) ?? document.querySelector("[data-firewall-add-address]")}
initial={editingAddr}
family={editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily}
onSave={(payload) => { void saveAddr(payload) }}
saving={busy}
error={formError}
/>
setExportOpen(false)}
rules={familyRules}
/>
{
if (!open && !mutationLock.current) { setPendingDelete(null); setDeleteError(null) }
}}>
{pendingDelete?.kind === "rule" ? "Удалить правило Firewall?" : "Удалить запись address-list?"}
{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(" · "))}
{deletePending && Ожидание результата удаления…
}
{deleteError && {deleteError}
}
Отмена
{ e.preventDefault(); void confirmDelete() }}>Удалить
{ if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
loadError={historyError}
onRetry={() => { void loadRevisions() }}
title="История Firewall"
itemLabel="объектов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
restoreDisabled={!can("mm:network:write") || !isLive || !writeServer?.enabled || !readTrusted || failures.some(f => f.serverId === historyServerId) || dataLoading || busy || historyRestoring}
onRestore={restoreRevision}
/>
>
)
}
function FirewallSource() {
const { mode, backendUrl } = useDataSource()
return
}
export default function FirewallPage() {
return (
)
}