/**
* @module refreshLogs
* @description Manages auto-refresh for log pages (router logs, error logs, service logs)
* using a SharedWorker for background fetches and morphdom for efficient DOM updates.
* Supports configurable refresh intervals, log filtering, and linkification of
* router IDs, lease hashes, and IP addresses in log entries.
* @author dr|z3d
* @license AGPL3 or later
*/
import { onVisible, onHidden } from "/js/onVisible.js";
import morphdom from "/js/morphdom.js";
/**
* Initializes log page refresh functionality including worker setup, filter input,
* and toggle controls.
* @function start
* @returns {void}
*/
function start() {
const $ = id => document.getElementById(id);
const els = {
mainLogs: $("logs"),
criticallogs: $("criticallogs"),
errorCount: $("errorCount"),
routerlogs: $("routerlogs"),
routerlogsList: $("routerlogs")?.querySelector("td ul"),
routerlogsFileInfo: $("routerlogs")?.querySelector("tr:first-child td p"),
servicelogs: $("wrapperlogs"),
refreshSpan: $("refreshPeriod"),
refreshInput: $("logRefreshInterval"),
toggleRefresh: $("toggleRefresh"),
filterInput: $("logFilterInput")
};
const state = {
updates: [],
worker: new SharedWorker("/js/fetchWorker.js"),
logsRefreshId: null,
intervalValue: localStorage.getItem("logsRefresh") || "30"
};
if ("SharedWorker" in window) {
state.worker.port.onmessage = event => {
const { responseText, isDown } = event.data;
if (isDown) { return; }
const parser = new DOMParser();
const doc = parser.parseFromString(responseText, "text/html");
const arrivedHidden = document.hidden;
processUpdates(doc, els, state);
requestAnimationFrame(() => {
if (arrivedHidden || document.hidden) {
state.updates = [];
return;
}
state.updates.forEach(fn => fn());
if ($("routerlogs")) {
applyFilter(els);
}
state.updates = [];
});
};
state.worker.port.start();
state.worker.port.postMessage({ visibility: !document.hidden });
document.addEventListener("visibilitychange", () => {
state.worker.port.postMessage({ visibility: !document.hidden });
});
state.worker.port.postMessage({ url: "/logs" });
}
/**
* Queues DOM updates for error counts, critical logs, router logs, and service logs.
* @function processUpdates
* @param {Document} doc - The parsed HTML document from the fetch response
* @param {Object} els - Cached DOM element references
* @param {Object} state - Application state including updates queue
* @returns {void}
*/
function processUpdates(doc, els, state) {
if (els.errorCount) {
const newEl = doc.getElementById("errorCount");
if (newEl) { state.updates.push(() => morphdom(els.errorCount, newEl, { childrenOnly: true })); }
}
if (els.criticallogs) {
const newEl = doc.getElementById("criticallogs");
if (newEl) { state.updates.push(() => morphdom(els.criticallogs, newEl, { childrenOnly: true })); }
}
if (els.routerlogsList) {
const newFileInfo = doc.querySelector("#routerlogs tr:first-child td p");
const newList = doc.querySelector("#routerlogs td ul");
if (newFileInfo) { state.updates.push(() => morphdom(els.routerlogsFileInfo, newFileInfo)); }
if (newList) {
const clone = newList.cloneNode(true);
tagFirstLi(clone);
linkifyLogEntries(clone);
state.updates.push(() => morphdom(els.routerlogsList, clone, {
childrenOnly: true,
getNodeKey: el => el.nodeType === Node.ELEMENT_NODE ? el.getAttribute("data-key") : null
}));
}
}
if (els.servicelogs) {
const newEl = doc.getElementById("wrapperlogs");
if (newEl) { state.updates.push(() => morphdom(els.servicelogs, newEl, { childrenOnly: true })); }
}
}
/**
* Starts the log refresh interval, retrying until required elements are available.
* @function initRefresh
* @returns {void}
*/
function initRefresh() {
if (!els.mainLogs || !els.routerlogs) {
setTimeout(initRefresh, 500);
return;
}
stopRefresh();
state.logsRefreshId = setInterval(() => refreshLogs(state, els), state.intervalValue * 1000);
els.toggleRefresh.classList.add("enabled");
}
/**
* Stops the log refresh interval timer.
* @function stopRefresh
* @returns {void}
*/
function stopRefresh() {
clearInterval(state.logsRefreshId);
}
/**
* Triggers a log refresh by posting a fetch URL to the SharedWorker.
* @function refreshLogs
* @param {Object} state - Application state with worker and interval info
* @param {Object} els - Cached DOM element references
* @returns {void}
*/
function refreshLogs(state, els) {
if (document.hidden || !navigator.onLine) { return; }
progressx.show(theme);
if ($("routerlogs")) {
state.worker.port.postMessage({ url: "/routerlogs" });
addFilterInput(els);
} else if ($("criticallogs")) {
state.worker.port.postMessage({ url: "/errorlogs" });
} else if ($("wrapperlogs")) {
state.worker.port.postMessage({ url: "/servicelogs" });
}
updateInterval(state, els);
progressx.hide();
}
/**
* Reads the refresh interval from localStorage and updates the input control
* and refresh timer accordingly.
* @function updateInterval
* @param {Object} state - Application state with interval value
* @param {Object} els - Cached DOM element references
* @returns {void}
*/
function updateInterval(state, els) {
state.intervalValue = localStorage.getItem("logsRefresh") || "30";
if (els.refreshInput) {
els.refreshInput.min = 0;
els.refreshInput.max = 3600;
els.refreshInput.value = state.intervalValue;
}
if (els.refreshInput && !els.refreshSpan.classList.contains("listening")) {
els.refreshInput.addEventListener("input", () => {
els.refreshSpan.classList.add("listening");
const value = els.refreshInput.value;
if (!value || isNaN(value)) { return; }
state.intervalValue = value;
localStorage.setItem("logsRefresh", value);
clearInterval(state.logsRefreshId);
if (value === "0") {
els.toggleRefresh.classList.replace("enabled", "disabled");
} else {
state.logsRefreshId = setInterval(() => refreshLogs(state, els), value * 1000);
els.toggleRefresh.classList.replace("disabled", "enabled");
}
});
}
}
/**
* Adds a debounced input listener to the log filter text field.
* @function addFilterInput
* @param {Object} els - Cached DOM element references
* @returns {void}
*/
function addFilterInput(els) {
if (!els.routerlogs) { return; }
if (els.filterInput._listenerAdded) { return; }
const debounce = (fn, delay) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
};
els.filterInput.addEventListener("input", debounce(() => applyFilter(els), 300));
els.filterInput._listenerAdded = true;
}
/**
* Filters log list items based on the current filter input value.
* @function applyFilter
* @param {Object} els - Cached DOM element references
* @returns {void}
*/
function applyFilter(els) {
if (!$("routerlogs")) { return; }
const filterValue = els.filterInput.value.toLowerCase();
if (els.routerlogsList) {
els.routerlogsList.querySelectorAll("li").forEach(li => {
li.style.display = li.textContent.toLowerCase().includes(filterValue) ? "" : "none";
});
}
}
/**
* Converts router IDs, lease hashes, and IP addresses in log entries into
* clickable links pointing to the relevant /netdb pages.
* @function linkifyLogEntries
* @param {HTMLElement} container - The container element with log entries
* @returns {void}
*/
function linkifyLogEntries(container) {
if (!container) { return; }
const isValidPort = s => {
const n = +s;
return n >= 1 && n <= 65535 && s === String(n);
};
const routerPattern = /\[([a-zA-Z0-9~\-]{6})\]/g;
const leasePattern = /(?:key\s*)?\$([a-zA-Z0-9~\-]{8})\$|#ls_([a-zA-Z0-9]{4})\b|\[([a-zA-Z0-9~\-]{8})\]/g;
const ipv4PortPattern = /\b((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b(?::(\d{1,5}))?\b/g;
const ipv6PortPattern = /(?:\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|\b(?:[0-9a-fA-F]{1,4}:){1,7}:)(?::(\d{1,5}))?\b/gi;
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
return node.parentElement?.tagName === "A" ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
}
}, false);
const textNodes = [];
for (let node; (node = walker.nextNode()); ) {
textNodes.push(node);
}
textNodes.forEach(textNode => {
const parent = textNode.parentElement;
if (!parent) return;
const text = textNode.textContent;
const fragments = [];
let cursor = 0;
const matches = [];
const addMatches = (pattern, getMatches) => {
let match;
pattern.lastIndex = 0;
while ((match = pattern.exec(text)) !== null) {
const results = getMatches(match);
if (results) matches.push(...results);
}
};
addMatches(/floodfill\s*\[([a-zA-Z0-9~\-]{6})\]/gi, m => [{
start: m.index,
end: m.index + m[0].length,
clean: {
id: m[1],
href: `/netdb?r=${m[1]}`,
isFF: true
}
}]);
addMatches(/router\s*\[([a-zA-Z0-9~\-]{6})\]/gi, m => [{
start: m.index,
end: m.index + m[0].length,
clean: {
id: m[1],
href: `/netdb?r=${m[1]}`,
isFF: false
}
}]);
addMatches(/\[([a-zA-Z0-9~\-]{6})\]/g, m => [{
start: m.index,
end: m.index + m[0].length,
clean: {
id: m[1],
href: `/netdb?r=${m[1]}`,
isFF: false
}
}]);
addMatches(leasePattern, m => {
const id = m[1] || m[2] || m[3];
if (!id) return [];
return [{ start: m.index, end: m.index + m[0].length, clean: { id, href: `/netdb?l=3#${id.substring(0, 4)}` } }];
});
addMatches(ipv4PortPattern, m => {
const ipStart = m.index;
const full = m[0];
const colonIdx = full.indexOf(':');
const ip = colonIdx === -1 ? full : full.substring(0, colonIdx);
const ipEnd = ipStart + ip.length;
const results = [{ start: ipStart, end: ipEnd, clean: { id: ip, href: `/netdb?ip=${ip}` } }];
if (colonIdx !== -1) {
const portStr = m[2];
if (portStr && isValidPort(portStr)) {
const portStart = ipEnd + 1;
results.push({ start: portStart, end: portStart + portStr.length, clean: { id: portStr, href: `/netdb?port=${portStr}` } });
}
}
return results;
});
addMatches(ipv6PortPattern, m => {
const full = m[0];
const ipStart = m.index;
const portStr = m[2];
const ipEnd = portStr ? ipStart + (full.length - portStr.length - 1) : ipStart + full.length;
const ipRaw = full.substring(0, ipEnd - ipStart);
const cleanIP = ipRaw.split('%')[0];
const results = [{ start: ipStart, end: ipEnd, clean: { id: ipRaw, href: `/netdb?ipv6=${cleanIP}` } }];
if (portStr && isValidPort(portStr)) {
const portStart = ipEnd + 1;
results.push({ start: portStart, end: portStart + portStr.length, clean: { id: portStr, href: `/netdb?port=${portStr}` } });
}
return results;
});
if (matches.length === 0) return;
matches.sort((a, b) => a.start - b.start);
const merged = [];
for (const m of matches) {
if (merged.length === 0 || m.start >= merged[merged.length - 1].end) {
merged.push(m);
}
}
for (const match of merged) {
if (match.start > cursor) {
fragments.push(document.createTextNode(text.slice(cursor, match.start)));
}
const link = document.createElement("a");
link.href = match.clean.href;
link.textContent = match.clean.id;
if (match.clean.isFF) {link.classList.add("isFF");}
fragments.push(link);
cursor = match.end;
}
if (cursor < text.length) {
fragments.push(document.createTextNode(text.slice(cursor)));
}
fragments.forEach(frag => parent.insertBefore(frag, textNode));
parent.removeChild(textNode);
});
}
/**
* Tags the first list item with a unique data-key attribute for morphdom diffing.
* @function tagFirstLi
* @param {HTMLElement} list - The list element to tag
* @returns {void}
*/
function tagFirstLi(list) {
if (!list) { return; }
const firstLi = list.querySelector("li");
if (firstLi) {
const shortKey = Date.now().toString().slice(-4);
firstLi.setAttribute("data-key", `log-${shortKey}`);
}
}
document.addEventListener("DOMContentLoaded", () => {
onVisible(els.mainLogs, () => requestAnimationFrame(() => initRefresh()));
onHidden(els.mainLogs, stopRefresh);
updateInterval(state, els);
addFilterInput(els);
applyFilter(els);
if ($("routerlogs")) {
els.toggleRefresh.addEventListener("click", () => {
const isOn = els.toggleRefresh.classList.contains("enabled");
if (isOn) {
els.toggleRefresh.classList.replace("enabled", "disabled");
stopRefresh();
} else {
els.toggleRefresh.classList.replace("disabled", "enabled");
refreshLogs(state, els);
initRefresh();
}
});
}
});
}
start();