Browse Source

Replicate RouterOS WebFig WinBox v4 dialog for Firewall Filter Rules with 5 tabs, interactive matchers, right action sidebar, and live statistics graphs

Proxmox Router 1 week ago
parent
commit
474d5f0bdb
4 changed files with 1510 additions and 266 deletions
  1. 5 0
      internal/api/firewall_handlers.go
  2. 44 0
      internal/firewall/types.go
  3. 554 113
      internal/web/static/app.js
  4. 907 153
      internal/web/static/index.html

+ 5 - 0
internal/api/firewall_handlers.go

@@ -2,7 +2,9 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
+	"time"
 
 	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/routing"
@@ -33,6 +35,9 @@ func (s *Server) handleSaveFilterRule(w http.ResponseWriter, r *http.Request) {
 		writeError(w, http.StatusBadRequest, "invalid request body")
 		return
 	}
+	if rule.ID == "" {
+		rule.ID = fmt.Sprintf("filter-%d", time.Now().UnixNano())
+	}
 	if err := s.fwMgr.SaveFilterRule(rule); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
 		return

+ 44 - 0
internal/firewall/types.go

@@ -30,6 +30,50 @@ type FilterRule struct {
 	Disabled        bool     `json:"disabled"`
 	Packets         uint64   `json:"packets"`
 	Bytes           uint64   `json:"bytes"`
+
+	// Extended WebFig / WinBox v4 properties
+	AnyPort            string   `json:"any_port,omitempty"`
+	InInterfaceList    string   `json:"in_interface_list,omitempty"`
+	OutInterfaceList   string   `json:"out_interface_list,omitempty"`
+	PacketMark         string   `json:"packet_mark,omitempty"`
+	ConnectionType     string   `json:"connection_type,omitempty"`
+	ConnectionNATState []string `json:"connection_nat_state,omitempty"`
+
+	// Advanced tab
+	Layer7Protocol   string `json:"layer7_protocol,omitempty"`
+	Content          string `json:"content,omitempty"`
+	ConnectionBytes  string `json:"connection_bytes,omitempty"`
+	ConnectionRate   string `json:"connection_rate,omitempty"`
+	PCC              string `json:"pcc,omitempty"`
+	SrcMACAddress    string `json:"src_mac_address,omitempty"`
+	InBridgePort     string `json:"in_bridge_port,omitempty"`
+	OutBridgePort    string `json:"out_bridge_port,omitempty"`
+	InBridgePortList string `json:"in_bridge_port_list,omitempty"`
+	OutBridgePortList string `json:"out_bridge_port_list,omitempty"`
+	IPsecPolicy      string `json:"ipsec_policy,omitempty"`
+	TLSHost          string `json:"tls_host,omitempty"`
+	IngressPriority  string `json:"ingress_priority,omitempty"`
+	Priority         string `json:"priority,omitempty"`
+	DSCP             string `json:"dscp,omitempty"`
+	TCPMSS           string `json:"tcp_mss,omitempty"`
+	TOS              string `json:"tos,omitempty"`
+	PacketSize       string `json:"packet_size,omitempty"`
+	Random           string `json:"random,omitempty"`
+	TCPFlags         string `json:"tcp_flags,omitempty"`
+	ICMPOptions      string `json:"icmp_options,omitempty"`
+	IPv4Options      string `json:"ipv4_options,omitempty"`
+	TTL              string `json:"ttl,omitempty"`
+
+	// Extra tab
+	Time     string `json:"time,omitempty"`
+	Days     string `json:"days,omitempty"`
+	Limit    string `json:"limit,omitempty"`
+	DstLimit string `json:"dst_limit,omitempty"`
+	PSD      string `json:"psd,omitempty"`
+
+	// Action tab extended
+	AddressList        string `json:"address_list,omitempty"`
+	AddressListTimeout string `json:"address_list_timeout,omitempty"`
 }
 
 // NATRule represents a MikroTik-style /ip firewall nat rule

+ 554 - 113
internal/web/static/app.js

@@ -2808,36 +2808,148 @@ function populateGeoIPSelect(selectId, currentVal = '') {
   sel.appendChild(grpCountries);
 }
 
