routerconsole/jsp/js/tunnelpeercount.js

/**
 * @module tunnelpeercount
 * @description Handles automatic and manual refresh with table sorting and
 * advanced filtering for the /tunnelpeercount page. Supports key=value filter
 * syntax (tier=, cc=, ip=, hash=, etc.) with URL and localStorage persistence.
 * @author dr|z3d
 * @license AGPL3 or later
 */

import { refreshElements } from "/js/refreshElements.js";

const d = document;
const header = d.getElementById("peercount");
const peers = d.getElementById("allPeers");
const refreshButton = d.getElementById("refreshPage");
const tunnels = d.getElementById("tunnelPeerCount");
const footer = tunnels?.querySelector(".tablefooter");
const REFRESH_INTERVAL = 10 * 1000;
const sorter = tunnels ? new Tablesort(tunnels, { descending: true }) : null;

let debugging = false;
let refreshId;
let filterListener = false;
let displayed = 0;
let debounceTimeout;

/**
 * Refreshes tunnel peer data elements via the refreshElements system.
 * @async
 * @function updateTunnels
 * @returns {Promise<void>}
 */
const updateTunnels = async () => {
  if (tunnels) {
    // Rows are diffed in a worker; the footer total refreshes as a fragment
    refreshElements("#allPeers", "/tunnelpeercount", REFRESH_INTERVAL, false, false, "allPeers", "allPeers");
    refreshElements(".tablefooter", "/tunnelpeercount", REFRESH_INTERVAL, false, false, "tableFooter");
  }
  checkForCachedFilter();
  applyQueryParamsFilter();
};

/**
 * Starts the periodic data refresh for tunnel peer counts.
 * @function initRefresh
 * @returns {void}
 */
const initRefresh = () => {
  updateTunnels();
};

/**
 * Creates and appends the filter input UI with clear button and localStorage persistence.
 * @function initFilter
 * @returns {void}
 */
function initFilter() {
  const table = tunnels;
  const addFilter = () => {
    const tnlFilter = d.createElement("span");
    tnlFilter.id = "tnlfilter";
    const filterInput = d.createElement("input");
    filterInput.type = "text";
    filterInput.id = "filterInput";
    filterInput.value = localStorage.getItem("filterValue") || "";
    tnlFilter.appendChild(filterInput);
    const clearFilter = d.createElement("button");
    clearFilter.textContent = "X";
    clearFilter.addEventListener("click", () => {
      filterInput.value = "";
      localStorage.removeItem("filterValue");
      history.replaceState({}, d.title, window.location.pathname);
      table.querySelectorAll(".lazy").forEach(row => row.style.display = "table-row");
      applyFilter("");
      displayPeerCount();
    });
    tnlFilter.appendChild(clearFilter);
    header.appendChild(tnlFilter);
    applyFilter(filterInput.value);
  };
  if (!d.getElementById("filterInput")) {
    addFilter();
  }
}

/**
 * Restores the filter value from localStorage and applies it.
 * @function checkForCachedFilter
 * @returns {void}
 */
function checkForCachedFilter() {
  const filterInput = d.getElementById("filterInput");
  if (filterInput) {
    filterInput.value = localStorage.getItem("filterValue") || "";
    if (filterInput.value) { applyFilter(filterInput.value); }
  }
  displayPeerCount();
}

/**
 * Filters tunnel peer rows based on key=value filter syntax.
 * Supports tier, cc/country, hash/id, hostname/host/h, ip, and v/version keys.
 * @function applyFilter
 * @param {string} filterValue - The filter string (plain text or key=value)
 * @returns {void}
 */
function applyFilter(filterValue) {
  const rows = peers.querySelectorAll(".lazy");
  displayed = 0;

  const filterParts = filterValue.split("=");
  const filterKey = filterParts[0].trim();
  const filterTerm = filterParts.length > 1 ? filterParts[1].trim() : filterValue.trim();

  rows.forEach(row => {
    const country = row.querySelector("td:first-child span.cc")?.textContent || "";
    const routerId = row.querySelector("td:nth-child(2) a")?.textContent || "";
    const version = row.querySelector("td:nth-child(3) a")?.textContent || "";
    const bwTier = row.querySelector("td:nth-child(4) a")?.textContent || "";
    const ipAddress = row.querySelector("td:nth-child(5) .ipaddress")?.textContent || "";
    const rlookup = row.querySelector("td:nth-child(6) .rlookup")?.textContent || "";

    let shouldDisplay = false;

    switch (filterKey) {
      case "tier":
        shouldDisplay = bwTier.includes(filterTerm);
        break;
      case "cc":
      case "country":
        shouldDisplay = country.includes(filterTerm.toUpperCase());
        break;
      case "hash":
      case "id":
        shouldDisplay = routerId.includes(filterTerm);
        break;
      case "hostname":
      case "host":
      case "h":
        shouldDisplay = rlookup.includes(filterTerm);
        break;
      case "ip":
        shouldDisplay = ipAddress.includes(filterTerm);
        break;
      case "v":
      case "version":
        shouldDisplay = version.includes(filterTerm);
        break;
      default:
        shouldDisplay = country.includes(filterTerm) || routerId.includes(filterTerm) ||
                        version.includes(filterTerm) || bwTier.includes(filterTerm) ||
                        ipAddress.includes(filterTerm) || (rlookup && rlookup.includes(filterTerm));
        break;
    }

    row.style.display = shouldDisplay ? "table-row" : "none";
    if (shouldDisplay) {displayed++;}
  });

  if (filterValue === "") {displayPeerCount();}
  else {
    clearTimeout(debounceTimeout);
    debounceTimeout = setTimeout(() => { displayPeerCount(); }, 500);
  }
}

