Files
MikrotikManager/app/(main)/firewall/page.tsx
T
Denozordec d2de8d3188
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 2m7s
Docker images / updater-image (push) Successful in 41s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
fix(ui): улучшить функциональность панели данных и заменить устаревшие компоненты
2026-06-30 22:36:00 +07:00

1900 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
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, 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 {
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,
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)
}
// 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 = 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">
<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">
{[
`/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],
)
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
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 ─────────────────────────────────────────── */}
<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 транзит LANWAN</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>
)
}
// ════════════════════════════════════════════════════════════════════════════
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} />
) : (
<DataPageCard>
<DataPageToolbarFrame>
<SegmentedControl
value={ipFamily}
onChange={setIpFamily}
options={(["all", "ip", "ip6"] as IpFamily[]).map((f) => ({
value: f,
label: IP_FAMILY_LABELS[f],
}))}
/>
<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={toggleRule} onEdit={openEdit} />
</DataPageCard>
)}
{/* 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>
)
}