Files
MikrotikManager/app/(main)/firewall/page.tsx
T

2843 lines
132 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
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<string, string[]> = {
// 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<string, string> = {
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<PacketDef>
}> = [
{ 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<IpFamily, string> = {
ip: "IPv4",
ip6: "IPv6",
}
// ─── Chain groups config ─────────────────────────────────────────────────────
const CHAIN_GROUPS: {
id: ChainGroup; label: string; icon: React.ReactNode; chains: string[]
}[] = [
{
id: "filter", label: "Filter", icon: <ShieldIcon className="size-3.5" />,
chains: ["input","forward","output"],
},
{ id: "nat", label: "NAT", icon: <ArrowRightLeftIcon className="size-3.5" />, chains: ["srcnat","dstnat"] },
{ id: "mangle", label: "Mangle", icon: <WrenchIcon className="size-3.5" />, chains: ["prerouting","postrouting","forward","input","output"] },
{ id: "raw", label: "Raw", icon: <LayersIcon className="size-3.5" />, chains: ["prerouting","output"] },
{ id: "address-lists", label: "Адр. листы", icon: <ListFilterIcon className="size-3.5" />, chains: [] },
{ id: "simulator", label: "Симулятор", icon: <PlayIcon className="size-3.5" />, chains: [] },
]
// ─── Action styles ────────────────────────────────────────────────────────────
const ACTION_STYLES: Record<string, string> = {
accept: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
drop: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
reject: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
masquerade: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
"mark-routing": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
"mark-conn": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
"fasttrack-connection": "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
"dst-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
"src-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
"add-src-to-address-list": "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
}
const CHAIN_STYLES: Record<string, string> = {
forward: "bg-foreground/5 text-foreground/70",
input: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
output: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
srcnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
dstnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
prerouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
postrouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
"ip6-input": "bg-violet-500/10 text-violet-600 dark:text-violet-400",
"ip6-forward": "bg-foreground/5 text-foreground/70",
"ip6-output": "bg-sky-500/10 text-sky-600 dark:text-sky-400",
}
// ─── 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 (
<select value={value} onChange={(e) => onChange(e.target.value)}
className={cn(
"h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
className,
)}>
{children}
</select>
)
}
// ─── 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<string, FirewallRule[]> = {}
for (const r of rules) {
if (!byChain[r.chain]) byChain[r.chain] = []
byChain[r.chain].push(r)
}
const TABLE_MAP: Record<string, string> = {
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 }: {
open: boolean
onClose: () => void
initialRule: Partial<FirewallRule> | null
chainGroup: ChainGroup
family: IpFamily
onSave: (payload: RuleWritePayload) => void
saving?: boolean
error?: string | null
}) {
const isNew = !initialRule?.id
const [form, setForm] = useState<RuleForm>(defaultForm)
useEffect(() => {
if (!open) return
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 ?? "",
})
}, [open, initialRule, chainGroup])
const set = <K extends keyof RuleForm>(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 (
<Sheet open={open} onOpenChange={(v) => { if (!v && !saving) onClose() }}>
<SheetContent showCloseButton={!saving} className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-lg">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<SheetTitle>{isNew ? "Новое правило" : "Редактировать правило"}</SheetTitle>
<SheetDescription>
{familyPath} {tableName} · {IP_FAMILY_LABELS[family]} · RouterOS 7
</SheetDescription>
</SheetHeader>
<fieldset disabled={saving} className="flex-1 min-h-0 overflow-y-auto px-6 py-5 flex flex-col gap-5">
{/* Chain + Action */}
<div className="flex flex-col gap-4">
<SectionTitle>Цепочка и действие</SectionTitle>
<div className="grid grid-cols-2 gap-3">
<FormField label="Цепочка" required>
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
</NativeSelect>
</FormField>
<FormField label="Действие" required>
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
</NativeSelect>
</FormField>
</div>
</div>
{/* Matching */}
<div className="flex flex-col gap-4">
<SectionTitle>Условие совпадения</SectionTitle>
<FormField label="Протокол">
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
<option key={p} value={p}>{p}</option>
)}
</NativeSelect>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
</FormField>
<FormField label="Dst-address / Address-list">
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
</FormField>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField label="Src-port" hint="TCP/UDP, напр. 1024-65535">
<Input className="font-mono h-8" placeholder="—"
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
</FormField>
<FormField label="Dst-port">
<Input className="font-mono h-8" placeholder="443"
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
</FormField>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField label="In-interface" hint="Входящий интерфейс">
<Input className="font-mono h-8" placeholder="wan-msk"
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
</FormField>
<FormField label="Out-interface">
<Input className="font-mono h-8" placeholder="lan"
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
</FormField>
</div>
<FormField label="Connection-state" hint="Через запятую: new, established, related, invalid">
<Input className="font-mono h-8" placeholder="new,established"
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
</FormField>
</div>
{/* Log + Comment */}
<div className="flex flex-col gap-4">
<SectionTitle>Логирование и комментарий</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Log</p>
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
</div>
<FormToggle disabled={saving} checked={form.log} onChange={(v) => set("log", v)} />
</div>
{form.log && (
<FormField label="Log-prefix" hint="Метка в логе, например FW-DROP">
<Input className="font-mono h-8" placeholder="FW-RULE"
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
</FormField>
)}
<FormField label="Комментарий">
<Input className="h-8" placeholder="Описание правила"
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</FormField>
</div>
{/* Enabled */}
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3">
<div>
<p className="text-sm font-medium">Правило включено</p>
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
</div>
<FormToggle disabled={saving} checked={form.enabled} onChange={(v) => set("enabled", v)} />
</div>
{/* CLI preview */}
<div className="rounded-lg bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 px-4 py-3">
<p className="text-[10px] font-mono text-zinc-500 mb-2">RouterOS CLI preview</p>
<pre className="text-[11px] font-mono text-zinc-300 whitespace-pre-wrap leading-relaxed">
{[
`${familyPath} ${tableName} add \\`,
` chain=${form.chain} \\`,
` action=${form.action}`,
form.proto !== "all" ? ` protocol=${form.proto} \\` : null,
form.srcAddrList
? looksLikeAddress(form.srcAddrList)
? ` src-address=${form.srcAddrList} \\`
: ` src-address-list=${form.srcAddrList} \\`
: null,
form.dstAddrList
? looksLikeAddress(form.dstAddrList)
? ` dst-address=${form.dstAddrList} \\`
: ` dst-address-list=${form.dstAddrList} \\`
: null,
form.dstPort ? ` dst-port=${form.dstPort} \\` : null,
form.inIface ? ` in-interface=${form.inIface} \\` : null,
form.outIface ? ` out-interface=${form.outIface} \\` : null,
form.connState ? ` connection-state=${form.connState} \\` : null,
form.log ? ` log=yes \\` : null,
form.log && form.logPrefix ? ` log-prefix="${form.logPrefix}" \\` : null,
form.comment ? ` comment="${form.comment}"` : null,
!form.enabled ? ` disabled=yes` : null,
].filter(Boolean).join("\n")}
</pre>
</div>
</fieldset>
{saving && <p role="status" className="px-6 text-sm text-muted-foreground">Ожидание результата сохранения…</p>}
{error && <p role="alert" className="px-6 text-sm text-destructive">{error}</p>}
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={saving} />}>Отмена</SheetClose>
<Button
className="flex-1"
disabled={saving}
onClick={() => onSave(formToWritePayload(form))}
>
{saving ? "Сохранение…" : isNew ? "Создать правило" : "Сохранить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── 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 (
<CodeExportSheet
open={open}
onClose={onClose}
title="Экспорт Firewall"
description="RouterOS .rsc · /ip firewall filter, nat, mangle"
formats={[
{
id: "rsc",
label: "MikroTik .rsc",
filename: `firewall-${new Date().toISOString().slice(0, 10)}.rsc`,
code,
},
]}
/>
)
}
// ─── 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])
useEffect(() => {
if (listFilter !== "all" && !lists.includes(listFilter)) setListFilter("all")
}, [lists, listFilter])
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<string, AddressListEntry[]> = {}
for (const e of filtered) {
if (!g[e.list]) g[e.list] = []
g[e.list].push(e)
}
return g
}, [filtered, listFilter])
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по адресу, листу…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 overflow-x-auto">
{lists.map((l) => (
<button key={l} onClick={() => setListFilter(l)}
className={cn("px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
listFilter === l ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}>
{l === "all" ? "Все листы" : l}
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} записей</span>
<Button size="sm" disabled={mutationsDisabled} onClick={onAdd}><PlusIcon className="size-4" />Добавить</Button>
</div>
<div className="flex flex-col gap-3">
{Object.entries(grouped).map(([listName, listEntries]) => (
<Frame key={listName} dense className="w-full">
<FramePanel className="p-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/20">
<div className="flex items-center gap-2">
<ListFilterIcon className="size-3.5 text-muted-foreground" />
<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" disabled={mutationsDisabled} onClick={() => onAddToList(listName)}>
<PlusIcon className="size-3" />Адрес
</Button>
</div>
<div className="divide-y divide-border">
{listEntries.map((e) => (
<div key={e.id} className={cn(
"flex items-center gap-3 px-4 py-2.5",
e.disabled && "opacity-50",
)}>
<span className={cn("size-1.5 rounded-full shrink-0", e.disabled ? "bg-muted-foreground" : "bg-emerald-500")} />
<span className="font-mono text-sm font-medium min-w-[150px]">{e.address}</span>
<span className="text-xs text-muted-foreground flex-1 truncate">{e.comment}</span>
{showServer && e.serverName && (
<span className="text-[10px] text-muted-foreground shrink-0">{e.serverName}</span>
)}
{e.timeout && (
<span className="text-[10px] font-mono bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 px-2 py-0.5 rounded shrink-0">
⏱ {e.timeout}
</span>
)}
<FormToggle checked={!e.disabled} disabled={mutationsDisabled} onChange={() => onToggle(e)} />
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7 shrink-0" aria-label={"Действия: " + e.address}>
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onEdit(e)}><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onToggle(e)}>
<PowerIcon className="size-4" />{e.disabled ? "Включить" : "Отключить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={mutationsDisabled} variant="destructive" onClick={() => onDelete(e)}>
<Trash2Icon className="size-4" />Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
</FramePanel>
</Frame>
))}
</div>
</div>
)
}
const defaultAddrForm = {
list: "",
address: "",
comment: "",
timeout: "",
enabled: true,
}
function AddressListSheet({
open, onClose, initial, family, onSave, saving = false, error,
}: {
open: boolean
onClose: () => void
initial: Partial<AddressListEntry> | null
family: IpFamily
onSave: (payload: { list: string; address: string; comment?: string; timeout?: string; disabled?: boolean }) => void
saving?: boolean
error?: string | null
}) {
const isNew = !initial?.id
const [form, setForm] = useState(defaultAddrForm)
useEffect(() => {
if (!open) return
setForm({
list: initial?.list ?? "",
address: initial?.address ?? "",
comment: initial?.comment ?? "",
timeout: initial?.timeout ?? "",
enabled: !(initial?.disabled ?? false),
})
}, [open, initial])
return (
<Sheet open={open} onOpenChange={(v) => { if (!v && !saving) onClose() }}>
<SheetContent showCloseButton={!saving} className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-md">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<SheetTitle>{isNew ? "Запись address-list" : "Редактировать запись"}</SheetTitle>
<SheetDescription>
{family === "ip6" ? "/ipv6 firewall address-list" : "/ip firewall address-list"} · {IP_FAMILY_LABELS[family]}
</SheetDescription>
</SheetHeader>
<fieldset disabled={saving} className="flex-1 min-h-0 overflow-y-auto px-6 py-5 flex flex-col gap-4">
<FormField label="Лист" required>
<Input className="font-mono h-8" placeholder="cdn-bypass"
value={form.list} onChange={(e) => setForm((f) => ({ ...f, list: e.target.value }))} />
</FormField>
<FormField label="Адрес" required>
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
value={form.address} onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))} />
</FormField>
<FormField label="Комментарий">
<Input className="h-8" value={form.comment}
onChange={(e) => setForm((f) => ({ ...f, comment: e.target.value }))} />
</FormField>
<FormField label="Timeout" hint="например 01:00:00">
<Input className="font-mono h-8" placeholder="опционально" value={form.timeout}
onChange={(e) => setForm((f) => ({ ...f, timeout: e.target.value }))} />
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2.5">
<div>
<p className="text-sm font-medium">Запись включена</p>
<p className="text-xs text-muted-foreground">Отключённые адреса не матчятся</p>
</div>
<FormToggle disabled={saving} checked={form.enabled} onChange={(v) => setForm((f) => ({ ...f, enabled: v }))} />
</div>
</fieldset>
{saving && <p role="status" className="px-6 text-sm text-muted-foreground">Ожидание результата сохранения…</p>}
{error && <p role="alert" className="px-6 text-sm text-destructive">{error}</p>}
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={saving} />}>Отмена</SheetClose>
<Button
className="flex-1"
disabled={saving || !form.list.trim() || !form.address.trim()}
onClick={() => onSave({
list: form.list.trim(),
address: form.address.trim(),
comment: form.comment.trim() || undefined,
timeout: form.timeout.trim() || undefined,
disabled: !form.enabled,
})}
>
{saving ? "Сохранение…" : isNew ? "Добавить" : "Сохранить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── Verdict helpers ──────────────────────────────────────────────────────────
function getVerdictIcon(action: string) {
if (action === "drop" || action === "reject") return <XCircleIcon className="size-5" />
if (action === "fasttrack-connection") return <ZapIcon className="size-5" />
if (action.startsWith("mark-")) return <SkipForwardIcon className="size-5" />
return <CheckCircle2Icon className="size-5" />
}
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 (
<div className={cn(
"border-l-2 transition-all duration-200",
step.status === "evaluating" && "border-amber-500 bg-amber-500/5",
step.status === "match" && "border-emerald-500 bg-emerald-500/5",
step.status === "passthrough" && "border-violet-500 bg-violet-500/5",
(step.status === "skip" || step.status === "disabled" || step.status === "pending") && "border-transparent",
)}>
<button
type="button"
onClick={() => isClickable && setExpanded(e => !e)}
className={cn(
"w-full flex items-center gap-3 px-5 py-2.5 text-left transition-colors",
isClickable ? "hover:bg-muted/30 cursor-pointer" : "cursor-default",
step.status === "pending" && "opacity-40",
step.status === "disabled" && "opacity-25",
)}
>
<span className="text-xs font-mono text-muted-foreground w-6 shrink-0 tabular-nums">
{index + 1}
</span>
<div className="shrink-0"><ChainBadge chain={step.rule.chain} /></div>
<div className="shrink-0"><ActionBadge action={step.rule.action} /></div>
<span className="text-xs font-mono text-muted-foreground flex-1 truncate min-w-0">
{step.rule.src || "any"} → {step.rule.dst || "any"}
{step.rule.proto && step.rule.proto !== "all" && (
<span className="opacity-50"> {step.rule.proto}</span>
)}
{step.rule.port && step.rule.port !== "—" && (
<span className="opacity-50">:{step.rule.port}</span>
)}
</span>
{step.rule.comment && (
<span className="hidden lg:block text-xs text-muted-foreground/50 truncate max-w-[200px]">
{step.rule.comment}
</span>
)}
{/* Status badge */}
<div className="shrink-0 min-w-[130px] flex justify-end">
{step.status === "pending" && (
<span className="text-[10px] text-muted-foreground/40">ожидание</span>
)}
{step.status === "evaluating" && (
<span className="text-[10px] text-amber-500 font-mono font-semibold flex items-center gap-1 animate-pulse">
<ZapIcon className="size-3" />проверяется…
</span>
)}
{step.status === "match" && (
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-mono font-semibold flex items-center gap-1">
<CheckCircle2Icon className="size-3" />совпадение!
</span>
)}
{step.status === "passthrough" && (
<span className="text-[10px] text-violet-500 font-mono font-semibold flex items-center gap-1">
<SkipForwardIcon className="size-3" />passthrough
</span>
)}
{step.status === "disabled" && (
<span className="text-[10px] text-muted-foreground/40 font-mono">⊝ выключено</span>
)}
{step.status === "skip" && (
<span className="text-[10px] text-muted-foreground font-mono flex items-center gap-1">
<MinusCircleIcon className="size-3" />пропуск
{step.failedAt && <span className="opacity-60">({step.failedAt})</span>}
</span>
)}
</div>
</button>
{/* Expanded checks */}
{expanded && step.checks.length > 0 && (
<div className="px-5 pb-3 pl-[52px] flex flex-wrap gap-2">
{step.checks.map((c, ci) => (
<div key={ci} className={cn(
"inline-flex items-center gap-1.5 rounded border px-2.5 py-1 text-[11px] font-mono",
c.passed
? "bg-emerald-500/5 border-emerald-500/20 text-emerald-700 dark:text-emerald-300"
: "bg-red-500/5 border-red-500/20 text-red-700 dark:text-red-300",
)}>
{c.passed
? <CheckCircle2Icon className="size-3 shrink-0" />
: <XCircleIcon className="size-3 shrink-0" />}
<span className="text-muted-foreground">{c.field}:</span>
<span className="font-semibold">{c.ruleVal}</span>
{!c.passed && (
<>
<span className="text-muted-foreground/40 mx-0.5">≠</span>
<span>{c.packetVal}</span>
</>
)}
</div>
))}
</div>
)}
</div>
)
}
// ─── Scenario constants + helpers ────────────────────────────────────────────
const SCENARIO_STORAGE_KEY = "routerlists-fw-scenarios"
const DEFAULT_SRULE: Omit<ScenarioRule, "id"> = {
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<PacketDef>(initial?.packet ?? DEFAULT_PKT)
const [rules, setRules] = useState<ScenarioRule[]>(initial?.rules ?? [])
const [addOpen, setAddOpen]= useState(false)
const [addForm, setAddForm]= useState<Omit<ScenarioRule, "id">>(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 extends keyof PacketDef>(k: K, v: PacketDef[K]) =>
setPkt2(p => ({ ...p, [k]: v }))
const setAF = <K extends keyof Omit<ScenarioRule, "id">>(k: K, v: Omit<ScenarioRule, "id">[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 (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<SheetTitle>{isNew ? "Новый сценарий" : `Редактировать: ${initial?.name}`}</SheetTitle>
<SheetDescription>Набор правил firewall/mangle/nat + тестовый пакет</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-6">
{/* Meta */}
<div className="flex flex-col gap-3">
<SectionTitle>Название</SectionTitle>
<div className="grid grid-cols-2 gap-3">
<FormField label="Название сценария" required>
<Input className="h-8" placeholder="Блокировка Tor Exit"
value={name} onChange={e => setName(e.target.value)} />
</FormField>
<FormField label="Описание">
<Input className="h-8" placeholder="Краткое описание"
value={desc} onChange={e => setDesc(e.target.value)} />
</FormField>
</div>
</div>
{/* Default packet */}
<div className="flex flex-col gap-3">
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<FormField label="Направление / цепочка">
<NativeSelect value={pkt.chain} onChange={v => setP("chain", v)}>
<optgroup label="Полный маршрут">
<option value="forward">forward — транзит</option>
<option value="input">input — на роутер</option>
<option value="output">output — от роутера</option>
<option value="dstnat">dstnat — port-forward</option>
</optgroup>
<optgroup label="Одна цепочка">
{["srcnat","prerouting","postrouting","ip6-forward","ip6-input","ip6-output"]
.map(c => <option key={c} value={c}>{c}</option>)}
</optgroup>
</NativeSelect>
</FormField>
<FormField label="Протокол">
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
</NativeSelect>
</FormField>
<FormField label="Conn-state">
<Input className="font-mono h-8" value={pkt.connState}
placeholder="new" onChange={e => setP("connState", e.target.value)} />
</FormField>
<FormField label="Src IP">
<Input className="font-mono h-8" value={pkt.srcAddr}
onChange={e => setP("srcAddr", e.target.value)} />
</FormField>
<FormField label="Dst IP">
<Input className="font-mono h-8" value={pkt.dstAddr}
onChange={e => setP("dstAddr", e.target.value)} />
</FormField>
<FormField label="Dst Port">
<Input className="font-mono h-8" value={pkt.dstPort}
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
</FormField>
<FormField label="In-interface">
<Input className="font-mono h-8" value={pkt.inIface}
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
</FormField>
<FormField label="Out-interface">
<Input className="font-mono h-8" value={pkt.outIface}
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
</FormField>
<FormField label="Dst addr-list">
<Input className="font-mono h-8" value={pkt.dstAddrList}
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
</FormField>
</div>
</div>
{/* Rules */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Правила сценария
</span>
<span className="text-xs font-mono text-muted-foreground">({rules.length})</span>
<div className="flex-1 h-px bg-border" />
<Button size="sm" variant="outline" className="h-6 text-xs gap-1 px-2"
onClick={() => setAddOpen(o => !o)}>
<PlusIcon className="size-3" />
{addOpen ? "Отмена" : "Добавить правило"}
</Button>
</div>
{/* Add-rule form */}
{addOpen && (
<div className="rounded-lg border bg-muted/20 p-4 flex flex-col gap-3">
<p className="text-xs font-medium text-muted-foreground">Новое правило</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Цепочка</label>
<NativeSelect value={addForm.chain} onChange={v => setAF("chain", v)}>
{CHAIN_OPTS.map(c => <option key={c} value={c}>{c}</option>)}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Действие</label>
<NativeSelect value={addForm.action} onChange={v => setAF("action", v)}>
{ACTION_OPTS.map(a => <option key={a} value={a}>{a}</option>)}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Протокол</label>
<NativeSelect value={addForm.proto} onChange={v => setAF("proto", v)}>
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Dst Port</label>
<Input className="font-mono h-8 text-xs" value={addForm.port}
placeholder="443" onChange={e => setAF("port", e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Src address / list</label>
<Input className="font-mono h-8 text-xs" value={addForm.src}
placeholder="10.0.0.0/8" onChange={e => setAF("src", e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Dst address / list</label>
<Input className="font-mono h-8 text-xs" value={addForm.dst}
placeholder="any" onChange={e => setAF("dst", e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">In-interface</label>
<Input className="font-mono h-8 text-xs" value={addForm.iface}
placeholder="lan" onChange={e => setAF("iface", e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Комментарий</label>
<Input className="h-8 text-xs" value={addForm.comment}
onChange={e => setAF("comment", e.target.value)} />
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FormToggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
<span className="text-xs text-muted-foreground">Включено</span>
</div>
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
</div>
</div>
)}
{/* Rules table */}
{rules.length > 0 ? (
<FirewallScenarioRulesDataGrid
rules={rules}
onToggleEnabled={toggleEnabled}
onMoveUp={(id) => moveRule(id, -1)}
onMoveDown={(id) => moveRule(id, 1)}
onRemove={removeRule}
/>
) : (
!addOpen && (
<div className="text-center py-6 text-sm text-muted-foreground border rounded-lg border-dashed">
Нет правил — нажмите «Добавить правило»
</div>
)
)}
</div>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1" onClick={handleSave} disabled={!name.trim()}>
{isNew ? "Создать сценарий" : "Сохранить изменения"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── Simulator Tab ────────────────────────────────────────────────────────────
const SIM_SPEED_MS: Record<SimSpeed, number> = { slow: 700, normal: 350, fast: 120, instant: 0 }
const SIM_SPEED_LABELS: Record<SimSpeed, string> = {
slow: "Медленно", normal: "Нормально", fast: "Быстро", instant: "Мгновенно",
}
function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
// ── Core sim state ──────────────────────────────────────────────────────────
const [packet, setPacket] = useState<PacketDef>(DEFAULT_PKT)
const [activePreset, setPreset] = useState<string | null>("https")
const [simState, setSimState] = useState<"idle" | "running" | "done">("idle")
const [steps, setSteps] = useState<SimStep[]>([])
const [speed, setSpeed] = useState<SimSpeed>("normal")
const [verdict, setVerdict] = useState<{ action: string; ruleIndex: number | null } | null>(null)
const cancelRef = useRef(false)
// ── Scenarios state ─────────────────────────────────────────────────────────
const [scenarios, setScenarios] = useState<SimScenario[]>(() => {
if (typeof window === "undefined") return []
try { return JSON.parse(localStorage.getItem(SCENARIO_STORAGE_KEY) ?? "[]") }
catch { return [] }
})
const [activeScenarioId, setActiveScenarioId] = useState<string | null>(null)
const [scenarioSheetOpen, setScenarioSheetOpen] = useState(false)
const [editingScenario, setEditingScenario] = useState<SimScenario | null>(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 extends keyof PacketDef>(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() {
cancelRef.current = true
setSimState("idle")
setSteps([])
setVerdict(null)
}
function applyPreset(p: typeof SIM_PRESETS[number]) {
setActiveScenarioId(null)
setPreset(p.id)
setPacket({ ...DEFAULT_PKT, ...p.pkt })
cancelRef.current = true
setSimState("idle")
setSteps([])
setVerdict(null)
}
function activateScenario(id: string | null) {
setActiveScenarioId(id)
setPreset(null)
cancelRef.current = true
setSimState("idle")
setSteps([])
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
}
cancelRef.current = false
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) return
setSteps(prev => prev.map((s, idx) => idx === i ? { ...s, status: "evaluating" } : s))
if (delay > 0) await new Promise<void>(res => setTimeout(res, delay))
if (cancelRef.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<void>(res => setTimeout(res, Math.max(delay * 0.5, 200)))
break
}
if (delay > 0) await new Promise<void>(res => setTimeout(res, Math.max(delay * 0.25, 40)))
}
if (!cancelRef.current) {
setVerdict(finalVerdict ?? { action: "accept (default)", ruleIndex: null })
setSimState("done")
}
}
const verdictColors = verdict ? getVerdictColors(verdict.action) : null
return (
<div className="flex flex-col gap-4">
{/* ── Presets + Scenarios card ─────────────────────────────────────────── */}
<OpsPanel title="Пресеты трафика" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
{SIM_PRESETS.map(p => (
<button key={p.id} type="button" onClick={() => applyPreset(p)}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-colors",
activePreset === p.id && !activeScenarioId
? "bg-primary/90 text-primary-foreground border-primary"
: "border-border bg-background hover:bg-muted/60 text-foreground",
)}>
<span className="leading-none">{p.emoji}</span>
<span className="font-medium leading-none">{p.label}</span>
<span className={cn("text-xs leading-none",
activePreset === p.id && !activeScenarioId
? "text-primary-foreground/60" : "text-muted-foreground",
)}>{p.desc}</span>
</button>
))}
</div>
<div className="h-px bg-border" />
{/* Saved scenarios */}
<div>
<div className="flex items-center justify-between mb-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Мои сценарии
</p>
<Button size="sm" variant="outline" className="h-6 text-xs gap-1 px-2"
onClick={() => { setEditingScenario(null); setScenarioSheetOpen(true) }}>
<PlusIcon className="size-3" />Новый сценарий
</Button>
</div>
{scenarios.length === 0 ? (
<p className="text-xs text-muted-foreground/60 py-1">
Нет сохранённых сценариев — создайте набор правил firewall/mangle/nat для тестирования
</p>
) : (
<div className="flex flex-wrap gap-2">
{scenarios.map(sc => {
const isActive = activeScenarioId === sc.id
return (
<div key={sc.id} className={cn(
"flex items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors",
isActive
? "bg-violet-500/10 border-violet-500/40"
: "border-border bg-background hover:bg-muted/40",
)}>
<div className="flex flex-col min-w-0 leading-tight">
<span className="text-sm font-medium">{sc.name}</span>
<span className="text-[11px] text-muted-foreground font-mono">
{sc.rules.length} правил · {sc.packet.chain}
{sc.description ? ` · ${sc.description}` : ""}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button type="button"
onClick={() => activateScenario(isActive ? null : sc.id)}
className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border transition-colors",
isActive
? "bg-violet-500 text-white border-violet-500"
: "border-border hover:bg-muted",
)}>
{isActive ? "Активен" : "Выбрать"}
</button>
<button type="button"
onClick={() => { setEditingScenario(sc); setScenarioSheetOpen(true) }}
className="p-1 text-muted-foreground/50 hover:text-foreground transition-colors">
<PencilIcon className="size-3.5" />
</button>
<button type="button" onClick={() => deleteScenario(sc.id)}
className="p-1 text-muted-foreground/50 hover:text-red-500 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
</div>
)
})}
</div>
)}
</div>
</OpsPanel>
{/* ── Packet editor ───────────────────────────────────────────────────── */}
<OpsPanel title="Параметры пакета" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center justify-end flex-wrap gap-2">
<div className="flex items-center gap-3">
<span className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border",
activeScenario
? "bg-violet-500/10 border-violet-500/30 text-violet-600 dark:text-violet-400"
: "bg-muted border-border text-muted-foreground",
)}>
{activeScenario ? `🎭 ${activeScenario.name}` : "🔴 Живые правила"}
</span>
<div className="flex flex-col items-end gap-0.5">
<span className="text-xs text-muted-foreground font-mono">
{simRules.length} правил ·{" "}
<span className="text-foreground font-semibold">{packet.chain}</span>
</span>
{DIR_PATHS[packet.chain] && (
<span className="text-[10px] text-muted-foreground/50 font-mono">
{DIR_PATHS[packet.chain].join(" → ")}
</span>
)}
</div>
</div>
</div>
{/* Row 1 */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Направление / цепочка</label>
<NativeSelect value={packet.chain} onChange={v => { setPkt("chain", v); resetSim() }}>
<optgroup label="Полный маршрут (все таблицы)">
<option value="forward">forward — транзит LAN→WAN</option>
<option value="input">input — на роутер</option>
<option value="output">output — от роутера</option>
<option value="dstnat">dstnat — port-forward</option>
</optgroup>
<optgroup label="Одна цепочка">
<option value="prerouting">prerouting</option>
<option value="postrouting">postrouting</option>
<option value="srcnat">srcnat</option>
<option value="ip6-forward">ip6-forward</option>
<option value="ip6-input">ip6-input</option>
<option value="ip6-output">ip6-output</option>
</optgroup>
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Протокол</label>
<NativeSelect value={packet.proto} onChange={v => setPkt("proto", v)}>
{["tcp","udp","icmp","gre","esp","ah"].map(p => <option key={p} value={p}>{p}</option>)}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Src IP</label>
<Input className="font-mono h-8 text-xs" value={packet.srcAddr}
onChange={e => setPkt("srcAddr", e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Dst IP</label>
<Input className="font-mono h-8 text-xs" value={packet.dstAddr}
onChange={e => setPkt("dstAddr", e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Src Port</label>
<Input className="font-mono h-8 text-xs" value={packet.srcPort}
placeholder="—" onChange={e => setPkt("srcPort", e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Dst Port</label>
<Input className="font-mono h-8 text-xs" value={packet.dstPort}
placeholder="—" onChange={e => setPkt("dstPort", e.target.value)} />
</div>
</div>
{/* Row 2 */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">In-interface</label>
<Input className="font-mono h-8 text-xs" value={packet.inIface}
placeholder="lan" onChange={e => setPkt("inIface", e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Out-interface</label>
<Input className="font-mono h-8 text-xs" value={packet.outIface}
placeholder="wan-msk" onChange={e => setPkt("outIface", e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Conn-state</label>
<Input className="font-mono h-8 text-xs" value={packet.connState}
placeholder="new" onChange={e => setPkt("connState", e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Dst addr-list</label>
<Input className="font-mono h-8 text-xs" value={packet.dstAddrList}
placeholder="youtube-bypass" onChange={e => setPkt("dstAddrList", e.target.value)} />
</div>
</div>
{/* Speed + Run controls */}
<div className="flex items-center gap-3 pt-2 border-t border-border flex-wrap">
<SlidersHorizontalIcon className="size-3.5 text-muted-foreground shrink-0" />
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{(["slow","normal","fast","instant"] as SimSpeed[]).map(s => (
<button key={s} type="button"
onClick={() => setSpeed(s)} disabled={simState === "running"}
className={cn(
"px-3 py-1 text-xs rounded transition-colors disabled:opacity-50",
speed === s ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{SIM_SPEED_LABELS[s]}
</button>
))}
</div>
<div className="flex items-center gap-2 ml-auto">
<Button variant="outline" size="sm" onClick={resetSim} disabled={simState === "idle"}>
<RotateCcwIcon className="size-4" />Сбросить
</Button>
{simState === "running" ? (
<Button size="sm" variant="destructive"
onClick={() => { cancelRef.current = true; setSimState("done") }}>
<SquareIcon className="size-4" />Стоп
</Button>
) : (
<Button size="sm" onClick={runSimulation} disabled={simRules.length === 0}>
<PlayIcon className="size-4" />
{simState === "done" ? "Снова" : "Запустить"}
</Button>
)}
</div>
</div>
</OpsPanel>
{/* ── Verdict banner ──────────────────────────────────────────────────── */}
{verdict && simState === "done" && verdictColors && (
<div className={cn(
"rounded-xl border px-5 py-4 flex items-center gap-4",
verdictColors.bg, verdictColors.border,
)}>
<div className={verdictColors.text}>{getVerdictIcon(verdict.action)}</div>
<div className="flex-1 min-w-0">
<p className={cn("font-semibold text-base", verdictColors.text)}>
{verdict.action === "drop" || verdict.action === "reject"
? "Пакет заблокирован"
: verdict.ruleIndex === null ? "Пакет принят (по умолчанию)" : "Пакет принят"}
{verdict.ruleIndex !== null && (
<span className="font-normal text-sm ml-2 opacity-70">— правило #{verdict.ruleIndex + 1}</span>
)}
</p>
<p className="text-sm text-muted-foreground mt-0.5">
Действие: <span className={cn("font-mono font-medium", verdictColors.text)}>{verdict.action}</span>
{verdict.ruleIndex === null && <span className="ml-2 opacity-70">· ни одно правило не совпало</span>}
</p>
</div>
</div>
)}
{/* ── Trace list ──────────────────────────────────────────────────────── */}
{steps.length > 0 && (
<Frame dense className="w-full">
<FramePanel className="p-0">
<div className="px-5 py-3 border-b flex items-center justify-between">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Трассировка · цепочка: <span className="text-foreground normal-case font-mono">{packet.chain}</span>
{activeScenario && (
<span className="ml-2 text-violet-500 font-sans normal-case">· {activeScenario.name}</span>
)}
</p>
<span className="text-xs text-muted-foreground">
<span className="text-emerald-500">{steps.filter(s => s.status === "match").length} совпало</span>
{" · "}{steps.filter(s => s.status === "skip").length} пропущено
{" · "}{steps.filter(s => s.status === "disabled").length} выкл
</span>
</div>
<div className="divide-y divide-border/60">
{steps.map((step, i) => {
const prevChain = i > 0 ? steps[i - 1].rule.chain : null
const showHeader = step.rule.chain !== prevChain
return (
<div key={step.rule.id}>
{showHeader && (
<div className="px-5 py-1.5 bg-muted/40 border-b border-border/50 flex items-center gap-2">
<span className="text-[10px] font-mono font-semibold text-foreground/70 uppercase tracking-wider">
{step.rule.chain}
</span>
{CHAIN_TABLE_HINT[step.rule.chain] && (
<span className="text-[10px] text-muted-foreground/50">
· {CHAIN_TABLE_HINT[step.rule.chain]}
</span>
)}
<span className="text-[10px] text-muted-foreground/40 ml-auto">
{steps.filter(s => s.rule.chain === step.rule.chain).length} правил
</span>
</div>
)}
<SimStepRow step={step} index={i} />
</div>
)
})}
</div>
</FramePanel>
</Frame>
)}
{/* ── Empty state ─────────────────────────────────────────────────────── */}
{simState === "idle" && simRules.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldOffIcon className="size-10 mb-3 opacity-30" />
<p className="text-sm font-medium">Нет правил в цепочке «{packet.chain}»</p>
<p className="text-xs mt-1 opacity-60">
{activeScenario
? "Добавьте правила в сценарий через кнопку «Редактировать»"
: "Выберите другой пресет или измените цепочку"}
</p>
</div>
)}
{/* ── Scenario Sheet ───────────────────────────────────────────────────── */}
<ScenarioSheet
open={scenarioSheetOpen}
onClose={() => setScenarioSheetOpen(false)}
initial={editingScenario}
onSave={saveScenario}
/>
</div>
)
}
// ─── Live API ─────────────────────────────────────────────────────────────────
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(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<FirewallRule, "family">): IpFamily {
return r.family === "ip6" ? "ip6" : "ip"
}
function addressFamily(e: Pick<AddressListEntry, "family">): 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<T>(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<Server[]>([])
const [liveRules, setLiveRules] = useState<FirewallRule[]>([])
const [liveAddrLists, setLiveAddrLists] = useState<AddressListEntry[]>([])
const [mockRules, setMockRules] = useState<FirewallRule[]>(firewallRules)
const [mockAddrLists, setMockAddrLists] = useState<AddressListEntry[]>(INIT_ADDRESS_LISTS)
const [dataLoading, setDataLoading] = useState(false)
const [dataError, setDataError] = useState<string | null>(null)
const [hasLoaded, setHasLoaded] = useState(false)
const [sliceTimes, setSliceTimes] = useState<FirewallSliceTime[]>([])
const [failures, setFailures] = useState<FirewallFailure[]>([])
const loadEpoch = useRef(0)
useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive])
const [busy, setBusy] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const mutationLock = useRef(false)
const [togglePending, setTogglePending] = useState(false)
const [movePending, setMovePending] = useState(false)
const [moveStatus, setMoveStatus] = useState<string | null>(null)
const [moveError, setMoveError] = useState<string | null>(null)
const [toggleStatus, setToggleStatus] = useState<string | null>(null)
const [toggleError, setToggleError] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<{ kind: "rule"; item: FirewallRule } | { kind: "address"; item: AddressListEntry } | null>(null)
const [deletePending, setDeletePending] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [selectedServerId, setSelectedServerId] = useState(serverFromQuery ?? ALL_SERVERS_ID)
const appliedQueryRef = useRef(false)
const [chainGroup, setChainGroup] = useState<ChainGroup>("filter")
const [chainFilter, setChainFilter] = useState<ChainFilter>("all")
const [ipFamily, setIpFamily] = useState<IpFamily>("ip")
const [search, setSearch] = useState("")
const [editingRule, setEditingRule] = useState<Partial<FirewallRule> | null>(null)
const [ruleSheetOpen, setRuleSheetOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [editingAddr, setEditingAddr] = useState<Partial<AddressListEntry> | null>(null)
const [addrSheetOpen, setAddrSheetOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState<string | null>(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<unknown>("/api/servers"),
apiFetch<unknown>("/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)
} catch (e) {
if (epoch !== loadEpoch.current) return
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<unknown>(
`/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 (!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)
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])
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
useEffect(() => {
if (appliedQueryRef.current || !serverFromQuery) return
if (displayServers.length === 0) return
if (displayServers.some((s) => s.id === serverFromQuery)) {
setSelectedServerId(serverFromQuery)
}
appliedQueryRef.current = true
}, [displayServers, serverFromQuery])
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<ServerTileItem[]>(() => (
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<string, number> = { 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 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 || firewallIncomplete) 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<typeof pendingDelete>) {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setDeleteError(null)
setPendingDelete(target)
}
async function confirmDelete() {
if (!pendingDelete || mutationLock.current || busy || historyRestoring || firewallIncomplete) 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 || firewallIncomplete) 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 || firewallIncomplete) 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 || firewallIncomplete) return
setFormError(null)
setEditingRule(r)
setRuleSheetOpen(true)
}
async function saveRule(payload: RuleWritePayload) {
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()
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 || firewallIncomplete) return
setFormError(null)
if (!requireWriteServer()) return
setEditingAddr(list ? { list } : null)
setAddrSheetOpen(true)
}
function openEditAddr(e: AddressListEntry) {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setFormError(null)
setEditingAddr(e)
setAddrSheetOpen(true)
}
async function toggleAddr(e: AddressListEntry) {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) 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 || firewallIncomplete) 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 (
<>
<ServerRailLayout
items={firewallRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayServers.length}
loading={isLive && dataLoading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Firewall" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { if (!mutationLock.current) void loadLive() }}
disabled={!isLive || dataLoading || busy || togglePending || deletePending || movePending || historyRestoring}
title={!isLive ? "Включите Live в источнике данных" : "Обновить правила с устройств"}
>
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
if (mutationLock.current) return
setHistoryOpen(true)
void loadRevisions()
}}
disabled={!isLive || !historyServerId || dataLoading || busy || togglePending || deletePending || movePending || historyRestoring || failures.some(f => f.serverId === historyServerId)}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
>
<HistoryIcon className="size-4" />
История
</Button>
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
<CodeXmlIcon className="size-4" />Экспорт .rsc
</Button>
<Button size="sm" disabled={firewallIncomplete} onClick={openAdd}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5">
{dataError && isLive && (
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{dataError}
{hasLoaded && <span> Показаны последние успешно загруженные данные.</span>}
</div>
)}
<div className="flex items-center gap-3 flex-wrap">
<SegmentedControl
value={ipFamily}
onChange={(v) => { setIpFamily(v); setChainFilter("all") }}
options={(["ip", "ip6"] as IpFamily[]).map((f) => ({
value: f,
label: IP_FAMILY_LABELS[f],
}))}
/>
<span className="text-xs text-muted-foreground">
{ipFamily === "ip6" ? "/ipv6 firewall" : "/ip firewall"}
</span>
</div>
{isLive && <div aria-label="Свежесть данных Firewall" className="text-xs text-muted-foreground">
{dataLoading && <p role="status">{hasLoaded ? "Обновление данных Firewall…" : "Загрузка данных Firewall…"}</p>}
<p title="Время получения данных в приложении; не время измерения на RouterOS">
{freshness.oldest ? <>Получено в приложении: <time dateTime={freshness.oldest}>{new Date(freshness.oldest).toLocaleString("ru-RU")}</time>
{freshness.newest !== freshness.oldest && <> — <time dateTime={freshness.newest}>{new Date(freshness.newest!).toLocaleString("ru-RU")}</time></>}
</> : hasLoaded && liveServers.every(server => !server.enabled) ? "Нет включённых устройств для опроса" : "Время получения срезов неизвестно"}
{freshness.unknown > 0 && <> · Не получено срезов: {freshness.unknown}</>}
</p>
</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) => {
const range = firewallTimeRange(scopedSliceTimes.filter(slice => slice.serverId === failure.serverId && (!failure.family || slice.family === failure.family) && (!failure.table || slice.table === failure.table)))
return <p key={index}>{[failure.serverName || failure.serverId, failure.family, failure.table, failure.error].filter(Boolean).join(" · ")} · Последние данные: {range.oldest
? <><time dateTime={range.oldest}>{new Date(range.oldest).toLocaleString("ru-RU")}</time>
{range.newest !== range.oldest && <> — <time dateTime={range.newest}>{new Date(range.newest!).toLocaleString("ru-RU")}</time></>}
</> : "ещё не получены"}{range.unknown > 0 && <> · Не получено срезов: {range.unknown}</>}</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>}
{toggleError && <p role="alert" className="text-sm text-destructive">{toggleError}</p>}
<KpiStatGrid
aria-label="Сводка Firewall"
items={[
{
id: "rules",
label: "Всего правил",
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : familyRules.length,
icon: <ShieldIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "enabled",
label: "Активных",
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : totalEnabled,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "drop",
label: "Блокирующих",
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : dropRules,
icon: <ShieldOffIcon className="size-4" />,
iconClassName: "text-destructive",
},
{
id: "hits",
label: "Срабатываний",
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : fmtHits(totalHits),
icon: <ListFilterIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
<div className="flex items-center gap-1 border-b">
{CHAIN_GROUPS.map((g) => (
<button key={g.id}
onClick={() => { setChainGroup(g.id); setChainFilter("all"); setSearch("") }}
className={cn(
"flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
chainGroup === g.id
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}>
{g.icon}
{g.label}
{g.id !== "address-lists" && g.id !== "simulator" && (
<span className="text-[10px] font-mono opacity-50">
{familyRules.filter((r) => r.table === g.id).length}
</span>
)}
{g.id === "address-lists" && (
<span className="text-[10px] font-mono opacity-50">{familyAddrLists.length}</span>
)}
</button>
))}
</div>
{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 || firewallIncomplete}
onAdd={() => openAddAddr()}
onAddToList={(list) => openAddAddr(list)}
onToggle={(e) => { void toggleAddr(e) }}
onEdit={openEditAddr}
onDelete={(item) => requestDelete({ kind: "address", item })}
showServer={showServerCol}
/>
) : chainGroup === "simulator" ? (
<SimulatorTab rules={familyRules} />
) : (
<DataPageCard>
<DataPageToolbarFrame>
<SegmentedControl
value={chainFilter}
onChange={setChainFilter}
options={[
{ value: "all", label: "Все", count: chainCounts.all ?? 0 },
...chainsInGroup.map((c) => ({
value: c,
label: c,
count: chainCounts[c] ?? 0,
})),
]}
/>
<InputGroup className="min-w-[220px] max-w-sm">
<InputGroupAddon>
<SearchIcon className="size-3.5" />
</InputGroupAddon>
<InputGroupInput
placeholder="Поиск по адресу, действию…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</InputGroup>
<span className="text-sm text-muted-foreground ml-auto">
{filteredRules.length} правил
</span>
</DataPageToolbarFrame>
<FirewallRulesDataGrid
rules={filteredRules}
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 && !firewallIncomplete ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
showServer={showServerCol}
/>
</DataPageCard>
)}
<OpsPanel title="RouterOS 7.20+ · /ip firewall + /ipv6 firewall — цепочки и новые матчеры" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-xs">
{[
{
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) => (
<div key={t.title}>
<p className="font-mono font-semibold text-foreground/80 mb-1.5 text-[11px]">{t.title}</p>
<ul className="flex flex-col gap-1">
{t.lines.map((c) => (
<li key={c} className="text-muted-foreground font-mono flex items-start gap-1.5">
<span className="text-muted-foreground/40 mt-0.5">›</span>{c}
</li>
))}
</ul>
</div>
))}
</div>
</OpsPanel>
</div>
</ServerRailLayout>
<RuleSheet
open={ruleSheetOpen}
onClose={() => { if (!mutationLock.current) setRuleSheetOpen(false) }}
initialRule={editingRule}
chainGroup={sheetChainGroup}
family={editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily}
onSave={(payload) => { void saveRule(payload) }}
saving={busy}
error={formError}
/>
<AddressListSheet
open={addrSheetOpen}
onClose={() => { if (!mutationLock.current) setAddrSheetOpen(false) }}
initial={editingAddr}
family={editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily}
onSave={(payload) => { void saveAddr(payload) }}
saving={busy}
error={formError}
/>
<ExportSheet
open={exportOpen}
onClose={() => setExportOpen(false)}
rules={familyRules}
/>
<AlertDialog open={!!pendingDelete} onOpenChange={open => {
if (!open && !mutationLock.current) { setPendingDelete(null); setDeleteError(null) }
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{pendingDelete?.kind === "rule" ? "Удалить правило Firewall?" : "Удалить запись address-list?"}</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete && (pendingDelete.kind === "rule"
? [pendingDelete.item.comment || pendingDelete.item.rosId || pendingDelete.item.id, pendingDelete.item.serverName || pendingDelete.item.serverId, ruleFamily(pendingDelete.item), pendingDelete.item.table, pendingDelete.item.chain].join(" · ")
: [pendingDelete.item.address, pendingDelete.item.list, pendingDelete.item.serverName || pendingDelete.item.serverId, addressFamily(pendingDelete.item)].join(" · "))}
</AlertDialogDescription>
{deletePending && <p role="status" className="text-sm text-muted-foreground">Ожидание результата удаления…</p>}
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePending}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={deletePending || togglePending || busy || historyRestoring || firewallIncomplete} onClick={e => { e.preventDefault(); void confirmDelete() }}>Удалить</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
loadError={historyError}
onRetry={() => { void loadRevisions() }}
title="История Firewall"
itemLabel="объектов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</>
)
}
function FirewallSource() {
const { mode, backendUrl } = useDataSource()
return <FirewallPageInner key={JSON.stringify([mode, backendUrl])} />
}
export default function FirewallPage() {
return (
<Suspense fallback={null}>
<FirewallSource />
</Suspense>
)
}