/**
 * Updates the displayed peer count in the navigation tab based on filtered results.
 * @function displayPeerCount
 * @returns {void}
 */
function displayPeerCount() {
  if (parseInt(displayed, 10) >= 0) {
    const navTab = d.querySelector(".confignav .tab2");
    const totalCountCell = tunnels.querySelector("tfoot .tablefooter td:first-child")?.textContent || "";
    const totalCount = totalCountCell.match(/\d+/)?.[0] || 0;
    const filterCount = d.querySelector("#filterCount") || d.createElement("span");
    const filterInput = d.getElementById("filterInput");
    const filterValue = filterInput?.value || "";
    if (filterInput) {
      filterCount.id = "filterCount";
      if (displayed > 0) {
        filterCount.textContent = ` (${displayed})`;
        if (!navTab.querySelector("#filterCount")) {navTab.appendChild(filterCount);}
        d.body.classList.remove("noresults");
      } else {
        filterCount.textContent = "";
        if (navTab.querySelector("#filterCount")) {navTab.removeChild(filterCount);}
        d.body.classList.add("noresults");
      }
    } else {filterCount.textContent = ` (${totalCount})`;}
  }
}

/**
   * Attaches an input listener to the filter field for real-time filtering
   * and URL synchronization.
   * @function addFilterListener
   * @returns {void}
   */
function addFilterListener() {
  const filterInput = d.getElementById("filterInput");
  if (filterInput) {
    filterInput.addEventListener("input", event => {
      const filterValue = event.target.value;
      if (filterValue === "") {localStorage.removeItem("filterValue");}
      else {localStorage.setItem("filterValue", filterValue);}
      applyFilter(filterValue);
      updateURLWithFilter(filterValue);
    });
    filterListener = true;
  }
}

/**
   * Applies filter values from URL query parameters on page load.
   * @function applyQueryParamsFilter
   * @returns {void}
   */
function applyQueryParamsFilter() {
  const urlParams = new URLSearchParams(window.location.search);
  const filterParam = urlParams.get("filter");
  const typeParam = urlParams.get("type");
  if (filterParam) {
    const filterInput = d.getElementById("filterInput");
    if (filterInput) {
      if (typeParam && ["tier", "cc", "country", "hash", "id", "h", "hostname", "host", "ip", "v", "version"].includes(typeParam)) {
        filterInput.value = `${typeParam}=${filterParam}`;
        localStorage.setItem("filterValue", `${typeParam}=${filterParam}`);
        applyFilter(`${typeParam}=${filterParam}`);
      } else {
        filterInput.value = filterParam;
        localStorage.setItem("filterValue", filterParam);
        applyFilter(filterParam);
      }
    }
  } else {
    const filterValue = localStorage.getItem("filterValue") || "";
    if (filterValue) {
      const filterInput = d.getElementById("filterInput");
      if (filterInput) {
        filterInput.value = filterValue;
        applyFilter(filterValue);
        updateURLWithFilter(filterValue);
      }
    }
  }
}

/**
   * Updates the browser URL with the current filter value using replaceState.
   * @function updateURLWithFilter
   * @param {string} filterValue - The current filter string
   * @returns {void}
   */
function updateURLWithFilter(filterValue) {
  const urlParams = new URLSearchParams(window.location.search);
  if (filterValue) {
    const filterParts = filterValue.split("=");
    const filterKey = filterParts[0].trim();
    const filterTerm = filterParts.length > 1 ? filterParts[1].trim() : filterValue.trim();

    if (["tier", "cc", "country", "hash", "id", "h", "hostname", "host", "ip", "v", "version"].includes(filterKey)) {
      urlParams.set("filter", filterTerm);
      urlParams.set("type", filterKey);
    } else {
      urlParams.set("filter", filterValue);
      urlParams.delete("type");
    }
  } else {
    urlParams.delete("filter");
    urlParams.delete("type");
  }
  history.replaceState({}, d.title, `${window.location.pathname}${urlParams.toString() ? `?${urlParams.toString()}` : ""}`);
}

(function init() {
  d.addEventListener("DOMContentLoaded", () => {
    progressx.hide();
    initRefresh();
    initFilter();
    if (!filterListener) {addFilterListener();}
    refreshButton?.addEventListener("click", async () => { await updateTunnels(); });
    refreshButton?.addEventListener("mouseenter", () => { refreshButton?.removeAttribute("href"); });
    tunnels?.addEventListener("beforeSort", () => { progressx.show(theme); });
    tunnels?.addEventListener("afterSort", () => { progressx.hide(); checkForCachedFilter(); });
    document.addEventListener("refreshComplete", () => { sorter?.refresh(); });
  });
})();