routerconsole/jsp/js/newHosts.js

/**
 * @module newHosts
 * @description Fetches and displays new hostname registrations from /susidns/log.jsp
 * in the sidebar, with a badge counter and tooltip listing. Caches data in localStorage
 * and periodically refreshes. Only active on the dark theme.
 * @author dr|z3d
 * @license AGPLv3 or later
 */

/** @type {number|null} */
let newHostsInterval = null;
/** @type {boolean} */
let tooltipInitialized = false;
/** @type {HTMLElement|null} */
let tooltipBadge = null;
/** @type {HTMLElement|null} */
let tooltipServices = null;

/**
 * Shows the new hosts tooltip; looks elements up at event time so the
 * handlers stay valid across sidebar full refreshes.
 * @function tooltipShow
 * @returns {void}
 */
function tooltipShow() {
  const newHostsList = document.getElementById("newHostsList");
  if (newHostsList) {newHostsList.hidden = false;}
  document.getElementById("sb_services")?.classList.add("tooltipped");
}

/**
 * Hides the new hosts tooltip; looks elements up at event time.
 * @function tooltipHide
 * @returns {void}
 */
function tooltipHide() {
  const newHostsList = document.getElementById("newHostsList");
  if (newHostsList) {newHostsList.hidden = true;}
  document.getElementById("sb_services")?.classList.remove("tooltipped");
}

/**
 * Attaches the tooltip listeners to the current badge node exactly once;
 * re-attaches if the badge was replaced by a sidebar full refresh.
 * @function ensureTooltipListeners
 * @param {HTMLElement} badge - The new hosts badge element
 * @returns {void}
 */
function ensureTooltipListeners(badge) {
  if (tooltipInitialized && tooltipBadge === badge) {return;}
  if (tooltipInitialized && tooltipBadge) {
    tooltipBadge.removeEventListener("mouseenter", tooltipShow);
    tooltipServices?.removeEventListener("mouseleave", tooltipHide);
  }
  badge.addEventListener("mouseenter", tooltipShow, { passive: true });
  tooltipServices = document.getElementById("sb_services");
  tooltipServices?.addEventListener("mouseleave", tooltipHide, { passive: true });
  tooltipBadge = badge;
  tooltipInitialized = true;
}

/**
 * Initializes the new hosts badge with periodic fetching, caching, and tooltip management.
 * @function newHosts
 * @returns {void}
 */
export function newHosts() {
  const newHostsBadge = document.getElementById("newHosts");
  if (!newHostsBadge) { return; }
  if (theme !== "dark") {
    newHostsBadge.style.display = "none";
    return;
  }

  const period = 30 * 60 * 1000;

  /**
   * Retrieves cached new hosts data from localStorage.
   * @function getStoredData
   * @returns {{hostnames: Array<{hostname: string, timestamp: number}>, count: number, lastUpdated: number|null}}
   */
  function getStoredData() {
    const key = "newHostsData";
    const rawData = localStorage.getItem(key);

    if (!rawData) {
      return { hostnames: [], count: 0, lastUpdated: null };
    }

    try {
      const parsed = JSON.parse(rawData);
      if (typeof parsed === "object" && !Array.isArray(parsed) && parsed !== null) {
        return parsed;
      }
      throw new Error("Data is not an object");
    } catch (e) {
      console.warn("Invalid JSON in localStorage, clearing:", key);
      localStorage.removeItem(key);
      return { hostnames: [], count: 0, lastUpdated: null };
    }
  }

  /**
   * Fetches the latest hosts from /susidns/log.jsp, filters by 24-hour window,
   * and updates localStorage and the badge display.
   * @function fetchNewHosts
   * @returns {void}
   */
  function fetchNewHosts() {
    localStorage.setItem("newHostsLastFetch", Date.now());

    fetch("/susidns/log.jsp")
      .then(response => response.text())
      .then(html => {
        const parser = new DOMParser();
        const doc = parser.parseFromString(html, "text/html");

        const now = new Date();
        const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);

        const entries = Array.from(doc.querySelectorAll("li"));
        const newHostnames = entries.flatMap(entry => {
          const dateText = entry.querySelector(".date")?.textContent;
          if (!dateText) return [];

          const entryDate = new Date(dateText);
          if (entryDate < oneDayAgo) return [];

          const links = entry.querySelectorAll("a");
          return Array.from(links).map(a => ({
            hostname: new URL(a.href).hostname,
            timestamp: entryDate.getTime()
          }));
        });

        const storedData = getStoredData();
        const storedHostnames = storedData.hostnames || [];

        const hostnameMap = new Map();
        [...storedHostnames, ...newHostnames].forEach(h => {
          if (!hostnameMap.has(h.hostname) || h.timestamp > hostnameMap.get(h.hostname)?.timestamp) {
            hostnameMap.set(h.hostname, h);
          }
        });

        const allHostnames = Array.from(hostnameMap.values())
          .filter(h => h.timestamp >= oneDayAgo.getTime())
          .sort((a, b) => b.timestamp - a.timestamp);

        const limitedHostnames = allHostnames.slice(0, 10);
        const sortedHostnames = limitedHostnames.map(h => h.hostname).sort();

        const count = sortedHostnames.length;
        localStorage.setItem("newHostsData", JSON.stringify({
          count,
          lastUpdated: Date.now(),
          hostnames: limitedHostnames
        }));

        if (count > 10) {
          newHostsBadge.textContent = "10+";
        } else {
          newHostsBadge.textContent = count || "";
        }

        updateTooltip(sortedHostnames);
      })
      .catch(err => console.error("Failed to fetch new hosts:", err));
  }

  /**
   * Retrieves new hosts from cache if recent enough, otherwise triggers a fresh fetch.
   * @function getNewHosts
   * @returns {void}
   */
  function getNewHosts() {
    const now = Date.now();
    const storedData = getStoredData();
    const lastFetch = parseInt(localStorage.getItem("newHostsLastFetch") || "0", 10);

    if (
      storedData.lastUpdated &&
      now - storedData.lastUpdated < 15*60*1000 &&
      now - lastFetch < 10*60*1000
    ) {
      const { count, hostnames } = storedData;
      if (count > 0) {
        if (count > 10) { newHostsBadge.textContent = "10+"; }
        else { newHostsBadge.textContent = count; }
        updateTooltip(hostnames.map(h => h.hostname));
      } else {
        newHostsBadge.textContent = "";
      }
    } else {
      fetchNewHosts();
    }
  }

  /**
   * Updates the tooltip content with a list of new hostnames as clickable links.
   * @function updateTooltip
   * @param {string[]} hostnames - Array of hostnames to display
   * @returns {void}
   */
  function updateTooltip(hostnames) {
    if (!newHostsBadge) { return; }

    const newHosts = document.getElementById("newHostsList");
    const newHostsTd = newHosts?.querySelector("td");

    if (!hostnames.length) {
      if (newHosts) { newHosts.hidden = true; }
      if (newHostsTd) { newHostsTd.innerHTML = ""; }
      return;
    }

    const newHostsList = hostnames.map(hostname => {
      const shortName = hostname.replace(".i2p", "");
      return `<a href="http://${hostname}" target="_blank">${shortName}</a>`;
    }).join("<br>");

    if (newHostsTd && newHostsTd.innerHTML !== newHostsList) { newHostsTd.innerHTML = newHostsList; }

    if (newHosts) { newHosts.hidden = true; }

    ensureTooltipListeners(newHostsBadge);
  }
  if (newHostsInterval) {clearInterval(newHostsInterval);}
  getNewHosts();
  newHostsInterval = setInterval(fetchNewHosts, period);
}