routerconsole/jsp/js/jobs.js

/**
 * @module jobs
 * @description Handles auto-refresh, table sorting, and section toggle state
 * persistence for the /jobs pages. Manages expand/collapse state of finished
 * and scheduled job sections via localStorage.
 * @author dr|z3d
 * @license AGPL3 or later
 */

(function() {
  const jobs = document.getElementById("jobstats");
  const STORAGE_KEY = "jobqueueExpandedSections";
  let isFirstLoad = true;

  /**
   * Loads expanded section IDs from localStorage.
   * @function loadExpandedSections
   * @returns {Set<string>} Set of expanded section IDs
   */
  function loadExpandedSections() {
    try {
      const stored = localStorage.getItem(STORAGE_KEY);
      if (stored) {
        return new Set(JSON.parse(stored));
      }
    } catch (e) {
      // localStorage not available or parse error
    }
    return new Set();
  }

  /**
   * Saves the expanded section IDs to localStorage.
   * @function saveExpandedSections
   * @param {Set<string>} expandedSections - Set of section IDs to save
   * @returns {void}
   */
  function saveExpandedSections(expandedSections) {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify([...expandedSections]));
    } catch (e) {
      // localStorage not available
    }
  }

  let expandedSections = loadExpandedSections();

  // Default states: true = expanded, false = collapsed
  const defaultStates = {
    "finishedjobs": false,  // Completed jobs collapsed by default
    "scheduledjobs": true   // Scheduled jobs expanded by default
  };

  /**
   * Initializes section toggles for the jobqueue page, applying default and
   * stored expand/collapse states.
   * @function initJobqueueToggles
   * @returns {boolean} True if toggles were initialized, false if not applicable
   */
  function initJobqueueToggles() {
    const joblog = document.querySelector(".joblog");
    if (!joblog) return false;

    // Add toggle classes and collapse/expand based on stored state
    const sectionToggles = joblog.querySelectorAll("h3");
    sectionToggles.forEach(toggle => {
      toggle.classList.add("sectionToggle");
      toggle.classList.add("toggle");
      const dropdown = document.createElement("span");
      dropdown.classList.add("dropdown");
      toggle.appendChild(dropdown);
      const content = toggle.nextElementSibling;
      if (content && content.nodeType === Node.ELEMENT_NODE) {
        const sectionId = toggle.id;
        // On first load, apply default states if section not in localStorage
        if (isFirstLoad && !expandedSections.has(sectionId)) {
          // Check if user has ever set a preference (localStorage exists)
          const hasUserPreference = localStorage.getItem(STORAGE_KEY) !== null;
          if (!hasUserPreference && defaultStates.hasOwnProperty(sectionId)) {
            if (defaultStates[sectionId]) {
              expandedSections.add(sectionId);
            }
          }
        }
        if (expandedSections.has(sectionId)) {
          toggle.classList.add("expanded");
          content.style.display = "block";
        } else {
          toggle.classList.remove("expanded");
          content.style.display = "none";
        }
        if (toggle.classList.contains("nojobs")) {
          toggle.style.pointerEvents = "none";
        } else {
          toggle.style.pointerEvents = "";
        }
      }
    });

    isFirstLoad = false;

    if (sectionToggles.length > 0) {
      document.body.classList.add("toggleElementsActive");
      if (expandedSections.size > 0) {
        document.body.classList.add("hasExpandedElement");
        document.documentElement.classList.remove("hasCollapsedElement");
      } else {
        document.body.classList.remove("hasExpandedElement");
        document.documentElement.classList.add("hasCollapsedElement");
      }
    }
    return true;
  }

  // Event delegation for toggle clicks - attached to document level
  document.documentElement.addEventListener("click", function(e) {
    const toggle = e.target.closest(".joblog h3");
    if (!toggle) return;

    const content = toggle.nextElementSibling;
    if (!content || content.nodeType !== Node.ELEMENT_NODE) return;

    const sectionId = toggle.id;
    const isExpanded = toggle.classList.contains("expanded");

    // Toggle current section independently
    if (!isExpanded) {
      content.style.display = "block";
      toggle.classList.add("expanded");
      expandedSections.add(sectionId);
    } else {
      content.style.display = "none";
      toggle.classList.remove("expanded");
      expandedSections.delete(sectionId);
    }

    // Save to localStorage
    saveExpandedSections(expandedSections);

    // Update document/body classes based on any expanded sections
    if (expandedSections.size > 0) {
      document.documentElement.classList.remove("hasCollapsedElement");
      document.body.classList.add("hasExpandedElement");
    } else {
      document.documentElement.classList.add("hasCollapsedElement");
      document.body.classList.remove("hasExpandedElement");
    }
  });

  // Initialize on load
  if (initJobqueueToggles()) {
    // Listen for refreshComplete event to restore toggle state
    document.addEventListener("refreshComplete", function() {
      initJobqueueToggles();
    });
    return;
  }

  if (!jobs) {return;}

  const REFRESH_INTERVAL = 5000;
  const sorter = new Tablesort(jobs, { descending: true });
  const progressx = window.progressx;
  const theme = window.theme;
  const header = document.getElementById("totaljobstats");
  const headerText = header ? header.innerHTML.trim() : "";

  let refreshIntervalId = null;
  let oldRowsMap = new Map();
  let lastQueryParams = window.location.search;

  /**
   * Fetches job data from /jobs, compares with existing rows, and updates the DOM
   * with new, modified, or removed rows.
   * @async
   * @function fetchJobs
   * @returns {Promise<void>}
   */
  async function fetchJobs() {
    try {
      // Clear old rows if query param changed (switching between 30s/all mode)
      if (window.location.search !== lastQueryParams) {
        oldRowsMap.clear();
        lastQueryParams = window.location.search;
      }

      const response = await fetch("/jobs" + window.location.search);
      if (!response.ok) throw new Error("Fetch failed");
      const text = await response.text();
      const doc = new DOMParser().parseFromString(text, "text/html");
      const jobsResponse = doc.getElementById("jobstats");
      if (!jobsResponse) return;

      const oldTbody = jobs.querySelector("#statCount");
      const newTbody = jobsResponse.querySelector("#statCount");
      if (!oldTbody || !newTbody) return;

      // If no rows in recent mode, show placeholder and force refresh
      const isRecentMode = !window.location.search.includes("period=all");
      const hasPlaceholder = oldTbody.querySelector("#init");
      const isAdvanced = jobs.classList.contains("advmode");
      const cols = isAdvanced ? "11" : "7";
      if (isRecentMode && newTbody.rows.length === 0 && !hasPlaceholder) {
        oldTbody.innerHTML = "<tr id=init><td colspan=" + cols + ">...</td></tr>";
        setTimeout(fetchJobs, REFRESH_INTERVAL);
        return;
      }

      const thead = jobs.querySelector("thead");
      const tfoot = jobs.querySelector("#statTotals");
      const newTfoot = jobsResponse.querySelector("#statTotals");
      if (newTbody.rows.length === 0) {
        thead.style.display = "none";
        tfoot.style.display = "none";
      } else {
        thead.style.display = "";
        tfoot.style.display = "";
      }

      // Update statTotals footer with new data
      if (tfoot && newTfoot) {
        const currentContent = tfoot.innerHTML.trim();
        const newContent = newTfoot.innerHTML.trim();
        if (currentContent !== newContent) {
          tfoot.innerHTML = newContent;
        }
      }

      // Always repopulate oldRowsMap to stay in sync with current DOM
      oldRowsMap.clear();
      Array.from(oldTbody.rows).forEach(row => {
        const name = row.cells[0]?.textContent.trim();
        if (name) oldRowsMap.set(name, row);
      });

      progressx.show(theme);
      requestAnimationFrame(() => {
        const newRows = Array.from(newTbody.rows);
        const fragment = document.createDocumentFragment();
        const updatedRows = [];

        newRows.forEach(newRow => {
          const jobName = newRow.cells[0]?.textContent.trim();
          if (!jobName) return;

          const oldRow = oldRowsMap.get(jobName);
          if (!oldRow) {
            fragment.appendChild(newRow.cloneNode(true));
            oldRowsMap.set(jobName, newRow.cloneNode(true));
            return;
          }

          const oldCells = Array.from(oldRow.cells);
          const newCells = Array.from(newRow.cells);
          let changed = false;

          newCells.forEach((newCell, j) => {
            const oldCell = oldCells[j];
            if (!oldCell) return;

            const oldSpan = oldCell.querySelector("span");
            const newSpan = newCell.querySelector("span");
            const oldText = (oldSpan ? oldSpan.textContent : oldCell.textContent).trim();
            const newText = (newSpan ? newSpan.textContent : newCell.textContent).trim();

            if (oldText !== newText) {
              oldCell.innerHTML = newCell.innerHTML;
              oldCell.classList.add("updated");
              changed = true;
            }
          });

          if (changed) {
            oldRowsMap.set(jobName, oldRow);
            updatedRows.push(oldRow);
            setTimeout(() => {
              oldCells.forEach(cell => cell.classList.remove("updated"));
            }, REFRESH_INTERVAL - 500);
          }
        });

        // Remove rows only in recent mode that are no longer in the data
        const isRecentMode = !window.location.search.includes("period=all");
        if (isRecentMode) {
          const newRowNames = new Set(newRows.map(r => r.cells[0]?.textContent.trim()).filter(n => n));
          oldRowsMap.forEach((row, name) => {
            if (!newRowNames.has(name)) {
              row.remove();
              oldRowsMap.delete(name);
            }
          });
        }

        // Add new rows that didn't exist before
        if (fragment.hasChildNodes()) {
          oldTbody.appendChild(fragment);
        }

        const footer = jobs.querySelector(".tablefooter");

        sorter.refresh();
        setTimeout(() => progressx.hide(), 500);
      });
    } catch (e) {
      progressx.hide();
    }
  }

  /**
   * Starts the periodic job data refresh interval.
   * @function startRefresh
   * @returns {void}
   */
  function startRefresh() {
    if (!refreshIntervalId) {
      fetchJobs();
      refreshIntervalId = setInterval(fetchJobs, REFRESH_INTERVAL);
    }
  }

  /**
   * Stops the periodic job data refresh interval.
   * @function stopRefresh
   * @returns {void}
   */
  function stopRefresh() {
    if (refreshIntervalId) {
      clearInterval(refreshIntervalId);
      refreshIntervalId = null;
    }
  }

  jobs.addEventListener("beforeSort", () => {
    progressx.show(theme);
    progressx.progress(0.5);
  });

  jobs.addEventListener("afterSort", () => {
    setTimeout(() => progressx.hide(), 500);
  });

  if (document.visibilityState === "visible") { startRefresh(); }

  document.addEventListener("visibilitychange", () => {
    document.visibilityState === "visible" ? startRefresh() : stopRefresh();
  });

  requestAnimationFrame(() => {
    sorter.refresh();
  });
})();