/**
* @module refreshIndex
* @file refreshIndex.js - Periodic refresh and status updates for I2PTunnel Manager
* @description Handles auto-refreshing the tunnel index page, updating service status counts,
* managing router down state overlay, and providing tunnel control functionality
* @author dr|z3d
* @license AGPL3 or later
*/
import { initToggleInfo } from "/i2ptunnel/js/toggleTunnelInfo.js";
// Notify parent to reload after action completes
window.addEventListener('message', (e) => {
if (e.origin !== parentOrigin) return;
if (e.data === 'actionComplete') {
window.parent.location.reload();
}
});
// If this page was loaded in iframe after an action, notify parent to reload
if (window.self !== window.top) {
const params = new URLSearchParams(window.location.search);
if (params.has('action') && params.get('action') !== 'list') {
window.parent.postMessage('actionComplete', parentOrigin);
}
}
/** @type {HTMLElement|null} */
const container = document.querySelector('#page');
/** @type {HTMLElement|null} */
const control = document.getElementById("globalTunnelControl");
/** @type {HTMLElement|null} */
const countClient = document.getElementById("countClient");
/** @type {HTMLElement|null} */
const countServer = document.getElementById("countServer");
/** @type {HTMLIFrameElement|null} */
let iframe = null;
try {
if (window.self !== window.top) {
iframe = window.parent.document.querySelector(".main#tunnelmgr");
}
} catch (e) {
console.warn('[refreshIndex] Cannot access parent document:', e);
}
/**
* Resize-event guard that re-pins the iframe to 100% while down.
* @type {Function|null}
*/
let _resizeGuard = null;
/** @type {HTMLElement|null} */
const isDownElement = document.getElementById("down");
/** @type {HTMLElement|null} */
const messages = document.getElementById("tunnelMessages");
/** @type {HTMLElement|null} */
const notReady = document.getElementById("notReady");
/** @type {HTMLStyleElement|null} */
const tempStylesheet = document.head.querySelector("#isDownOverlay");
/** @type {HTMLElement|null} */
const toggle = document.getElementById("toggleInfo");
/** @type {HTMLElement|null} */
const tunnelIndex = document.getElementById("page");
/** @type {string} */
const url = window.location.pathname;
/** @type {string} */
const safeTheme = typeof theme !== 'undefined' ? theme : 'dark';
/** @type {string} */
const parentOrigin = window.parent?.location?.origin || location.origin;
/** @type {WeakSet} */
const boundToggles = new WeakSet();
/** @type {boolean} */
let isDownClassAdded = false;
/** @type {number|undefined} */
let isDownTimeoutId;
/** @type {boolean} */
let tunnelControlListenerAttached = false;
/**
* Updates element content if it differs from the response element.
* @function updateElementContent
* @param {HTMLElement} element - The DOM element to update
* @param {HTMLElement} responseElement - The element containing new content
* @returns {void}
*/
function updateElementContent(element, responseElement) {
if (element && responseElement && element.innerHTML !== responseElement.innerHTML) {
element.innerHTML = responseElement.innerHTML;
}
}
/**
* Fetches the current page and updates tunnel status information.
* On success, clears down state, parses response HTML, and updates volatile content.
* On failure, schedules down state handler after 5 second delay.
* @async
* @function refreshTunnelStatus
* @returns {Promise<void>}
*/
async function refreshTunnelStatus() {
try {
const response = await fetch(url, { method: "GET", headers: { "Cache-Control": "no-cache" }, credentials: 'same-origin' });
if (response.ok) {
clearTimeout(isDownTimeoutId);
if (isDownElement) { isDownElement.remove(); }
if (tempStylesheet) { tempStylesheet.remove(); }
const doc = new DOMParser().parseFromString(await response.text(), "text/html");
requestAnimationFrame(() => { resizeIframe(); });
if (isDownClassAdded && doc.getElementById("globalTunnelControl")) { reloadPage(); }
document.body.classList.remove("isDown");
if (container) { container.style = ""; }
if (iframe) {
iframe.style.padding = "";
iframe.style.height = "";
iframe.style.minHeight = "";
const parentBox = window.parent && window.parent.document
? window.parent.document.querySelector(".main#tunnelmgr") : null;
if (parentBox) { parentBox.style.minHeight = ""; parentBox.style.height = ""; }
}
if (_resizeGuard) { window.removeEventListener("resize", _resizeGuard); _resizeGuard = null; }
if (notReady || doc.getElementById("notReady")) {
const notReadyResponse = doc.getElementById("notReady");
if (notReadyResponse) { refreshAll(doc); }
else { reloadPage(); }
} else {
updateNonceLinks(doc);
updateVolatile(doc);
}
countServices();
resizeIframe();
isDownClassAdded = false;
} else {
isDownTimeoutId = setTimeout(handleDownState, 5000);
}
} catch (error) {
isDownTimeoutId = setTimeout(handleDownState, 5000);
}
}
/**
* Displays the 'router is down' overlay and schedules a re-check.
* Creates a full-screen overlay with "Router is down" message,
* adds temporary stylesheet for visual effect, and resizes iframe.
* @function handleDownState
* @returns {void}
*/
function handleDownState() {
const viewportHeight = window.innerHeight;
if (!document.body.classList.contains("isDown") && !document.getElementById("down")) {
document.body.classList.add("isDown");
if (container) {
container.style.height = "100%";
container.style.minHeight = `${viewportHeight}px`;
container.style.overflow = 'hidden';
}
const downElement = document.createElement("div");
downElement.id = "down";
downElement.className = "notReady";
downElement.innerHTML = "<b><span>Router is down</span></b>";
downElement.style.zIndex = "9999";
document.body.appendChild(downElement);
if (!tempStylesheet) {
const styleSheet = document.createElement("style");
styleSheet.id = "isDownOverlay";
const bg = (safeTheme === "light" || safeTheme === "classic" ? "#f2f2ff" : "#000");
const styles =
"body.isDown{display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}" +
"body.isDown::after{position:fixed;top:0;right:0;bottom:0;left:0;z-index:998;background:" + bg + ";content:''}" +
"body.isDown #page{display:none}" +
// Always center the overlay in the iframe and never crop it, regardless
// of theme box styling (which uses static margins). Pinned to the
// viewport so it stays centered even when the iframe is shorter than
// its content. overflow is visible so long messages are never clipped.
"body.isDown #down,#down{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);" +
"z-index:9999;max-width:90vw;width:auto;margin:0;padding:30px 40px;box-sizing:border-box;" +
"text-align:center;overflow:visible;min-height:0;max-height:none}";
styleSheet.appendChild(document.createTextNode(styles));
document.head.appendChild(styleSheet);
}
if (iframe) {
iframe.style.padding = "0";
// Force the iframe to fill its parent container so the overlay covers
// the whole embedded view instead of collapsing with the (now empty)
// content. iframe-resizer sizes to content (taggedElement), so with the
// router down the page collapses and the iframe shrinks; pin it to 100%
// of the parent and stop it from re-shrinking until the router returns.
iframe.style.height = "100%";
iframe.style.minHeight = "100%";
const parentBox = window.parent && window.parent.document
? window.parent.document.querySelector(".main#tunnelmgr") : null;
if (parentBox) {
parentBox.style.minHeight = "100vh";
parentBox.style.height = "100vh";
}
if (_resizeGuard) { window.removeEventListener("resize", _resizeGuard); }
_resizeGuard = () => {
iframe.style.height = "100%";
iframe.style.minHeight = "100%";
if (parentBox) {
parentBox.style.minHeight = "100vh";
parentBox.style.height = "100vh";
}
requestAnimationFrame(() => { resizeIframe(); });
};
window.addEventListener("resize", _resizeGuard);
requestAnimationFrame(() => {resizeIframe();});
}
}
refreshTunnelStatus();
isDownClassAdded = true;
}
/**
* Counts tunnel services by status and updates the UI count displays.
* Iterates through status categories (running, standby, starting, stopped)
* and updates both client and server count elements.
* @function countServices
* @returns {void}
*/
function countServices() {
const statuses = [
{ clientSelector: ".cli.statusRunning", serverSelector: ".svr.statusRunning", status: "running" },
{ clientSelector: ".cli.statusStandby", serverSelector: ".svr.statusStandby", status: "standby" },
{ clientSelector: ".cli.statusStarting", serverSelector: ".svr.statusStarting", status: "starting" },
{ clientSelector: ".cli.statusNotRunning", serverSelector: ".svr.statusNotRunning", status: "stopped" }
];
/**
* Sets the status class on a table row element, removing other status classes.
* @function setStatusClass
* @param {HTMLElement} parentTr - The table row element
* @param {string} status - The status class to apply (running, standby, starting, stopped)
* @returns {void}
*/
const setStatusClass = (parentTr, status) => {
const statusClasses = statuses.map(s => s.status);
statusClasses.forEach(s => parentTr.classList.remove(s));
parentTr.classList.add(status);
};
/**
* Updates the count display for a given selector and status.
* @function updateCount
* @param {HTMLElement} element - The element displaying the count
* @param {string} selector - CSS selector for tunnel elements
* @param {string} status - The status category
* @returns {void}
*/
const updateCount = (element, selector, status) => {
if (!element) return;
const elements = document.querySelectorAll(selector);
element.textContent = elements.length > 0 ? ` x ${elements.length}` : "";
elements.forEach(el => {
const parentTr = el.closest("tr");
if (parentTr) { setStatusClass(parentTr, status); }
});
};
if (control) {
statuses.forEach(({ clientSelector, serverSelector, status }) => {
if (countClient) {
updateCount(countClient.querySelector(`.${status}`), clientSelector, status);
}
if (countServer) {
updateCount(countServer.querySelector(`.${status}`), serverSelector, status);
}
});
}
}
/**
* Updates volatile tunnel property elements from the response document.
* If element count differs, refreshes full panels; otherwise updates changed elements.
* @function updateVolatile
* @param {Document} responseDoc - The parsed HTML document from fetch response
* @returns {void}
*/
function updateVolatile(responseDoc) {
const updating = document.querySelectorAll(".tunnelProperties .volatile");
const updatingResponse = responseDoc.querySelectorAll(".tunnelProperties .volatile");
updateLog(responseDoc);
if (updating.length !== updatingResponse.length) {
refreshPanels(responseDoc);
} else {
updating.forEach((el, i) => {
const responseEl = updatingResponse[i];
if (el.textContent.trim() !== responseEl.textContent.trim()) {
updateElementContent(el, responseEl);
}
});
}
}
/**
* Updates the tunnel messages log from the response document.
* If messages element exists in response but not locally, triggers full page refresh.
* @function updateLog
* @param {Document} responseDoc - The parsed HTML document from fetch response
* @returns {void}
*/
function updateLog(responseDoc) {
const messagesResponse = responseDoc?.getElementById("tunnelMessages");
updateElementContent(messages, messagesResponse);
if (messagesResponse && !messages) {refreshAll(responseDoc);}
}
/**
* Refreshes server and client tunnel panels from the response document.
* @function refreshPanels
* @param {Document} responseDoc - The parsed HTML document from fetch response
* @returns {void}
*/
function refreshPanels(responseDoc) {
updateLog(responseDoc);
updateNonceLinks(responseDoc);
["serverTunnels", "clientTunnels"].forEach(id => {
const element = document.getElementById(id);
const responseElement = responseDoc.getElementById(id);
updateElementContent(element, responseElement);
});
}
/**
* Performs a full page content refresh from the response document.
* Updates the page element, reinitializes tunnel control, binds toggle,
* recounts services, and resizes iframe.
* @function refreshAll
* @param {Document} responseDoc - The parsed HTML document from fetch response
* @returns {void}
*/
function refreshAll(responseDoc) {
const tunnelIndexResponse = responseDoc?.getElementById("page");
updateElementContent(tunnelIndex, tunnelIndexResponse);
updateNonceLinks(responseDoc);
initTunnelControl();
bindToggle();
countServices();
resizeIframe();
}
/**
* Forces a hard reload of the current page, bypassing cache.
* @function reloadPage
* @returns {void}
*/
function reloadPage() {location.reload(true);}
/**
* Updates nonce values in all action forms from the response document.
* Parses new nonce from first form input and updates all form nonce inputs in DOM.
* @function updateNonceLinks
* @param {Document} responseDoc - The parsed HTML document from fetch response
* @returns {void}
*/
function updateNonceLinks(responseDoc) {
const firstForm = responseDoc.querySelector('form[id] input[name="nonce"]');
if (!firstForm) { console.warn('[refreshIndex] No nonce form found in response'); return; }
const newNonce = firstForm.value;
if (!newNonce) { console.warn('[refreshIndex] Empty nonce value in response'); return; }
document.querySelectorAll('form[id] input[name="nonce"]').forEach(input => {
if (input.value !== newNonce) {
input.value = newNonce;
}
});
}
/**
* Binds the toggle info click handler if not already attached.
* Prevents duplicate event listeners by checking for 'listener' class.
* @function bindToggle
* @returns {void}
*/
function bindToggle() {
if (toggle && !boundToggles.has(toggle)) {
toggle.addEventListener("click", initToggleInfo);
boundToggles.add(toggle);
}
}
/**
* Main refresh cycle function called on interval.
* Refreshes tunnel status and ensures control bindings are active.
* If control element is missing, retries after 500ms delay.
* @function refreshIndex
* @returns {void}
*/
function refreshIndex() {
refreshTunnelStatus();
if (!control) { setTimeout(refreshIndex, 500); return; }
bindToggle();
if (tunnelIndex && !tunnelIndex.classList.contains("listener")) {initTunnelControl();}
}
/**
* Initializes tunnel control click handlers for start/stop/restart actions.
* Attaches delegated click listener on the tunnel index for control buttons.
* @function initTunnelControl
* @returns {void}
*/
function initTunnelControl() {
if (!tunnelIndex || tunnelControlListenerAttached) return;
tunnelControlListenerAttached = true;
tunnelIndex.addEventListener("click", async event => {
const target = event.target.closest("button[name='action']");
if (target && target.classList.contains("control") && !target.classList.contains("create") && !target.classList.contains("preview")) {
event.preventDefault();
const formId = target.getAttribute("form");
const form = document.getElementById(formId);
if (!form) {
console.warn(`[refreshIndex] Form not found: ${formId}`);
if (messages) messages.innerHTML = `<li class="error">Error: Form "${formId}" not found</li>`;
return;
}
await tunnelControl(form, target.value);
}
});
}
/**
* Executes a tunnel control action via POST and updates the UI.
* @async
* @function tunnelControl
* @param {HTMLFormElement} form - The form element containing action parameters
* @param {string} action - The action value from the clicked button
* @returns {Promise<void>}
*/
async function tunnelControl(form, action) {
try {
const url = new URL(form.getAttribute("action"), window.location.href).href;
const formParams = new URLSearchParams();
formParams.set('nonce', form.querySelector('input[name="nonce"]').value);
formParams.set('tunnel', form.querySelector('input[name="tunnel"]').value);
formParams.set('action', action);
const response = await fetch(url, {
method: "POST",
headers: { "Cache-Control": "no-cache" },
credentials: 'same-origin',
body: formParams
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
console.error(`[refreshIndex] Action failed: ${response.status} ${response.statusText}`, errorText);
if (messages) {
messages.innerHTML = `<li class="error">Server error ${response.status}: ${response.statusText}</li>`;
}
return;
}
const doc = new DOMParser().parseFromString(await response.text(), "text/html");
const errorEl = doc.querySelector(".error");
const messagesEl = doc.getElementById("tunnelMessages");
if (errorEl && messages) {
messages.innerHTML = errorEl.outerHTML;
}
if (messagesEl && messages) {
messages.innerHTML = messagesEl.innerHTML;
}
if (errorEl && errorEl.textContent.includes("Invalid form submission")) {
await refreshTunnelStatus();
const freshForm = document.getElementById(form.id);
if (!freshForm) {
console.warn(`[refreshIndex] Fresh form not found after refresh: ${form.id}`);
return;
}
const retryParams = new URLSearchParams(new FormData(freshForm));
const retryResponse = await fetch(url, {
method: "POST",
headers: { "Cache-Control": "no-cache" },
credentials: 'same-origin',
body: retryParams
});
if (!retryResponse.ok) {
if (messages) {
messages.innerHTML = `<li class="error">Retry failed: ${retryResponse.status}</li>`;
}
return;
}
const retryDoc = new DOMParser().parseFromString(await retryResponse.text(), "text/html");
updateNonceLinks(retryDoc);
countServices();
updateVolatile(retryDoc);
return;
}
updateNonceLinks(doc);
countServices();
updateVolatile(doc);
} catch (e) {
console.error(e);
if (messages) {
messages.innerHTML = `<li class="error">Action failed: ${e.message || e}</li>`;
}
}
}
/**
* Resizes the parent iframe to fit content by posting a resize message.
* Only executes if the page is running inside an iframe.
* @function resizeIframe
* @returns {void}
*/
function resizeIframe() {
const isIframed = document.documentElement.classList.contains("iframed") || window.self !== window.top;
if (isIframed) {
setTimeout(() => {
parent.postMessage({ action: 'resize', iframeId: 'i2ptunnelframe' }, parentOrigin);
}, 100);
}
}
/**
* Preloads an image into browser cache for faster subsequent display.
* @function preloadImage
* @param {string} url - The URL of the image to preload
* @returns {void}
* @example
* preloadImage("/themes/console/dark/images/tunnelmanager.webp");
*/
function preloadImage(url) {
const img = new Image();
img.src = url;
}
// Preload image immediately for isDown display (before router potentially goes down)
if (safeTheme === "dark") {
preloadImage("/themes/console/dark/images/tunnelmanager.webp");
}
/**
* DOMContentLoaded handler that initializes service counting and bindings.
* @listens DOMContentLoaded
*/
document.addEventListener("DOMContentLoaded", () => {
countServices();
if (toggle) { bindToggle(); }
if (control) { initTunnelControl(); }
setInterval(refreshIndex, 5000);
refreshIndex();
});
/**
* Window load handler that resizes iframe if page is embedded.
* @listens load
*/
window.addEventListener("load", function () {
if (window.self !== window.top) { resizeIframe(); }
});