2095 lines
101 KiB
TypeScript
2095 lines
101 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { firewallRules, type FirewallRule } from "@/lib/data"
|
||
import { Card, CardContent } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
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,
|
||
MoreHorizontalIcon, PencilIcon, Trash2Icon, CopyIcon, CodeXmlIcon,
|
||
CheckIcon, PowerIcon, CheckCircleIcon,
|
||
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
|
||
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
|
||
PackageIcon, SlidersHorizontalIcon,
|
||
} from "lucide-react"
|
||
|
||
// ─── 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 }
|
||
}
|
||
|
||
interface AddressListEntry {
|
||
id: string
|
||
list: string
|
||
address: string
|
||
comment: string
|
||
disabled: boolean
|
||
timeout?: string
|
||
}
|
||
|
||
// ─── 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 },
|
||
{ id: "al2", list: "youtube-bypass", address: "172.217.0.0/16", comment: "Google/YouTube AS15169", disabled: false },
|
||
{ id: "al3", list: "cdn-bypass", address: "104.16.0.0/12", comment: "Cloudflare AS13335", disabled: false },
|
||
{ id: "al4", list: "cdn-bypass", address: "23.32.0.0/11", comment: "Akamai AS20940", disabled: false },
|
||
{ id: "al5", list: "cdn-bypass", address: "151.101.0.0/16", comment: "Fastly AS54113", disabled: false },
|
||
{ id: "al6", list: "streaming-eu", address: "54.246.0.0/16", comment: "Netflix AS2906", disabled: false },
|
||
{ id: "al7", list: "streaming-eu", address: "45.57.0.0/16", comment: "Netflix AS2906 NL", disabled: false },
|
||
{ id: "al8", list: "social-block", address: "157.240.0.0/17", comment: "Facebook/Meta AS32934", disabled: false },
|
||
{ id: "al9", list: "social-block", address: "31.13.64.0/18", comment: "Facebook/Meta AS32934", disabled: false },
|
||
{ id: "al10", list: "gaming-low-latency",address: "162.159.128.0/19", comment: "Discord/Cloudflare AS13335", disabled: false },
|
||
{ id: "al11", list: "management-access", address: "10.10.0.0/16", comment: "LAN", disabled: false },
|
||
{ id: "al12", list: "management-access", address: "192.168.100.0/24", comment: "MGMT VLAN", disabled: false },
|
||
{ id: "al13", list: "blocklist-dynamic", address: "185.220.101.45", comment: "Tor exit — dynamic ban", disabled: false, timeout: "01:00:00" },
|
||
{ id: "al14", list: "blocklist-dynamic", address: "80.82.70.118", comment: "Shodan scanner — dynamic ban", disabled: false, timeout: "00:42:18" },
|
||
]
|
||
|
||
// ─── IP family filter ─────────────────────────────────────────────────────────
|
||
|
||
type IpFamily = "all" | "ip" | "ip6"
|
||
|
||
const IP_FAMILY_LABELS: Record<IpFamily, string> = {
|
||
all: "IPv4 + IPv6",
|
||
ip: "IPv4",
|
||
ip6: "IPv6",
|
||
}
|
||
|
||
// ─── Chain groups config ─────────────────────────────────────────────────────
|
||
|
||
const CHAIN_GROUPS: {
|
||
id: ChainGroup; label: string; icon: React.ReactNode; chains: string[]; ip6chains?: string[]
|
||
}[] = [
|
||
{
|
||
id: "filter", label: "Filter", icon: <ShieldIcon className="size-3.5" />,
|
||
chains: ["input","forward","output"],
|
||
ip6chains: ["ip6-input","ip6-forward","ip6-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
|
||
|
||
// ─── 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)
|
||
}
|
||
|
||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||
return (
|
||
<button type="button" role="switch" aria-checked={checked}
|
||
onClick={() => onChange(!checked)}
|
||
className={cn(
|
||
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
|
||
checked ? "bg-primary" : "bg-input",
|
||
)}>
|
||
<span className={cn(
|
||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||
checked ? "translate-x-4" : "translate-x-0",
|
||
)} />
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function ActionBadge({ action }: { action: string }) {
|
||
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
|
||
return (
|
||
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
|
||
{action}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function ChainBadge({ chain }: { chain: string }) {
|
||
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
|
||
return (
|
||
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
|
||
{chain}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function Field({ label, hint, required, children }: {
|
||
label: string; hint?: string; required?: boolean; children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-sm font-medium">
|
||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
||
</label>
|
||
{children}
|
||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<div className="flex items-center gap-2 py-0.5">
|
||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
||
<div className="flex-1 h-px bg-border" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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-transparent px-2.5 text-sm outline-none",
|
||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||
"dark:bg-input/30",
|
||
className,
|
||
)}>
|
||
{children}
|
||
</select>
|
||
)
|
||
}
|
||
|
||
// ─── RSC Export generator ─────────────────────────────────────────────────────
|
||
|
||
function generateRsc(rules: FirewallRule[], timestamp?: string): string {
|
||
const lines: string[] = []
|
||
lines.push(`# RouterLists 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 = TABLE_MAP[chain] ?? "filter"
|
||
lines.push(`# ── /ip firewall ${table} chain=${chain} ──────────────────────────────`)
|
||
for (const r of chainRules) {
|
||
const parts = [`/ip firewall ${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 }: {
|
||
open: boolean
|
||
onClose: () => void
|
||
initialRule: Partial<FirewallRule> | null
|
||
chainGroup: ChainGroup
|
||
}) {
|
||
const isNew = !initialRule?.id
|
||
const [form, setForm] = useState<RuleForm>(() => ({
|
||
...defaultForm,
|
||
chain: CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains[0] ?? "forward",
|
||
...(initialRule ?? {}),
|
||
srcPort: "",
|
||
dstPort: initialRule?.port ?? "",
|
||
inIface: initialRule?.iface ?? "",
|
||
outIface: "",
|
||
connState: "",
|
||
srcAddrList: initialRule?.src ?? "",
|
||
dstAddrList: initialRule?.dst ?? "",
|
||
log: false,
|
||
logPrefix: "",
|
||
}))
|
||
|
||
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 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>
|
||
/ip firewall {chainGroup === "nat" ? "nat" : chainGroup === "mangle" ? "mangle" : "filter"} · 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">
|
||
<Field label="Цепочка" required>
|
||
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
|
||
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
|
||
</NativeSelect>
|
||
</Field>
|
||
<Field label="Действие" required>
|
||
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
|
||
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
||
</NativeSelect>
|
||
</Field>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Matching */}
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Условие совпадения</SectionTitle>
|
||
<Field 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>
|
||
</Field>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field 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)} />
|
||
</Field>
|
||
<Field 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)} />
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field 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)} />
|
||
</Field>
|
||
<Field label="Dst-port">
|
||
<Input className="font-mono h-8" placeholder="443"
|
||
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="In-interface" hint="Входящий интерфейс">
|
||
<Input className="font-mono h-8" placeholder="wan-msk"
|
||
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
|
||
</Field>
|
||
<Field label="Out-interface">
|
||
<Input className="font-mono h-8" placeholder="lan"
|
||
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
|
||
</Field>
|
||
</div>
|
||
|
||
<Field 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)} />
|
||
</Field>
|
||
</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>
|
||
<Toggle checked={form.log} onChange={(v) => set("log", v)} />
|
||
</div>
|
||
{form.log && (
|
||
<Field 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)} />
|
||
</Field>
|
||
)}
|
||
<Field label="Комментарий">
|
||
<Input className="h-8" placeholder="Описание правила"
|
||
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||
</Field>
|
||
</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>
|
||
<Toggle 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">
|
||
{[
|
||
`/ip firewall filter add \\`,
|
||
` chain=${form.chain} \\`,
|
||
` action=${form.action}`,
|
||
form.proto !== "all" ? ` protocol=${form.proto} \\` : null,
|
||
form.srcAddrList ? ` src-address-list=${form.srcAddrList} \\` : null,
|
||
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" onClick={onClose}>
|
||
{isNew ? "Добавить правило" : "Сохранить"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|
||
|
||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||
|
||
function ExportSheet({ open, onClose, rules }: {
|
||
open: boolean; onClose: () => void; rules: FirewallRule[]
|
||
}) {
|
||
const [copied, setCopied] = useState(false)
|
||
|
||
// Derived from open — new timestamp each time modal opens, undefined when closed
|
||
const timestamp = useMemo(
|
||
() => open ? new Date().toLocaleString("ru") : undefined,
|
||
[open], // eslint-disable-line react-hooks/exhaustive-deps
|
||
)
|
||
|
||
const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp])
|
||
|
||
function handleCopy() {
|
||
const full = generateRsc(rules, new Date().toLocaleString("ru"))
|
||
navigator.clipboard.writeText(full).then(() => {
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
})
|
||
}
|
||
|
||
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">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<SheetTitle>Экспорт Firewall</SheetTitle>
|
||
<SheetDescription>RouterOS .rsc · /ip firewall filter, nat, mangle</SheetDescription>
|
||
</div>
|
||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||
{copied
|
||
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
|
||
: <><CopyIcon className="size-3.5" />Копировать</>}
|
||
</Button>
|
||
</div>
|
||
</SheetHeader>
|
||
<div className="flex-1 overflow-y-auto">
|
||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||
{code.split("\n").map((line, i) => {
|
||
const isComment = line.startsWith("#")
|
||
const isSection = isComment && line.includes("──")
|
||
const isKey = /^\s+[a-z]/.test(line)
|
||
return (
|
||
<span key={i} className={
|
||
isSection ? "text-muted-foreground/50"
|
||
: isComment ? "text-muted-foreground"
|
||
: isKey ? "text-sky-400/90"
|
||
: "text-foreground"
|
||
}>
|
||
{line}{"\n"}
|
||
</span>
|
||
)
|
||
})}
|
||
</pre>
|
||
</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={handleCopy}>
|
||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||
{copied ? "Скопировано" : "Копировать .rsc"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|
||
|
||
// ─── Address List tab ─────────────────────────────────────────────────────────
|
||
|
||
function AddressListsTab({ entries, onAdd }: {
|
||
entries: AddressListEntry[]
|
||
onAdd: () => void
|
||
}) {
|
||
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])
|
||
|
||
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)
|
||
})
|
||
}, [entries, search, listFilter])
|
||
|
||
// Group by list name for display
|
||
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">
|
||
{/* toolbar */}
|
||
<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>
|
||
|
||
{/* groups */}
|
||
<div className="flex flex-col gap-3">
|
||
{Object.entries(grouped).map(([listName, listEntries]) => (
|
||
<Card key={listName}>
|
||
<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">
|
||
<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>
|
||
{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>
|
||
)}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger render={
|
||
<Button variant="ghost" size="icon" className="size-7 shrink-0">
|
||
<MoreHorizontalIcon className="size-4" />
|
||
</Button>
|
||
} />
|
||
<DropdownMenuContent side="bottom" align="end">
|
||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 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
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
useEffect(() => {
|
||
if (!open) return
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setName(initial?.name ?? ""); setDesc(initial?.description ?? "")
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setPkt2(initial?.packet ?? DEFAULT_PKT); setRules(initial?.rules ?? [])
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setAddForm(DEFAULT_SRULE); setAddOpen(false)
|
||
}, [open])
|
||
|
||
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">
|
||
<Field label="Название сценария" required>
|
||
<Input className="h-8" placeholder="Блокировка Tor Exit"
|
||
value={name} onChange={e => setName(e.target.value)} />
|
||
</Field>
|
||
<Field label="Описание">
|
||
<Input className="h-8" placeholder="Краткое описание"
|
||
value={desc} onChange={e => setDesc(e.target.value)} />
|
||
</Field>
|
||
</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">
|
||
<Field 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>
|
||
</Field>
|
||
<Field label="Протокол">
|
||
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
|
||
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
|
||
</NativeSelect>
|
||
</Field>
|
||
<Field label="Conn-state">
|
||
<Input className="font-mono h-8" value={pkt.connState}
|
||
placeholder="new" onChange={e => setP("connState", e.target.value)} />
|
||
</Field>
|
||
<Field label="Src IP">
|
||
<Input className="font-mono h-8" value={pkt.srcAddr}
|
||
onChange={e => setP("srcAddr", e.target.value)} />
|
||
</Field>
|
||
<Field label="Dst IP">
|
||
<Input className="font-mono h-8" value={pkt.dstAddr}
|
||
onChange={e => setP("dstAddr", e.target.value)} />
|
||
</Field>
|
||
<Field label="Dst Port">
|
||
<Input className="font-mono h-8" value={pkt.dstPort}
|
||
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
|
||
</Field>
|
||
<Field label="In-interface">
|
||
<Input className="font-mono h-8" value={pkt.inIface}
|
||
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
|
||
</Field>
|
||
<Field label="Out-interface">
|
||
<Input className="font-mono h-8" value={pkt.outIface}
|
||
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
|
||
</Field>
|
||
<Field label="Dst addr-list">
|
||
<Input className="font-mono h-8" value={pkt.dstAddrList}
|
||
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
|
||
</Field>
|
||
</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">
|
||
<Toggle 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 ? (
|
||
<div className="rounded-lg border overflow-hidden">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b bg-muted/30 text-muted-foreground">
|
||
<th className="text-left px-3 py-2 w-6">#</th>
|
||
<th className="text-left px-3 py-2">Цепочка</th>
|
||
<th className="text-left px-3 py-2">Действие</th>
|
||
<th className="text-left px-3 py-2">Src</th>
|
||
<th className="text-left px-3 py-2">Dst</th>
|
||
<th className="text-left px-3 py-2">Порт</th>
|
||
<th className="text-left px-3 py-2">Iface</th>
|
||
<th className="text-left px-3 py-2">Комментарий</th>
|
||
<th className="w-24 px-2 py-2" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{rules.map((r, i) => (
|
||
<tr key={r.id} className={cn(
|
||
"hover:bg-muted/20 transition-colors",
|
||
!r.enabled && "opacity-40",
|
||
)}>
|
||
<td className="px-3 py-1.5 text-muted-foreground tabular-nums">{i + 1}</td>
|
||
<td className="px-3 py-1.5"><ChainBadge chain={r.chain} /></td>
|
||
<td className="px-3 py-1.5"><ActionBadge action={r.action} /></td>
|
||
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.src || "any"}</td>
|
||
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.dst || "any"}</td>
|
||
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.port || "—"}</td>
|
||
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.iface || "—"}</td>
|
||
<td className="px-3 py-1.5 text-muted-foreground/70 max-w-[110px] truncate">{r.comment || "—"}</td>
|
||
<td className="px-2 py-1.5">
|
||
<div className="flex items-center gap-0.5 justify-end">
|
||
<button type="button" onClick={() => toggleEnabled(r.id)}
|
||
title={r.enabled ? "Отключить" : "Включить"}
|
||
className="p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors">
|
||
<PowerIcon className="size-3.5" />
|
||
</button>
|
||
<button type="button" onClick={() => moveRule(r.id, -1)} disabled={i === 0}
|
||
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors">▲</button>
|
||
<button type="button" onClick={() => moveRule(r.id, 1)} disabled={i === rules.length - 1}
|
||
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors">▼</button>
|
||
<button type="button" onClick={() => removeRule(r.id)}
|
||
className="p-0.5 ml-0.5 text-muted-foreground/40 hover:text-red-500 transition-colors">
|
||
<Trash2Icon className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
!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 ─────────────────────────────────────────── */}
|
||
<Card>
|
||
<CardContent className="px-5 py-4 flex flex-col gap-4">
|
||
|
||
{/* Built-in traffic presets */}
|
||
<div>
|
||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2.5">
|
||
Пресеты трафика
|
||
</p>
|
||
<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>
|
||
|
||
<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>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* ── Packet editor ───────────────────────────────────────────────────── */}
|
||
<Card>
|
||
<CardContent className="px-5 py-4 flex flex-col gap-4">
|
||
|
||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||
<PackageIcon className="size-3.5" />Параметры пакета
|
||
</p>
|
||
<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>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* ── 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 && (
|
||
<Card>
|
||
<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>
|
||
</Card>
|
||
)}
|
||
|
||
{/* ── 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>
|
||
)
|
||
}
|
||
|
||
// ─── Rules Table ──────────────────────────────────────────────────────────────
|
||
|
||
function RulesTable({ rules, onToggle, onEdit }: {
|
||
rules: FirewallRule[]
|
||
onToggle: (id: string) => void
|
||
onEdit: (r: FirewallRule) => void
|
||
}) {
|
||
if (rules.length === 0) {
|
||
return (
|
||
<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">Правила не найдены</p>
|
||
<p className="text-xs mt-1">Попробуйте изменить фильтр или добавьте новое правило</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||
<th className="text-left font-medium px-5 py-3 w-8">#</th>
|
||
<th className="text-left font-medium px-4 py-3">Цепочка</th>
|
||
<th className="text-left font-medium px-4 py-3">Действие</th>
|
||
<th className="text-left font-medium px-4 py-3">Источник</th>
|
||
<th className="text-left font-medium px-4 py-3">Назначение</th>
|
||
<th className="text-left font-medium px-4 py-3">Протокол</th>
|
||
<th className="text-left font-medium px-4 py-3">Порт</th>
|
||
<th className="text-left font-medium px-4 py-3">Интерфейс</th>
|
||
<th className="text-right font-medium px-4 py-3">Пакетов</th>
|
||
<th className="text-left font-medium px-4 py-3 w-12">Вкл</th>
|
||
<th className="w-10 px-3 py-3" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{rules.map((r, i) => (
|
||
<tr key={r.id}
|
||
className={cn("hover:bg-muted/40 transition-colors", !r.enabled && "opacity-40")}>
|
||
<td className="px-5 py-2.5 font-mono text-xs text-muted-foreground">{i + 1}</td>
|
||
<td className="px-4 py-2.5"><ChainBadge chain={r.chain} /></td>
|
||
<td className="px-4 py-2.5"><ActionBadge action={r.action} /></td>
|
||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
|
||
{r.src || "any"}
|
||
</td>
|
||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
|
||
{r.dst || "any"}
|
||
</td>
|
||
<td className="px-4 py-2.5 text-xs font-mono">{r.proto}</td>
|
||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.port || "—"}</td>
|
||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.iface || "—"}</td>
|
||
<td className="px-4 py-2.5 text-right">
|
||
<span className={cn(
|
||
"text-xs font-mono tabular-nums",
|
||
r.hits > 1_000_000 ? "text-emerald-600 dark:text-emerald-400 font-semibold"
|
||
: r.hits > 10_000 ? "text-foreground"
|
||
: "text-muted-foreground",
|
||
)}>
|
||
{fmtHits(r.hits)}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<Toggle checked={r.enabled} onChange={() => onToggle(r.id)} />
|
||
</td>
|
||
<td className="px-3 py-2.5">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger render={
|
||
<Button variant="ghost" size="icon" className="size-7">
|
||
<MoreHorizontalIcon className="size-4" />
|
||
</Button>
|
||
} />
|
||
<DropdownMenuContent side="bottom" align="end">
|
||
<DropdownMenuItem onClick={() => onEdit(r)}>
|
||
<PencilIcon className="size-4" />Редактировать
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem>
|
||
<CopyIcon className="size-4" />Дублировать
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem onClick={() => onToggle(r.id)}>
|
||
<PowerIcon className="size-4" />
|
||
{r.enabled ? "Отключить" : "Включить"}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem variant="destructive">
|
||
<Trash2Icon className="size-4" />Удалить правило
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
export default function FirewallPage() {
|
||
const [rules, setRules] = useState<FirewallRule[]>(firewallRules)
|
||
const [addrLists] = useState<AddressListEntry[]>(INIT_ADDRESS_LISTS)
|
||
|
||
const [chainGroup, setChainGroup] = useState<ChainGroup>("filter")
|
||
const [chainFilter, setChainFilter] = useState<ChainFilter>("all")
|
||
const [ipFamily, setIpFamily] = useState<IpFamily>("all")
|
||
const [search, setSearch] = useState("")
|
||
|
||
const [editingRule, setEditingRule] = useState<Partial<FirewallRule> | null>(null)
|
||
const [ruleSheetOpen, setRuleSheetOpen] = useState(false)
|
||
const [exportOpen, setExportOpen] = useState(false)
|
||
|
||
// ── derived ──────────────────────────────────────────────────────────────
|
||
|
||
const chainsInGroup = useMemo(() => {
|
||
if (chainGroup === "address-lists") return []
|
||
const grp = CHAIN_GROUPS.find((g) => g.id === chainGroup)
|
||
if (!grp) return []
|
||
const v4 = grp.chains
|
||
const v6 = grp.ip6chains ?? []
|
||
if (ipFamily === "ip") return v4
|
||
if (ipFamily === "ip6") return v6
|
||
return [...v4, ...v6]
|
||
}, [chainGroup, ipFamily])
|
||
|
||
const groupRules = useMemo(() => {
|
||
if (chainGroup === "address-lists") return []
|
||
return rules.filter((r) => chainsInGroup.includes(r.chain))
|
||
}, [rules, chainGroup, chainsInGroup])
|
||
|
||
const filteredRules = useMemo(() => {
|
||
return groupRules.filter((r) => {
|
||
if (chainFilter !== "all" && r.chain !== chainFilter) return false
|
||
// ipFamily filtering also respects rules with explicit family field
|
||
if (ipFamily === "ip" && r.family === "ip6") return false
|
||
if (ipFamily === "ip6" && r.family === "ip") 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)
|
||
)
|
||
})
|
||
}, [groupRules, chainFilter, ipFamily, 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])
|
||
|
||
// ── stats ────────────────────────────────────────────────────────────────
|
||
|
||
const totalEnabled = rules.filter((r) => r.enabled).length
|
||
const totalHits = rules.reduce((s, r) => s + r.hits, 0)
|
||
const dropRules = rules.filter((r) => r.action === "drop" || r.action === "reject").length
|
||
|
||
// ── handlers ─────────────────────────────────────────────────────────────
|
||
|
||
function toggleRule(id: string) {
|
||
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r))
|
||
}
|
||
|
||
function openAdd() {
|
||
setEditingRule(null)
|
||
setRuleSheetOpen(true)
|
||
}
|
||
|
||
function openEdit(r: FirewallRule) {
|
||
setEditingRule(r)
|
||
setRuleSheetOpen(true)
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "Firewall" }]}
|
||
actions={
|
||
<>
|
||
<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-1 overflow-y-auto p-6">
|
||
<div className="flex flex-col gap-5">
|
||
|
||
{/* Stats */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{[
|
||
{ label: "Всего правил", value: rules.length, icon: <ShieldIcon className="size-4 text-muted-foreground" /> },
|
||
{ label: "Активных", value: totalEnabled, icon: <CheckCircleIcon className="size-4 text-emerald-500" /> },
|
||
{ label: "Блокирующих", value: dropRules, icon: <ShieldOffIcon className="size-4 text-red-500" /> },
|
||
{ label: "Срабатываний", value: fmtHits(totalHits), icon: <ListFilterIcon className="size-4 text-sky-400" /> },
|
||
].map((s) => (
|
||
<Card key={s.label}>
|
||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||
</div>
|
||
<div className="mt-0.5">{s.icon}</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* Table group tabs */}
|
||
<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">
|
||
{rules.filter((r) => g.chains.includes(r.chain)).length}
|
||
</span>
|
||
)}
|
||
{g.id === "address-lists" && (
|
||
<span className="text-[10px] font-mono opacity-50">{addrLists.length}</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{chainGroup === "address-lists" ? (
|
||
<AddressListsTab entries={addrLists} onAdd={() => {}} />
|
||
) : chainGroup === "simulator" ? (
|
||
<SimulatorTab rules={rules} />
|
||
) : (
|
||
<Card>
|
||
{/* toolbar */}
|
||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||
{/* IP family selector */}
|
||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||
{(["all", "ip", "ip6"] as IpFamily[]).map((f) => (
|
||
<button key={f} onClick={() => setIpFamily(f)}
|
||
className={cn(
|
||
"flex items-center gap-1.5 rounded px-3 py-1 text-xs transition-colors whitespace-nowrap",
|
||
ipFamily === f
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
{IP_FAMILY_LABELS[f]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* chain sub-tabs */}
|
||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||
{[{ value: "all", label: "Все" }, ...chainsInGroup.map((c) => ({ value: c, label: c }))].map((t) => (
|
||
<button key={t.value} onClick={() => setChainFilter(t.value)}
|
||
className={cn(
|
||
"flex items-center gap-1.5 rounded px-3 py-1 text-xs transition-colors",
|
||
chainFilter === t.value
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
{t.label}
|
||
<span className="opacity-50">{chainCounts[t.value] ?? 0}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* search */}
|
||
<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>
|
||
|
||
<span className="text-sm text-muted-foreground ml-auto">{filteredRules.length} правил</span>
|
||
</div>
|
||
|
||
<RulesTable rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
|
||
</Card>
|
||
)}
|
||
|
||
{/* RouterOS reference */}
|
||
<Card>
|
||
<CardContent className="px-5 py-4">
|
||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
||
RouterOS 7.20+ · /ip firewall + /ipv6 firewall — цепочки и новые матчеры
|
||
</p>
|
||
<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: ["ip6-input", "ip6-forward", "ip6-output"],
|
||
},
|
||
{
|
||
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>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sheet: Add/Edit Rule */}
|
||
<RuleSheet
|
||
open={ruleSheetOpen}
|
||
onClose={() => setRuleSheetOpen(false)}
|
||
initialRule={editingRule}
|
||
chainGroup={chainGroup === "address-lists" ? "filter" : chainGroup}
|
||
/>
|
||
|
||
{/* Sheet: Export */}
|
||
<ExportSheet
|
||
open={exportOpen}
|
||
onClose={() => setExportOpen(false)}
|
||
rules={rules}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|