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
- 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]>
2666 lines
117 KiB
TypeScript
2666 lines
117 KiB
TypeScript
"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>
|
||
)
|
||
}
|