routerconsole/jsp/js/viewprofile.js

/**
 * @module viewprofile
 * @description Parses raw profile text data from the #viewprofile pre element
 * into a structured HTML table with section headers, formatted values (speed in MB/s,
 * readable dates, response times), and collapsible section toggles.
 * @author dr|z3d
 * @license AGPL3 or later
 */

(function() {
  document.addEventListener("DOMContentLoaded", () => {
    const viewprofile = document.getElementById("viewprofile");
    const profileContainer = document.querySelector("#viewprofile pre");
    const profile = profileContainer.innerText;
    const lines = profile.split("\n");
    profileContainer.style.display = "none";

    let manualSpeedScoreAdjustment = 0; // Initialize the adjustment value
    let speedValue = null; // Initialize the speed value

    // First pass to collect all relevant values
    lines.forEach((line) => {
      if (line.startsWith("# ---")) {} // skip
      else if (line.startsWith("# ") && line.includes(":")) {
        const [key, ...rest] = line.slice(2).split(":");
        const value = rest.join(":").trim();
        const cleanedKey = key.replace("(ms since the epoch)", "").trim();
        const cleanedValue = cleanValue(value);

        if (cleanedKey === "Manual Speed Score adjustment") {
          manualSpeedScoreAdjustment = parseFloat(cleanedValue);
        }

        if (cleanedKey === "Speed") {
          speedValue = parseFloat(cleanedValue);
        }
      }
    });

    // Second pass to generate the table HTML
    let tableHTML = "<table>";
    lines.forEach((line) => {
      if (line.startsWith("# ---")) {} // skip
      else if (line.startsWith("# ") && line.includes(":")) {
        const [key, ...rest] = line.slice(2).split(":");
        const value = rest.join(":").trim();
        const cleanedKey = key.replace("(ms since the epoch)", "").replace("(ms)", "").trim();
        const cleanedValue = cleanValue(value);

        if (cleanedKey === "Speed") {
          const speed = speedValue - manualSpeedScoreAdjustment;
          if (speed > 0) { // Only display if speed is greater than 0 B/s
            let convertedSpeed;
            if (!isNaN(speed)) {
              if (speed >= 1024) {
                convertedSpeed = (speed / 1024).toFixed(2); // Convert to MB/s
                tableHTML += "<tr class=stat><td>" + cleanedKey + "</td><td>" + convertedSpeed + " MB/s</td></tr>";
              } else {
                convertedSpeed = speed.toFixed(0);
                tableHTML += "<tr class=stat><td>" + cleanedKey + "</td><td>" + convertedSpeed + " B/s</td></tr>";
              }
            }
          }
        } else if (cleanedKey === "Time of last failed lookup from peer" ||
            cleanedKey === "Time of last successful lookup from peer" ||
            cleanedKey === "Time of last failed store to peer" ||
            cleanedKey === "Time of last successful store to peer") {
          const convertedDate = convertEpochToReadableDate(cleanedValue);
          tableHTML += "<tr class=stat><td>" + cleanedKey + "</td><td>" + convertedDate + "</td></tr>";
        } else if (cleanedKey === "Period") {
          const concatenatedValue = cleanedKey + ": " + cleanedValue;
          tableHTML += "<tr class='subheading period'><th colspan=2>" + concatenatedValue + "</th></tr>";
        } else if (cleanedKey === "Average peer response time") {
          const responseTime = parseFloat(cleanedValue);
          let formattedResponseTime;
          if (!isNaN(responseTime)) {
            if (responseTime > 1000) {
              formattedResponseTime = (responseTime / 1000).toFixed(1) + " seconds";
            } else {
              formattedResponseTime = responseTime.toFixed(0) + " milliseconds";
            }
          }
          tableHTML += "<tr class=stat><td>" + cleanedKey + "</td><td>" + formattedResponseTime + "</td></tr>";
        } else {
          tableHTML += "<tr class=stat><td>" + cleanedKey + "</td><td>" + cleanedValue + "</td></tr>";
        }
      } else if (line.startsWith("# ") && !line.includes(":")) {
        const sectionTitle = line.slice(2).trim();
        const [text, title] = extractTooltip(sectionTitle);
        if (text === "Router Information") {
          tableHTML += "<tr class='section heading ri expanded'><th colspan='2' title='" + title + "'>" + text + "</th></tr>";
        } else if (text === "NetDb History") {
          tableHTML += "<tr class='section heading db expanded'><th colspan='2' title='" + title + "'>" + text + "</th></tr>";
        } else if (text === "Tunnel History") {
          tableHTML += "<tr class='section heading tnl expanded'><th colspan='2' title='" + title + "'>" + text + "</th></tr>";
        } else {
          tableHTML += "<tr class=section><th colspan='2' title='" + title + "'>" + text + "</th></tr>";
        }
      }
    });

    tableHTML += "</table>";
    viewprofile.innerHTML = tableHTML;
    viewprofile.classList.add("tabulated");
    setTimeout(addToggles, 50);
  });

  /**
   * Extracts tooltip content from bracketed text in a title string.
   * @function extractTooltip
   * @param {string} title - The title string potentially containing [tooltip] text
   * @returns {string[]} Tuple of [clean title, tooltip content]
   */
  function extractTooltip(title) {
    const match = title.match(/\[(.*?)\]/);
    if (match) {
      const titleContent = match[1];
      const text = title.replace(match[0], "").trim();
      return [text, titleContent];
    }
    return [title, ""];
  }

  /**
   * Cleans and formats a raw profile value by removing brackets, GMT suffixes,
   * and replacing special codes with readable text.
   * @function cleanValue
   * @param {string} value - The raw value string to clean
   * @returns {string} The cleaned and formatted value
   */
  function cleanValue(value) {
    let cleanedValue = value.replace(/\[.*?\]/g, "").trim();
    cleanedValue = cleanedValue.replace(/GMT/g, "");
    cleanedValue = cleanedValue.replace(/_/g, " ");
    cleanedValue = cleanedValue.replace(/0\.0/g, "0");
    cleanedValue = cleanedValue.replace(/9999999/g, "9999999 (Low latency)")
    cleanedValue = cleanedValue.replace(/-30/g, "-30 (High latency)")
    if (cleanedValue.endsWith(".0")) {cleanedValue = cleanedValue.slice(0, -2);}
    return cleanedValue;
  }

  /**
   * Converts an epoch millisecond timestamp to a human-readable date string.
   * @function convertEpochToReadableDate
   * @param {string} epoch - The epoch timestamp as a string
   * @returns {string} A formatted date string or "Never" if epoch is "0"
   */
  function convertEpochToReadableDate(epoch) {
    if (epoch === "0") {return "Never";}
    const date = new Date(parseInt(epoch, 10));
    const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
    const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

    const dayOfWeek = weekdays[date.getUTCDay()];
    const month = months[date.getUTCMonth()];
    const dayOfMonth = date.getUTCDate();
    const hours = String(date.getUTCHours()).padStart(2, '0');
    const minutes = String(date.getUTCMinutes()).padStart(2, '0');
    const seconds = String(date.getUTCSeconds()).padStart(2, '0');

    return `${dayOfWeek} ${month} ${dayOfMonth} ${hours}:${minutes}:${seconds} ${date.getFullYear()}`;
  }

  /**
   * Converts bytes to kilobytes with two decimal places.
   * @function convertBtoKB
   * @param {number} bytes - The value in bytes
   * @returns {string} The value in KB as a formatted string
   */
  function convertBtoKB(bytes) {
    return (bytes / 1024).toFixed(2);
  }

/**
 * Sets up collapsible section toggles for the profile table, expanding
 * router info, NetDb history, and tunnel history sections by default.
 * @function addToggles
 * @returns {void}
 */
function addToggles() {
  document.querySelectorAll(".section").forEach(section => {
    let next = section.nextElementSibling;
    while (next && !next.classList.contains("section")) {
      next.classList.add("hidden");
      next = next.nextElementSibling;
    }
  });

  document.querySelectorAll(".section").forEach(section => {
    section.addEventListener("click", function() {
      this.classList.toggle("expanded");
      let next = this.nextElementSibling;
      while (next && !next.classList.contains("section")) {
        next.classList.toggle("hidden");
        next = next.nextElementSibling;
      }
      if (this.classList.contains("expanded")) {
        window.scrollTo(0, this.offsetTop);
      } else {
        window.scrollTo(0, 0);
      }
    });
  });

  // Expand the .ri, .tnl, and .db sections by default
  const sectionsToExpand = document.querySelectorAll(".ri, .tnl, .db");
  if (sectionsToExpand.length > 0) {
    Array.from(sectionsToExpand).forEach(section => {
      section.classList.add("expanded");
      let next = section.nextElementSibling;
      while (next && !next.classList.contains("section")) {
        next.classList.remove("hidden");
        next = next.nextElementSibling;
      }
    });
  }
  setTimeout(() => viewprofile.removeAttribute("hidden"), 100);
}

})();