+// ==================== FIREWALL FILTER MODAL (ROUTEROS WEBFIG / WINBOX V4) ====================
+
+function switchFwRuleTab(tab) {
+  state.activeFwRuleTab = tab;
+  document.querySelectorAll('.fw-rule-tab-btn').forEach(btn => {
+    btn.classList.remove('bg-[#182638]', 'text-white', 'border-sky-400');
+    btn.classList.add('text-slate-400', 'border-transparent');
+  });
+  const activeBtn = document.getElementById(`fw-tab-btn-${tab}`);
+  if (activeBtn) {
+    activeBtn.classList.remove('text-slate-400', 'border-transparent');
+    activeBtn.classList.add('bg-[#182638]', 'text-white', 'border-sky-400');
+  }
+  document.querySelectorAll('.fw-rule-pane').forEach(p => p.classList.add('hidden'));
+  const activePane = document.getElementById(`fw-pane-${tab}`);
+  if (activePane) activePane.classList.remove('hidden');
+
+  if (tab === 'statistics') {
+    startFwStatPolling();
+  } else {
+    stopFwStatPolling();
+  }
+}
+
+function toggleFwField(fieldKey, expand) {
+  const plusBtn = document.getElementById(`fw-plus-${fieldKey}`);
+  const fieldBox = document.getElementById(`fw-field-${fieldKey}`);
+  if (!plusBtn || !fieldBox) return;
+
+  if (expand) {
+    plusBtn.classList.add('hidden');
+    fieldBox.classList.remove('hidden');
+    const valInput = document.getElementById(`fw-val-${fieldKey}`);
+    if (valInput) valInput.focus();
+  } else {
+    fieldBox.classList.add('hidden');
+    plusBtn.classList.remove('hidden');
+    const valInput = document.getElementById(`fw-val-${fieldKey}`);
+    if (valInput) {
+      if (valInput.tagName === 'SELECT') {
+        valInput.selectedIndex = 0;
+      } else {
+        valInput.value = '';
+      }
+    }
+    const invInput = document.getElementById(`fw-inv-${fieldKey}`);
+    if (invInput) invInput.checked = false;
+
+    if (fieldKey === 'cs') {
+      ['filter-modal-cs-established', 'filter-modal-cs-related', 'filter-modal-cs-new', 'filter-modal-cs-invalid', 'filter-modal-cs-untracked'].forEach(id => {
+        const el = document.getElementById(id);
+        if (el) el.checked = false;
+      });
+    }
+    if (fieldKey === 'natstate') {
+      ['filter-modal-nat-srcnat', 'filter-modal-nat-dstnat'].forEach(id => {
+        const el = document.getElementById(id);
+        if (el) el.checked = false;
+      });
+    }
+    if (fieldKey === 'days') {
+      ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].forEach(d => {
+        const el = document.getElementById(`fw-day-${d}`);
+        if (el) el.checked = true;
+      });
+    }
+  }
+}
+
+function setFwFieldVal(fieldKey, value, hasInvert = false) {
+  if (value !== undefined && value !== null && value !== '' && value !== 'all') {
+    toggleFwField(fieldKey, true);
+    let strVal = String(value);
+    if (hasInvert) {
+      const inv = strVal.startsWith('!');
+      if (inv) strVal = strVal.slice(1);
+      const invEl = document.getElementById(`fw-inv-${fieldKey}`);
+      if (invEl) invEl.checked = inv;
+    }
+    const valEl = document.getElementById(`fw-val-${fieldKey}`);
+    if (valEl) valEl.value = strVal;
+  } else {
+    toggleFwField(fieldKey, false);
+  }
+}
+
+function getFwFieldVal(fieldKey, hasInvert = false) {
+  const fieldBox = document.getElementById(`fw-field-${fieldKey}`);
+  if (!fieldBox || fieldBox.classList.contains('hidden')) return '';
+  const valEl = document.getElementById(`fw-val-${fieldKey}`);
+  let v = valEl ? valEl.value.trim() : '';
+  if (!v) return '';
+  if (hasInvert) {
+    const invEl = document.getElementById(`fw-inv-${fieldKey}`);
+    if (invEl && invEl.checked) {
+      v = '!' + v.replace(/^!/, '');
+    }
+  }
+  return v;
+}
+
+function toggleMaximizeFilterModal() {
+  const win = document.getElementById('filter-modal-window');
+  if (!win) return;
+  if (win.classList.contains('max-w-[720px]')) {
+    win.classList.remove('max-w-[720px]', 'max-h-[92vh]');
+    win.classList.add('max-w-[96vw]', 'h-[94vh]');
+  } else {
+    win.classList.remove('max-w-[96vw]', 'h-[94vh]');
+    win.classList.add('max-w-[720px]', 'max-h-[92vh]');
+  }
+}
+
 function openAddFilterModal() {
-  document.getElementById('filter-modal-title').innerText = 'Add Firewall Filter Rule';
+  switchFwRuleTab('general');
+  const badge = document.getElementById('fw-modal-badge');
+  if (badge) badge.innerText = 'New...';
   document.getElementById('filter-modal-id').value = '';
+  document.getElementById('filter-modal-enabled').checked = true;
+  document.getElementById('filter-modal-comment').value = '';
   document.getElementById('filter-modal-chain').value = 'forward';
   document.getElementById('filter-modal-action').value = 'accept';
-  document.getElementById('filter-modal-proto').value = 'all';
-  document.getElementById('filter-modal-src').value = '';
-  document.getElementById('filter-modal-dst').value = '';
-  document.getElementById('filter-modal-sport').value = '';
-  document.getElementById('filter-modal-dport').value = '';
-  document.getElementById('filter-modal-connmark').value = '';
-  document.getElementById('filter-modal-comment').value = '';
-  document.getElementById('filter-modal-disabled').checked = false;
-
-  document.getElementById('filter-modal-cs-established').checked = false;
-  document.getElementById('filter-modal-cs-related').checked = false;
-  document.getElementById('filter-modal-cs-new').checked = false;
-  document.getElementById('filter-modal-cs-invalid').checked = false;
-
-  populateInterfaceSelect('filter-modal-iniface');
-  populateInterfaceSelect('filter-modal-outiface');
-  if (document.getElementById('filter-modal-srclist-inv')) document.getElementById('filter-modal-srclist-inv').checked = false;
-  if (document.getElementById('filter-modal-dstlist-inv')) document.getElementById('filter-modal-dstlist-inv').checked = false;
-  populateAddressListSelect('filter-modal-srclist');
-  populateAddressListSelect('filter-modal-dstlist');
-
-  if (document.getElementById('filter-modal-srcgeoip-inv')) document.getElementById('filter-modal-srcgeoip-inv').checked = false;
-  if (document.getElementById('filter-modal-dstgeoip-inv')) document.getElementById('filter-modal-dstgeoip-inv').checked = false;
-  populateGeoIPSelect('filter-modal-srcgeoip');
-  populateGeoIPSelect('filter-modal-dstgeoip');
+  document.getElementById('filter-modal-log').checked = false;
+
+  populateInterfaceSelect('fw-val-iniface');
+  populateInterfaceSelect('fw-val-outiface');
+  populateAddressListSelect('fw-val-srclist');
+  populateAddressListSelect('fw-val-dstlist');
+
+  const allFieldKeys = [
+    'src', 'dst', 'srclist', 'dstlist', 'proto', 'sport', 'dport', 'anyport',
+    'iniface', 'outiface', 'inifacelist', 'outifacelist', 'pktmark', 'connmark',
+    'routingmark', 'conntype', 'cs', 'natstate',
+    'layer7', 'content', 'connbytes', 'connrate', 'pcc', 'srcmac',
+    'outbridgeport', 'inbridgeport', 'inbridgeportlist', 'outbridgeportlist',
+    'ipsec', 'tlshost', 'ingresspriority', 'priority', 'dscp', 'tcpmss',
+    'pktsize', 'random', 'tcpflags', 'icmpoptions', 'ipv4options', 'ttl',
+    'time', 'days', 'limit', 'dstlimit', 'psd', 'logprefix'
+  ];
+  allFieldKeys.forEach(k => toggleFwField(k, false));
+
+  resetCurrentRuleCounters();
   toggleFilterActionFields();
 
   document.getElementById('filter-modal').classList.remove('hidden');
@@ -2847,140 +2959,469 @@ function openEditFilterModal(id) {
   const r = (state.filterRules || []).find(rule => rule.id === id);
   if (!r) return;
 
-  document.getElementById('filter-modal-title').innerText = `Edit Filter Rule (#${r.order})`;
+  switchFwRuleTab('general');
+  const badge = document.getElementById('fw-modal-badge');
+  if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`;
+
   document.getElementById('filter-modal-id').value = r.id;
+  document.getElementById('filter-modal-enabled').checked = !r.disabled;
+  document.getElementById('filter-modal-comment').value = r.comment || '';
   document.getElementById('filter-modal-chain').value = r.chain || 'forward';
   document.getElementById('filter-modal-action').value = r.action || 'accept';
-  document.getElementById('filter-modal-proto').value = r.protocol || 'all';
-  document.getElementById('filter-modal-src').value = r.src_address || '';
-  document.getElementById('filter-modal-dst').value = r.dst_address || '';
-  document.getElementById('filter-modal-sport').value = r.src_port || '';
-  document.getElementById('filter-modal-dport').value = r.dst_port || '';
-  document.getElementById('filter-modal-connmark').value = r.connection_mark || '';
-  document.getElementById('filter-modal-comment').value = r.comment || '';
-  document.getElementById('filter-modal-disabled').checked = !!r.disabled;
+  document.getElementById('filter-modal-log').checked = !!r.log;
+
+  populateInterfaceSelect('fw-val-iniface', (r.in_interface || '').replace(/^!/, ''));
+  populateInterfaceSelect('fw-val-outiface', (r.out_interface || '').replace(/^!/, ''));
+  populateAddressListSelect('fw-val-srclist', (r.src_address_list || '').replace(/^!/, ''));
+  populateAddressListSelect('fw-val-dstlist', (r.dst_address_list || '').replace(/^!/, ''));
+
+  setFwFieldVal('src', r.src_address, true);
+  setFwFieldVal('dst', r.dst_address, true);
+  setFwFieldVal('srclist', r.src_address_list, true);
+  setFwFieldVal('dstlist', r.dst_address_list, true);
+  if (r.protocol && r.protocol !== 'all') {
+    setFwFieldVal('proto', r.protocol, true);
+  } else {
+    toggleFwField('proto', false);
+  }
+  setFwFieldVal('sport', r.src_port, true);
+  setFwFieldVal('dport', r.dst_port, true);
+  setFwFieldVal('anyport', r.any_port, true);
+  setFwFieldVal('iniface', r.in_interface, true);
+  setFwFieldVal('outiface', r.out_interface, true);
+  setFwFieldVal('inifacelist', r.in_interface_list, true);
+  setFwFieldVal('outifacelist', r.out_interface_list, true);
+  setFwFieldVal('pktmark', r.packet_mark, true);
+  setFwFieldVal('connmark', r.connection_mark, true);
+  setFwFieldVal('routingmark', r.routing_mark, true);
+  setFwFieldVal('conntype', r.connection_type, false);
 
   const states = r.connection_state || [];
-  document.getElementById('filter-modal-cs-established').checked = states.includes('established');
-  document.getElementById('filter-modal-cs-related').checked = states.includes('related');
-  document.getElementById('filter-modal-cs-new').checked = states.includes('new');
-  document.getElementById('filter-modal-cs-invalid').checked = states.includes('invalid');
-
-  populateInterfaceSelect('filter-modal-iniface', r.in_interface);
-  populateInterfaceSelect('filter-modal-outiface', r.out_interface);
+  if (states.length > 0) {
+    toggleFwField('cs', true);
+    document.getElementById('filter-modal-cs-established').checked = states.includes('established');
+    document.getElementById('filter-modal-cs-related').checked = states.includes('related');
+    document.getElementById('filter-modal-cs-new').checked = states.includes('new');
+    document.getElementById('filter-modal-cs-invalid').checked = states.includes('invalid');
+    document.getElementById('filter-modal-cs-untracked').checked = states.includes('untracked');
+  } else {
+    toggleFwField('cs', false);
+  }
 
-  const srcInv = (r.src_address_list || '').startsWith('!');
-  const dstInv = (r.dst_address_list || '').startsWith('!');
-  if (document.getElementById('filter-modal-srclist-inv')) document.getElementById('filter-modal-srclist-inv').checked = srcInv;
-  if (document.getElementById('filter-modal-dstlist-inv')) document.getElementById('filter-modal-dstlist-inv').checked = dstInv;
-  populateAddressListSelect('filter-modal-srclist', r.src_address_list || '');
-  populateAddressListSelect('filter-modal-dstlist', r.dst_address_list || '');
+  const natStates = r.connection_nat_state || [];
+  if (natStates.length > 0) {
+    toggleFwField('natstate', true);
+    document.getElementById('filter-modal-nat-srcnat').checked = natStates.includes('srcnat');
+    document.getElementById('filter-modal-nat-dstnat').checked = natStates.includes('dstnat');
+  } else {
+    toggleFwField('natstate', false);
+  }
+
+  // Advanced tab fields
+  setFwFieldVal('layer7', r.layer7_protocol, false);
+  setFwFieldVal('content', r.content, false);
+  setFwFieldVal('connbytes', r.connection_bytes, false);
+  setFwFieldVal('connrate', r.connection_rate, false);
+  setFwFieldVal('pcc', r.pcc, false);
+  setFwFieldVal('srcmac', r.src_mac_address, true);
+  setFwFieldVal('outbridgeport', r.out_bridge_port, false);
+  setFwFieldVal('inbridgeport', r.in_bridge_port, false);
+  setFwFieldVal('inbridgeportlist', r.in_bridge_port_list, false);
+  setFwFieldVal('outbridgeportlist', r.out_bridge_port_list, false);
+  setFwFieldVal('ipsec', r.ipsec_policy, false);
+  setFwFieldVal('tlshost', r.tls_host, false);
+  setFwFieldVal('ingresspriority', r.ingress_priority, false);
+  setFwFieldVal('priority', r.priority, false);
+  setFwFieldVal('dscp', r.dscp, false);
+  setFwFieldVal('tcpmss', r.tcp_mss, false);
+  if (r.tos) {
+    const tosEl = document.getElementById('fw-val-tos');
+    if (tosEl) tosEl.value = r.tos;
+  }
+  setFwFieldVal('pktsize', r.packet_size, false);
+  setFwFieldVal('random', r.random, false);
+  setFwFieldVal('tcpflags', r.tcp_flags, false);
+  setFwFieldVal('icmpoptions', r.icmp_options, false);
+  setFwFieldVal('ipv4options', r.ipv4_options, false);
+  setFwFieldVal('ttl', r.ttl, false);
+
+  // Extra tab fields
+  setFwFieldVal('time', r.time, false);
+  if (r.days) {
+    toggleFwField('days', true);
+    ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].forEach(d => {
+      const chk = document.getElementById(`fw-day-${d}`);
+      if (chk) chk.checked = r.days.includes(d);
+    });
+  } else {
+    toggleFwField('days', false);
+  }
+  setFwFieldVal('limit', r.limit, false);
+  setFwFieldVal('dstlimit', r.dst_limit, false);
+  setFwFieldVal('psd', r.psd, false);
+
+  // Action fields
+  if (r.reject_with) {
+    const rwEl = document.getElementById('filter-modal-rejectwith');
+    if (rwEl) rwEl.value = r.reject_with;
+  }
+  if (r.jump_target) {
+    const jtEl = document.getElementById('filter-modal-jumptarget');
+    if (jtEl) jtEl.value = r.jump_target;
+  }
+  if (r.address_list) {
+    const alEl = document.getElementById('filter-modal-action-addrlist');
+    if (alEl) alEl.value = r.address_list;
+  }
+  if (r.address_list_timeout) {
+    const toEl = document.getElementById('filter-modal-action-timeout');
+    if (toEl) toEl.value = r.address_list_timeout;
+  }
+  setFwFieldVal('logprefix', r.log_prefix, false);
+
+  // Statistics
+  const bEl = document.getElementById('fw-stat-bytes');
+  const pEl = document.getElementById('fw-stat-packets');
+  if (bEl) bEl.value = formatTraffic(r.bytes || 0).replace('/s', '');
+  if (pEl) pEl.value = (r.packets || 0).toLocaleString();
+  state.fwLastBytes = r.bytes || 0;
+  state.fwLastPackets = r.packets || 0;
+  state.fwLastTimestamp = Date.now();
+  state.fwStatRateHistory = new Array(30).fill(0);
+  state.fwStatPktHistory = new Array(30).fill(0);
+  renderFwSparkline('fw-stat-rate-svg', state.fwStatRateHistory, 'bps', 'fw-stat-legend-rate');
+  renderFwSparkline('fw-stat-pktrate-svg', state.fwStatPktHistory, 'p/s', 'fw-stat-legend-pktrate');
 
-  const srcGeoInv = (r.src_geoip || '').startsWith('!');
-  const dstGeoInv = (r.dst_geoip || '').startsWith('!');
-  if (document.getElementById('filter-modal-srcgeoip-inv')) document.getElementById('filter-modal-srcgeoip-inv').checked = srcGeoInv;
-  if (document.getElementById('filter-modal-dstgeoip-inv')) document.getElementById('filter-modal-dstgeoip-inv').checked = dstGeoInv;
-  populateGeoIPSelect('filter-modal-srcgeoip', r.src_geoip || '');
-  populateGeoIPSelect('filter-modal-dstgeoip', r.dst_geoip || '');
   toggleFilterActionFields();
-
   document.getElementById('filter-modal').classList.remove('hidden');
 }
 
 function closeFilterModal() {
+  stopFwStatPolling();
   document.getElementById('filter-modal').classList.add('hidden');
 }
 
 function toggleFilterActionFields() {
-  const act = document.getElementById('filter-modal-action').value;
+  const act = document.getElementById('filter-modal-action')?.value || 'accept';
   const rejectBox = document.getElementById('filter-action-reject-box');
+  const jumpBox = document.getElementById('filter-action-jump-box');
+  const addrBox = document.getElementById('filter-action-addrlist-box');
+
   if (rejectBox) {
     if (act === 'reject') rejectBox.classList.remove('hidden');
     else rejectBox.classList.add('hidden');
   }
+  if (jumpBox) {
+    if (act === 'jump') jumpBox.classList.remove('hidden');
+    else jumpBox.classList.add('hidden');
+  }
+  if (addrBox) {
+    if (act === 'add-dst-to-address-list' || act === 'add-src-to-address-list') addrBox.classList.remove('hidden');
+    else addrBox.classList.add('hidden');
+  }
 }
 
-async function submitSaveFilterRule(e) {
-  e.preventDefault();
+function copyFilterRule() {
+  document.getElementById('filter-modal-id').value = '';
+  const badge = document.getElementById('fw-modal-badge');
+  if (badge) badge.innerText = 'New...';
+  showToast('Rule Cloned', 'Ready to save as a new rule copy', 'info');
+}
+
+function resetCurrentRuleCounters() {
+  state.fwStatRateHistory = new Array(30).fill(0);
+  state.fwStatPktHistory = new Array(30).fill(0);
+  state.fwLastBytes = 0;
+  state.fwLastPackets = 0;
+  state.fwLastTimestamp = Date.now();
+  const bEl = document.getElementById('fw-stat-bytes');
+  const pEl = document.getElementById('fw-stat-packets');
+  const rEl = document.getElementById('fw-stat-rate');
+  const prEl = document.getElementById('fw-stat-pktrate');
+  if (bEl) bEl.value = '0 B';
+  if (pEl) pEl.value = '0';
+  if (rEl) rEl.value = '0 bps';
+  if (prEl) prEl.value = '0 p/s';
+  renderFwSparkline('fw-stat-rate-svg', state.fwStatRateHistory, 'bps', 'fw-stat-legend-rate');
+  renderFwSparkline('fw-stat-pktrate-svg', state.fwStatPktHistory, 'p/s', 'fw-stat-legend-pktrate');
+  showToast('Counters Reset', 'Rule counter display reset to 0', 'info');
+}
+
+async function resetAllFwCounters() {
+  try {
+    const res = await fetch(`${API_BASE}/firewall/reset-counters`, { method: 'POST' });
+    if (res.ok) {
+      resetCurrentRuleCounters();
+      showToast('All Counters Reset', 'All firewall counters zeroed', 'success');
+      loadFirewall(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function applyFilterRule(closeAfter = false) {
   const id = document.getElementById('filter-modal-id').value;
   const chain = document.getElementById('filter-modal-chain').value;
   const action = document.getElementById('filter-modal-action').value;
-  const proto = document.getElementById('filter-modal-proto').value;
-  const src = document.getElementById('filter-modal-src').value.trim();
-  const dst = document.getElementById('filter-modal-dst').value.trim();
-  const sport = document.getElementById('filter-modal-sport').value.trim();
-  const dport = document.getElementById('filter-modal-dport').value.trim();
-  const inIface = document.getElementById('filter-modal-iniface').value;
-  const outIface = document.getElementById('filter-modal-outiface').value;
-  const connmark = document.getElementById('filter-modal-connmark').value.trim();
-
-  let srclist = document.getElementById('filter-modal-srclist')?.value || '';
-  if (srclist && document.getElementById('filter-modal-srclist-inv')?.checked) {
-    srclist = '!' + srclist.replace(/^!/, '');
-  }
-  let dstlist = document.getElementById('filter-modal-dstlist')?.value || '';
-  if (dstlist && document.getElementById('filter-modal-dstlist-inv')?.checked) {
-    dstlist = '!' + dstlist.replace(/^!/, '');
-  }
+  const comment = document.getElementById('filter-modal-comment').value.trim();
+  const enabled = document.getElementById('filter-modal-enabled').checked;
+
+  const src = getFwFieldVal('src', true);
+  const dst = getFwFieldVal('dst', true);
+  const srclist = getFwFieldVal('srclist', true);
+  const dstlist = getFwFieldVal('dstlist', true);
+  const proto = getFwFieldVal('proto', true);
+  const sport = getFwFieldVal('sport', true);
+  const dport = getFwFieldVal('dport', true);
+  const anyport = getFwFieldVal('anyport', true);
+  const iniface = getFwFieldVal('iniface', true);
+  const outiface = getFwFieldVal('outiface', true);
+  const inifacelist = getFwFieldVal('inifacelist', true);
+  const outifacelist = getFwFieldVal('outifacelist', true);
+  const pktmark = getFwFieldVal('pktmark', true);
+  const connmark = getFwFieldVal('connmark', true);
+  const routingmark = getFwFieldVal('routingmark', true);
+  const conntype = getFwFieldVal('conntype', false);
 
-  let srcgeoip = document.getElementById('filter-modal-srcgeoip')?.value || '';
-  if (srcgeoip && document.getElementById('filter-modal-srcgeoip-inv')?.checked) {
-    srcgeoip = '!' + srcgeoip.replace(/^!/, '');
-  }
-  let dstgeoip = document.getElementById('filter-modal-dstgeoip')?.value || '';
-  if (dstgeoip && document.getElementById('filter-modal-dstgeoip-inv')?.checked) {
-    dstgeoip = '!' + dstgeoip.replace(/^!/, '');
+  const states = [];
+  const csBox = document.getElementById('fw-field-cs');
+  if (csBox && !csBox.classList.contains('hidden')) {
+    if (document.getElementById('filter-modal-cs-established')?.checked) states.push('established');
+    if (document.getElementById('filter-modal-cs-related')?.checked) states.push('related');
+    if (document.getElementById('filter-modal-cs-new')?.checked) states.push('new');
+    if (document.getElementById('filter-modal-cs-invalid')?.checked) states.push('invalid');
+    if (document.getElementById('filter-modal-cs-untracked')?.checked) states.push('untracked');
   }
 
-  const comment = document.getElementById('filter-modal-comment').value.trim();
-  const disabled = document.getElementById('filter-modal-disabled').checked;
-  const rejectWith = document.getElementById('filter-modal-rejectwith')?.value || '';
+  const natStates = [];
+  const natBox = document.getElementById('fw-field-natstate');
+  if (natBox && !natBox.classList.contains('hidden')) {
+    if (document.getElementById('filter-modal-nat-srcnat')?.checked) natStates.push('srcnat');
+    if (document.getElementById('filter-modal-nat-dstnat')?.checked) natStates.push('dstnat');
+  }
 
-  const states = [];
-  if (document.getElementById('filter-modal-cs-established').checked) states.push('established');
-  if (document.getElementById('filter-modal-cs-related').checked) states.push('related');
-  if (document.getElementById('filter-modal-cs-new').checked) states.push('new');
-  if (document.getElementById('filter-modal-cs-invalid').checked) states.push('invalid');
+  const payload = {
+    id,
+    chain,
+    action,
+    protocol: proto || 'all',
+    src_address: src,
+    dst_address: dst,
+    src_port: sport,
+    dst_port: dport,
+    any_port: anyport,
+    in_interface: iniface,
+    out_interface: outiface,
+    in_interface_list: inifacelist,
+    out_interface_list: outifacelist,
+    packet_mark: pktmark,
+    connection_mark: connmark,
+    routing_mark: routingmark,
+    connection_type: conntype,
+    connection_state: states,
+    connection_nat_state: natStates,
+    src_address_list: srclist,
+    dst_address_list: dstlist,
+    comment,
+    disabled: !enabled,
+
+    // Advanced tab
+    layer7_protocol: getFwFieldVal('layer7', false),
+    content: getFwFieldVal('content', false),
+    connection_bytes: getFwFieldVal('connbytes', false),
+    connection_rate: getFwFieldVal('connrate', false),
+    pcc: getFwFieldVal('pcc', false),
+    src_mac_address: getFwFieldVal('srcmac', true),
+    out_bridge_port: getFwFieldVal('outbridgeport', false),
+    in_bridge_port: getFwFieldVal('inbridgeport', false),
+    in_bridge_port_list: getFwFieldVal('inbridgeportlist', false),
+    out_bridge_port_list: getFwFieldVal('outbridgeportlist', false),
+    ipsec_policy: getFwFieldVal('ipsec', false),
+    tls_host: getFwFieldVal('tlshost', false),
+    ingress_priority: getFwFieldVal('ingresspriority', false),
+    priority: getFwFieldVal('priority', false),
+    dscp: getFwFieldVal('dscp', false),
+    tcp_mss: getFwFieldVal('tcpmss', false),
+    tos: document.getElementById('fw-val-tos')?.value || '',
+    packet_size: getFwFieldVal('pktsize', false),
+    random: getFwFieldVal('random', false),
+    tcp_flags: getFwFieldVal('tcpflags', false),
+    icmp_options: getFwFieldVal('icmpoptions', false),
+    ipv4_options: getFwFieldVal('ipv4options', false),
+    ttl: getFwFieldVal('ttl', false),
+
+    // Extra tab
+    time: getFwFieldVal('time', false),
+    limit: getFwFieldVal('limit', false),
+    dst_limit: getFwFieldVal('dstlimit', false),
+    psd: getFwFieldVal('psd', false),
+
+    // Action tab
+    reject_with: action === 'reject' ? (document.getElementById('filter-modal-rejectwith')?.value || '') : '',
+    jump_target: action === 'jump' ? (document.getElementById('filter-modal-jumptarget')?.value || '') : '',
+    address_list: action.startsWith('add-') ? (document.getElementById('filter-modal-action-addrlist')?.value || '') : '',
+    address_list_timeout: action.startsWith('add-') ? (document.getElementById('filter-modal-action-timeout')?.value || '') : '',
+    log: document.getElementById('filter-modal-log')?.checked || false,
+    log_prefix: getFwFieldVal('logprefix', false)
+  };
 
   try {
     const res = await fetch(`${API_BASE}/firewall/filter`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        id,
-        chain,
-        action,
-        reject_with: rejectWith,
-        protocol: proto,
-        src_address: src,
-        dst_address: dst,
-        src_port: sport,
-        dst_port: dport,
-        in_interface: inIface,
-        out_interface: outIface,
-        connection_state: states,
-        connection_mark: connmark,
-        src_address_list: srclist,
-        dst_address_list: dstlist,
-        src_geoip: srcgeoip,
-        dst_geoip: dstgeoip,
-        comment,
-        disabled
-      })
+      body: JSON.stringify(payload)
     });
     if (res.ok) {
+      const data = await res.json();
       showToast('Filter Rule Saved', 'Rule applied successfully to firewall', 'success');
-      closeFilterModal();
       loadFirewall(false);
+      if (closeAfter) {
+        closeFilterModal();
+      } else {
+        if (data.id) {
+          document.getElementById('filter-modal-id').value = data.id;
+          const badge = document.getElementById('fw-modal-badge');
+          if (badge) badge.innerText = `#${data.id}`;
+        }
+      }
     } else {
       const err = await res.json();
-      showToast('Error', err.message, 'error');
+      showToast('Error', err.message || 'Failed to save rule', 'error');
     }
   } catch (err) {
     showToast('Error', err.message, 'error');
   }
 }
 
