routerconsole/jsp/js/profiles.js

/**
 * @module profiles
 * @description Handles auto-refresh, table sorting, and session ban summaries
 * for the /profiles pages, including profile lists, floodfill profiles, and banlist.
 * @author dr|z3d
 * @license AGPL3 or later
 */

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

(function() {
  const banlist = document.getElementById("banlist");
  const banBody = document.getElementById("sessionBanlist");
  const bfoot = document.getElementById("sessionBanlistFooter");
  const ff = document.getElementById("floodfills");
  const ffprofiles = document.getElementById("ffProfiles");
  const main = document.getElementById("profiles");
  const pbody = document.getElementById("pbody");
  const plist = document.getElementById("profilelist");
  const rings = document.getElementById("profilestats");
  const sessionBans = document.getElementById("sbans");
  const uri = window.location.search.substring(1) !== "" ? window.location.pathname + "?" + window.location.search.substring(1) : window.location.pathname;
  let sorterFF = null;
  let sorterP = null;
  let sorterBans = null;
  /** Reasons hidden from the ban table (auto-disabled or toggled off). */
  const disabledReasons = new Set();
  /** Reasons the user explicitly enabled despite any auto-disable threshold. */
  const userForcedActive = new Set();
  const LS_KEY = "profiles_banFilter_" + location.search;

  /**
   * Persist filter state to localStorage so it survives page refresh.
   * Saves both disabled-reason and forced-active sets separately.
   * @function saveFilterState
   * @returns {void}
   */
  function saveFilterState() {
    try {
      localStorage.setItem(LS_KEY, JSON.stringify([...disabledReasons]));
      localStorage.setItem(LS_KEY + "_forced", JSON.stringify([...userForcedActive]));
    } catch (e) {}
  }

  /**
   * Restore filter state from localStorage on page load.
   * Populates both disabled-reason and forced-active sets.
   * Called once from DOMContentLoaded, before the ban summary is rendered.
   * @function loadFilterState
   * @returns {void}
   */
  function loadFilterState() {
    try {
      const saved = localStorage.getItem(LS_KEY);
      if (saved) {
        JSON.parse(saved).forEach(r => disabledReasons.add(r));
      }
      const forced = localStorage.getItem(LS_KEY + "_forced");
      if (forced) {
        JSON.parse(forced).forEach(r => userForcedActive.add(r));
      }
    } catch (e) {}
  }

  /**
   * Initializes sort listeners and refresh schedules for profile pages.
   * @function initRefresh
   * @returns {void}
   */
  function initRefresh() {
    addSortListeners();
    setupRefreshes();
  }

  /**
   * Sets up Tablesort instances and progress bar handlers for profile tables.
   * @function addSortListeners
   * @returns {void}
   */
  function addSortListeners() {
    if (ff && sorterFF === null) {
      sorterFF = new Tablesort(ff, {descending: true});
      ff.addEventListener("beforeSort", () => progressx.show(theme));
      ff.addEventListener("afterSort", () => progressx.hide());
    } else if (plist && sorterP === null) {
      sorterP = new Tablesort(plist, {descending: true});
      plist.addEventListener("beforeSort", () => progressx.show(theme));
      plist.addEventListener("afterSort", () => progressx.hide());
    } else if (sessionBans && sorterBans === null) {
      sorterBans = new Tablesort(sessionBans, {descending: false});
      sessionBans.addEventListener("beforeSort", () => progressx.show(theme));
      sessionBans.addEventListener("afterSort", () => progressx.hide());
    }
  }

  /**
   * Configures periodic element refresh for profile rings, lists, floodfills, and bans.
   * @function setupRefreshes
   * @returns {void}
   */
  function setupRefreshes() {
    // Refresh the profile summary rings every 15 seconds, silently
    if (rings) {
      refreshElements("#profilestats", uri, 15000, false, true, "profilestats");
    }

    // Refresh profile list every 15 seconds; rows are diffed in a worker.
    // 10s min interval suppresses the redundant fetch when a short tab hide
    // falls right after a completed refresh
    if (plist) {
      const targetSelectors = pbody ? `#pbody` : `#profilelist`;
      refreshElements(targetSelectors, uri, 15000, false, false, pbody ? "pbody" : null, pbody ? "pbody" : null, 10000);
    }

    // Refresh floodfill profiles every 15 seconds; rows are diffed in a worker
    if (ff) {
      const targetSelectors = ffprofiles ? `#ffProfiles` : `#floodfills`;
      refreshElements(targetSelectors, uri, 15000, false, false, ffprofiles ? "ffProfiles" : "floodfills", ffprofiles ? "ffProfiles" : null);
      // Refresh the floodfill health rings, silently
      refreshElements("#ffstats", uri, 15000, false, true, "ffstats");
    }

    // Refresh session bans every 15 seconds; rows are diffed in a worker.
    // 10s min interval suppresses the redundant fetch when a short tab hide
    // falls right after a completed refresh
    if (sessionBans) {
      refreshElements("#sessionBanlist", uri, 15000, false, false, "sessionBanlist", "sessionBanlist", 10000);
    }

    document.addEventListener("refreshComplete", () => {
      addSortListeners();
      if (sorterP) {sorterP.refresh();}
      if (sorterFF) {sorterFF.refresh();}
      if (sorterBans) {sorterBans.refresh();}
      if (banBody) {updateBanSummary(banBody);}
    });

  }

  /**
   * Filters the session ban table based on active reasons in the summary list.
   * @function filterBanTable
   * @returns {void}
   */
  function filterBanTable() {
    if (!banBody) return;
    const activeReasons = new Set();
    document.querySelectorAll("#banSummary ul.ban-reasons li.active").forEach(li => {
      let reason = li.textContent.replace(/^\d+\s*/, "").trim();
      activeReasons.add(reason);
    });
    banBody.querySelectorAll("tr").forEach(row => {
      const reasonCell = row.querySelector("td.reason");
      if (!reasonCell) { return; }
      let reason = reasonCell.textContent;
      reason = reason.split("(")[0].trim();
      reason = reason.replace("<b> -&gt; </b>", "")
                     .replace("<b> -> </b>", "")
                     .replace(/>\s*/, "")
                     .replace(/->\s*/, "")
                     .replace(/➜\s*/, "")
                     .replace(/^-\s*/, "")
                     .replace(/^ \-> /, "")
                     .replace("Excessive NTCP connection", "Excessive connection")
                     .replace(/Blocklist:\s*[\d.:a-f]+/i, "Blocklist");
      reason = reason.trim();
      row.style.display = activeReasons.has(reason) ? "" : "none";
    });
  }

  /**
   * Generates and displays a summary of session bans grouped by reason.
   * @function updateBanSummary
   * @param {HTMLElement} banBody - The table body containing ban rows
   * @returns {void}
   */
  function updateBanSummary(banBody) {
    if (!banBody) {return;}
    const rows = banBody.querySelectorAll("tr");
    const reasonCounts = {};
    const total = rows.length;

    rows.forEach((row) => {
      const reasonCell = row.querySelector("td.reason");
      if (reasonCell) {
        let reason = reasonCell.textContent;
        reason = reason.split("(")[0].trim();
        reason = reason.replace("<b> -&gt; </b>", "")
                       .replace("<b> -> </b>", "")
                       .replace(/>\s*/, "")
                       .replace(/->\s*/, "")
                       .replace(/➜\s*/, "")
                       .replace(/^-\s*/, "")  // Remove leading dash
                       .replace(/^ \-> /, "") // Remove leading " -> "
                       .replace("Excessive NTCP connection", "Excessive connection")
                       .replace(/Blocklist:\s*[\d.:a-f]+/i, "Blocklist");
        reason = reason.trim();
        if (reason) { reasonCounts[reason] = (reasonCounts[reason] || 0) + 1; }
      }
    });

    let sorted = Object.entries(reasonCounts).sort((a, b) => {
      if (b[1] !== a[1]) { return b[1] - a[1]; }
      return a[0].localeCompare(b[0]);
    });

    // Auto-disable categories with >= 1000 entries for performance,
    // but respect user's explicit enable override
    sorted.forEach(([reason, count]) => {
      if (count >= 1000 && !userForcedActive.has(reason)) { disabledReasons.add(reason); }
    });

    let summaryDiv = document.getElementById("banSummary");
    let html = `<h2>Total Session Bans: ${total}</h2>\n<ul class="ban-reasons">`;
    sorted.forEach(([reason, count]) => {
      const cls = disabledReasons.has(reason) ? "" : " active";
      html += `<li class="${cls}"><span class=badge>${count}</span> ${reason}</li>\n`;
    });
    html += "</ul>";

    if (!summaryDiv) {
      summaryDiv = document.createElement("div");
      summaryDiv.id = "banSummary";
      if (sessionBans && sessionBans.parentNode) {
        sessionBans.parentNode.insertBefore(summaryDiv, sessionBans);
      }
    }
    summaryDiv.innerHTML = html;
    filterBanTable();
    const footer = document.getElementById("sessionBanlistFooter");
    if (footer) {footer.remove();}
  }

  document.addEventListener("click", (e) => {
    const li = e.target.closest("#banSummary ul.ban-reasons li");
    if (!li) return;
    li.classList.toggle("active");
    const reason = li.textContent.replace(/^\d+\s*/, "").trim();
    if (li.classList.contains("active")) {
      disabledReasons.delete(reason);
      userForcedActive.add(reason);
    } else {
      disabledReasons.add(reason);
      userForcedActive.delete(reason);
    }
    saveFilterState();
    filterBanTable();
  });

  let banObserver;

  document.addEventListener("DOMContentLoaded", () => {
    loadFilterState();
    initRefresh();
    if (banBody) {
      updateBanSummary(banBody);
      banObserver = new MutationObserver(() => updateBanSummary(banBody));
      banObserver.observe(banBody, {childList: true});
    }
  });

  function reattachBanObserver() {
    if (banBody && !banObserver) {
      banObserver = new MutationObserver(() => updateBanSummary(banBody));
      banObserver.observe(banBody, {childList: true});
    } else if (banBody && banObserver) {
      banObserver.observe(banBody, {childList: true});
    }
  }

  document.addEventListener("visibilitychange", () => {
    if (document.hidden && banObserver) {
      banObserver.disconnect();
    } else if (!document.hidden) {
      reattachBanObserver();
      updateBanSummary(banBody);
    }
  });
})();