Files
DenozordecandCursor 25b82997b6
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m20s
Docker images / frontend-image (push) Successful in 2m51s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
feat(config-revisions): implement configuration history for Firewall, GRE, and WireGuard
- Added configuration history management for Firewall, GRE, and WireGuard pages, enabling users to view and restore previous configurations.
- Introduced new components for displaying configuration history and integrated them into the respective pages.
- Enhanced API routes to support fetching and restoring configuration revisions, ensuring data consistency across the application.
- Updated state management to handle loading and restoring states effectively, improving user experience during data operations.
- Enhanced tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <[email protected]>
2026-09-11 14:03:00 +07:00

2666 lines
117 KiB
TypeScript
Raw Permalink 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 { 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 { 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 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 }: {
open: boolean
onClose: () => void
initialRule: Partial<FirewallRule> | null
chainGroup: ChainGroup
family: IpFamily
onSave: (payload: RuleWritePayload) => void
saving?: boolean
}) {
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) onClose() }}>
<SheetContent 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>
<div className="flex-1 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 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 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>
</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"
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,
}: {
entries: AddressListEntry[]
onAdd: () => void
onAddToList: (list: string) => void
onToggle: (entry: AddressListEntry) => void
onEdit: (entry: AddressListEntry) => void
onDelete: (entry: AddressListEntry) => void
showServer?: 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" 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" 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} onChange={() => onToggle(e)} />
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7 shrink-0">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onEdit(e)}><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem onClick={() => onToggle(e)}>
<PowerIcon className="size-4" />{e.disabled ? "Включить" : "Отключить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem 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,
}: {
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
}) {
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) onClose() }}>
<SheetContent 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>
<div className="flex-1 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 checked={form.enabled} onChange={(v) => setForm((f) => ({ ...f, enabled: v }))} />
</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"
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 ─────────────────────────────────────────────────────────────────
interface BackendServer {
id: number
name: string
host: string
type: "jump-host" | "exit-node" | "home-router"
site: string
country: string
asn: string
enabled: boolean
status: "online" | "offline" | null
latency: number | null
}
interface FirewallAllResponse {
rules: FirewallRule[]
addressLists: AddressListEntry[]
}
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(backendUrl, path, init)
}
}
function mapBackendToServer(s: BackendServer): 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 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 [busy, setBusy] = useState(false)
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 [historyRestoring, setHistoryRestoring] = useState(false)
const loadLive = useCallback(async () => {
if (!isLive) return
setDataLoading(true)
setDataError(null)
try {
const [backendServers, fw] = await Promise.all([
apiFetch<BackendServer[]>("/api/servers"),
apiFetch<FirewallAllResponse>("/api/firewall/all"),
])
setLiveServers(backendServers.map(mapBackendToServer))
setLiveRules(fw.rules)
setLiveAddrLists(fw.addressLists)
} catch (e) {
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
setLiveServers([])
setLiveRules([])
setLiveAddrLists([])
} finally {
setDataLoading(false)
}
}, [isLive, apiFetch])
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
const loadRevisions = useCallback(async () => {
if (!isLive || !historyServerId) return
setHistoryLoading(true)
try {
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
`/api/firewall/revisions?serverId=${encodeURIComponent(historyServerId)}`,
)
setRevisions(res.revisions)
} catch (err) {
toast.error("Не удалось загрузить историю", { description: String(err) })
setRevisions([])
} finally {
setHistoryLoading(false)
}
}, [isLive, historyServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) return
setHistoryRestoring(true)
try {
await apiFetch(
`/api/firewall/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
)
toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
} catch (err) {
toast.error("Не удалось откатить", { description: String(err) })
} finally {
setHistoryRestoring(false)
}
}, [isLive, historyServerId, apiFetch, loadLive, loadRevisions])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveServers([])
setLiveRules([])
setLiveAddrLists([])
setDataError(null)
})
return
}
queueMicrotask(() => {
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 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) return
if (isLive) {
const key = ruleKey(r)
if (!key) return
try {
await apiFetch("/api/firewall/rules", {
method: "PATCH",
body: JSON.stringify({ ...key, disabled: r.enabled }),
})
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось переключить правило")
}
return
}
setMockRules((rs) => rs.map((x) => x.id === id ? { ...x, enabled: !x.enabled } : x))
}
async function deleteRule(r: FirewallRule) {
if (isLive) {
const key = ruleKey(r)
if (!key) return
try {
await apiFetch("/api/firewall/rules", { method: "DELETE", body: JSON.stringify(key) })
toast.success("Правило удалено")
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось удалить правило")
}
return
}
setMockRules((rs) => rs.filter((x) => x.id !== r.id))
}
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) {
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
setLiveRules(next)
try {
await apiFetch("/api/firewall/rules/move", {
method: "POST",
body: JSON.stringify({
...key,
...(dest.dest ? { destinationRosId: dest.dest } : {}),
}),
})
await loadLive()
} catch (e) {
setLiveRules(prev)
toast.error(e instanceof Error ? e.message : "Не удалось переместить правило")
}
}
function openAdd() {
if (!requireWriteServer()) return
const table = tableOfGroup(chainGroup)
if (!table) {
setChainGroup("filter")
}
setEditingRule(null)
setRuleSheetOpen(true)
}
function openEdit(r: FirewallRule) {
setEditingRule(r)
setRuleSheetOpen(true)
}
async function saveRule(payload: RuleWritePayload) {
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) {
setBusy(true)
try {
if (editingRule?.rosId) {
await apiFetch("/api/firewall/rules", {
method: "PUT",
body: JSON.stringify({ serverId, family, table, rosId: editingRule.rosId, ...payload }),
})
toast.success("Правило сохранено")
} else {
await apiFetch("/api/firewall/rules", {
method: "POST",
body: JSON.stringify({ serverId, family, table, ...payload }),
})
toast.success("Правило создано")
}
setRuleSheetOpen(false)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сохранить правило")
} finally {
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 (!requireWriteServer()) return
setEditingAddr(list ? { list } : null)
setAddrSheetOpen(true)
}
function openEditAddr(e: AddressListEntry) {
setEditingAddr(e)
setAddrSheetOpen(true)
}
async function toggleAddr(e: AddressListEntry) {
if (isLive) {
const key = addressKey(e)
if (!key) return
try {
await apiFetch("/api/firewall/address-lists", {
method: "PATCH",
body: JSON.stringify({ ...key, disabled: !e.disabled }),
})
await loadLive()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось переключить запись")
}
return
}
setMockAddrLists((rows) => rows.map((x) => x.id === e.id ? { ...x, disabled: !x.disabled } : x))
}
async function deleteAddr(e: AddressListEntry) {
if (isLive) {
const key = addressKey(e)
if (!key) return
try {
await apiFetch("/api/firewall/address-lists", { method: "DELETE", body: JSON.stringify(key) })
toast.success("Запись удалена")
await loadLive()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось удалить запись")
}
return
}
setMockAddrLists((rows) => rows.filter((x) => x.id !== e.id))
}
async function saveAddr(payload: {
list: string
address: string
comment?: string
timeout?: string
disabled?: boolean
}) {
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) {
setBusy(true)
try {
if (editingAddr?.rosId) {
await apiFetch("/api/firewall/address-lists", {
method: "PUT",
body: JSON.stringify({ serverId, family, rosId: editingAddr.rosId, ...payload }),
})
toast.success("Запись сохранена")
} else {
await apiFetch("/api/firewall/address-lists", {
method: "POST",
body: JSON.stringify({ serverId, family, ...payload }),
})
toast.success("Запись создана")
}
setAddrSheetOpen(false)
await loadLive()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось сохранить запись")
} finally {
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={() => { void loadLive() }}
disabled={!isLive || dataLoading}
title={!isLive ? "Включите Live в источнике данных" : "Обновить правила с устройств"}
>
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={!isLive || !historyServerId || dataLoading}
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" onClick={openAdd}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5">
{dataError && isLive && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{dataError}
</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>
<KpiStatGrid
aria-label="Сводка Firewall"
items={[
{
id: "rules",
label: "Всего правил",
value: familyRules.length,
icon: <ShieldIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "enabled",
label: "Активных",
value: totalEnabled,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "drop",
label: "Блокирующих",
value: dropRules,
icon: <ShieldOffIcon className="size-4" />,
iconClassName: "text-destructive",
},
{
id: "hits",
label: "Срабатываний",
value: 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>
{chainGroup === "address-lists" ? (
<AddressListsTab
entries={familyAddrLists}
onAdd={() => openAddAddr()}
onAddToList={(list) => openAddAddr(list)}
onToggle={(e) => { void toggleAddr(e) }}
onEdit={openEditAddr}
onDelete={(e) => { void deleteAddr(e) }}
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}
onToggle={(id) => { void toggleRule(id) }}
onEdit={openEdit}
onDelete={(r) => { void deleteRule(r) }}
onReorder={canReorder ? (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={() => setRuleSheetOpen(false)}
initialRule={editingRule}
chainGroup={sheetChainGroup}
family={editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily}
onSave={(payload) => { void saveRule(payload) }}
saving={busy}
/>
<AddressListSheet
open={addrSheetOpen}
onClose={() => setAddrSheetOpen(false)}
initial={editingAddr}
family={editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily}
onSave={(payload) => { void saveAddr(payload) }}
saving={busy}
/>
<ExportSheet
open={exportOpen}
onClose={() => setExportOpen(false)}
rules={familyRules}
/>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История Firewall"
itemLabel="объектов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</>
)
}
export default function FirewallPage() {
return (
<Suspense fallback={null}>
<FirewallPageInner />
</Suspense>
)
}