+function submitSaveFilterRule(e) {
+  if (e && e.preventDefault) e.preventDefault();
+  return applyFilterRule(true);
+}
+
+function startFwStatPolling() {
+  stopFwStatPolling();
+  pollRuleStats();
+  state.fwStatInterval = setInterval(pollRuleStats, 1000);
+}
+
+function stopFwStatPolling() {
+  if (state.fwStatInterval) {
+    clearInterval(state.fwStatInterval);
+    state.fwStatInterval = null;
+  }
+}
+
+async function pollRuleStats() {
+  const id = document.getElementById('filter-modal-id')?.value;
+  if (!id) {
+    renderFwSparkline('fw-stat-rate-svg', state.fwStatRateHistory || new Array(30).fill(0), 'bps', 'fw-stat-legend-rate');
+    renderFwSparkline('fw-stat-pktrate-svg', state.fwStatPktHistory || new Array(30).fill(0), 'p/s', 'fw-stat-legend-pktrate');
+    return;
+  }
+
+  try {
+    const res = await fetch(`${API_BASE}/firewall/filter`);
+    if (!res.ok) return;
+    const rules = await res.json();
+    const r = rules.find(x => x.id === id);
+    if (!r) return;
+
+    const now = Date.now();
+    const dt = Math.max(0.2, (now - (state.fwLastTimestamp || now)) / 1000);
+    state.fwLastTimestamp = now;
+
+    const curBytes = r.bytes || 0;
+    const curPackets = r.packets || 0;
+
+    const deltaBytes = Math.max(0, curBytes - (state.fwLastBytes || curBytes));
+    const deltaPkts = Math.max(0, curPackets - (state.fwLastPackets || curPackets));
+
+    state.fwLastBytes = curBytes;
+    state.fwLastPackets = curPackets;
+
+    const rateBps = Math.round((deltaBytes * 8) / dt);
+    const ratePkt = Math.round(deltaPkts / dt);
+
+    if (!state.fwStatRateHistory) state.fwStatRateHistory = new Array(30).fill(0);
+    if (!state.fwStatPktHistory) state.fwStatPktHistory = new Array(30).fill(0);
+
+    state.fwStatRateHistory.push(rateBps);
+    if (state.fwStatRateHistory.length > 30) state.fwStatRateHistory.shift();
+
+    state.fwStatPktHistory.push(ratePkt);
+    if (state.fwStatPktHistory.length > 30) state.fwStatPktHistory.shift();
+
+    const bEl = document.getElementById('fw-stat-bytes');
+    const pEl = document.getElementById('fw-stat-packets');
+    const rEl = document.getElementById('fw-stat-rate');
+    const prEl = document.getElementById('fw-stat-pktrate');
+
+    if (bEl) bEl.value = formatTraffic(curBytes).replace('/s', '');
+    if (pEl) pEl.value = curPackets.toLocaleString();
+    if (rEl) rEl.value = formatBps(rateBps);
+    if (prEl) prEl.value = `${ratePkt.toLocaleString()} p/s`;
+
+    renderFwSparkline('fw-stat-rate-svg', state.fwStatRateHistory, 'bps', 'fw-stat-legend-rate');
+    renderFwSparkline('fw-stat-pktrate-svg', state.fwStatPktHistory, 'p/s', 'fw-stat-legend-pktrate');
+  } catch (e) {
+    console.error('Error polling rule stats', e);
+  }
+}
+
+function formatBps(bps) {
+  if (bps >= 1000000000) return (bps / 1000000000).toFixed(1) + ' Gbps';
+  if (bps >= 1000000) return (bps / 1000000).toFixed(1) + ' Mbps';
+  if (bps >= 1000) return (bps / 1000).toFixed(1) + ' kbps';
+  return bps + ' bps';
+}
+
+function renderFwSparkline(svgId, dataArray, unit, legendId) {
+  const svg = document.getElementById(svgId);
+  if (!svg) return;
+
+  const w = 500;
+  const h = 100;
+  const paddingRight = 45;
+  const graphWidth = w - paddingRight;
+
+  const maxVal = Math.max(5, ...dataArray);
+  const currentVal = dataArray[dataArray.length - 1] || 0;
+
+  const leg = document.getElementById(legendId);
+  if (leg) {
+    leg.innerText = unit === 'bps' ? formatBps(currentVal) : `${currentVal.toLocaleString()} p/s`;
+  }
+
+  let gridLines = '';
+  for (let i = 0; i <= 5; i++) {
+    const y = 8 + (i * (h - 18) / 5);
+    const valAtLevel = Math.round(maxVal * (5 - i) / 5);
+    const label = unit === 'bps' ? formatBps(valAtLevel) : `${valAtLevel} p/s`;
+    gridLines += `
+      <line x1="0" y1="${y.toFixed(1)}" x2="${graphWidth}" y2="${y.toFixed(1)}" stroke="#1e2c3e" stroke-width="1" />
+      <text x="${w - 2}" y="${(y + 3).toFixed(1)}" fill="#64748b" font-size="9" font-family="monospace" text-anchor="end">${label}</text>
+    `;
+  }
+
+  const step = graphWidth / (dataArray.length - 1);
+  const points = dataArray.map((val, idx) => {
+    const x = idx * step;
+    const y = (h - 10) - (val / maxVal) * (h - 20);
+    return `${x.toFixed(1)},${y.toFixed(1)}`;
+  });
+
+  const polylineStr = points.join(' ');
+  const areaPath = `M 0,${h - 10} L ${polylineStr} L ${graphWidth},${h - 10} Z`;
+
+  svg.innerHTML = `
+    ${gridLines}
+    <path d="${areaPath}" fill="rgba(14, 165, 233, 0.12)" />
+    <polyline fill="none" stroke="#38bdf8" stroke-width="1.8" stroke-linejoin="round" points="${polylineStr}" />
+    <circle cx="${graphWidth}" cy="${points[points.length - 1].split(',')[1]}" r="3" fill="#38bdf8" />
+  `;
+}
+
 async function moveFilterRule(id, dir) {
   try {
     const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}/move`, {

File diff suppressed because it is too large
+ 907 - 153
internal/web/static/index.html


Some files were not shown because too many files changed in this diff