/* jshint esversion: 2024, module: true */ // import { StructuredEditor } from "./structured-editor.js"; /** * Network Classroom Web UI - Modern ES2024 Implementation * @module NetworkUI */ // Global state const state = { currentInterface: null, interfaces: [], theme: localStorage.getItem("network-ui-theme") || "dark", structuredEditor: null, editorMode: "raw", }; /** * DOM Elements cache * @type {Object} */ const elements = Object.freeze({ themeToggle: document.getElementById("themeToggle"), themeIcon: document.getElementById("themeIcon"), panels: Object.freeze({ status: document.getElementById("panelStatus"), configs: document.getElementById("panelConfigs"), logs: document.getElementById("panelLogs"), commands: document.getElementById("panelCommands"), }), buttons: Object.freeze({ nav: document.querySelectorAll(".nav-button"), refreshStatus: document.getElementById("refreshStatus"), refreshConfigs: document.getElementById("refreshConfigs"), saveConfig: document.getElementById("saveConfig"), validateConfig: document.getElementById("validateConfig"), refreshLogs: document.getElementById("refreshLogs"), restartNetworkd: document.getElementById("restartNetworkd"), rebootDevice: document.getElementById("rebootDevice"), }), inputs: Object.freeze({ configSelect: document.getElementById("configSelect"), cfgEditor: document.getElementById("cfgEditor"), restartAfterSave: document.getElementById("restartAfterSave"), }), outputs: Object.freeze({ ifaceTabs: document.getElementById("interfaceTabs"), ifaceDetails: document.getElementById("interfaceDetails"), validateResult: document.getElementById("validateResult"), logsArea: document.getElementById("logsArea"), cmdResult: document.getElementById("cmdResult"), }), }); /** * Initialize the application * @function init */ const init = () => { applyTheme(state.theme); setupEventListeners(); loadStatus(); }; /** * Toggle between light and dark themes * @function toggleTheme */ const toggleTheme = () => { const newTheme = state.theme === "dark" ? "light" : "dark"; applyTheme(newTheme); }; /** * Apply theme to document * @function applyTheme * @param {string} theme - Theme name ('light' or 'dark') */ const applyTheme = (theme) => { document.documentElement.setAttribute("data-theme", theme); state.theme = theme; localStorage.setItem("network-ui-theme", theme); // Update theme icon if (elements.themeIcon) { elements.themeIcon.textContent = theme === "dark" ? "☀️" : "🌙"; } }; /** * Set up all event listeners * @function setupEventListeners */ const setupEventListeners = () => { // Navigation elements.themeToggle?.addEventListener("click", toggleTheme); elements.buttons.nav.forEach((button) => { button.addEventListener("click", (event) => { show(event.currentTarget.dataset.panel); }); }); // Status panel elements.buttons.refreshStatus?.addEventListener("click", loadStatus); // Configs panel elements.buttons.refreshConfigs?.addEventListener("click", refreshConfigs); elements.buttons.saveConfig?.addEventListener("click", saveConfig); elements.buttons.validateConfig?.addEventListener("click", validateConfig); elements.inputs.configSelect?.addEventListener("change", loadConfig); // Logs panel elements.buttons.refreshLogs?.addEventListener("click", loadLogs); // Commands panel elements.buttons.restartNetworkd?.addEventListener("click", restartNetworkd); elements.buttons.rebootDevice?.addEventListener("click", rebootDevice); // Touch support document.addEventListener("touchstart", handleTouchStart, { passive: true }); }; /** * Handle touch events for better mobile support * @function handleTouchStart * @param {TouchEvent} event */ const handleTouchStart = (event) => { // Add visual feedback for touch if ( event.target.classList.contains("button") || event.target.classList.contains("nav-button") ) { event.target.style.opacity = "0.7"; setTimeout(() => { event.target.style.opacity = ""; }, 150); } }; /** * Show specified panel and hide others * @function show * @param {string} panel - Panel to show */ const show = (panel) => { // Hide all panels and remove active class from buttons Object.values(elements.panels).forEach((p) => p?.classList.remove("active")); elements.buttons.nav.forEach((btn) => btn?.classList.remove("active")); // Show selected panel and activate button elements.panels[panel]?.classList.add("active"); document.querySelector(`[data-panel="${panel}"]`)?.classList.add("active"); // Load panel-specific data const panelActions = { status: loadStatus, configs: refreshConfigs, logs: loadLogs, }; panelActions[panel]?.(); }; /** * API utility function * @function api * @param {string} path - API endpoint * @param {Object} [options] - Fetch options * @returns {Promise} */ const api = async (path, options = {}) => { const response = await fetch(path, options); if (!response.ok) { const text = await response.text(); throw new Error(`${response.status} ${text}`); } return response; }; /** * Load and display network status * @function loadStatus */ const loadStatus = async () => { try { const response = await api("/api/status"); const data = await response.json(); state.interfaces = data.Interfaces ?? []; renderInterfaceTabs(state.interfaces); // Show first interface by default if (state.interfaces.length > 0 && !state.currentInterface) { showInterfaceDetails(state.interfaces[0]); } } catch (error) { elements.outputs.ifaceDetails.innerHTML = `
Error loading status: ${error.message}
`; } }; /** * Render interface tabs * @function renderInterfaceTabs * @param {Array} interfaces - Array of interface objects */ const renderInterfaceTabs = (interfaces) => { if (!interfaces.length) { elements.outputs.ifaceTabs.innerHTML = '
No network interfaces found
'; elements.outputs.ifaceDetails.innerHTML = ""; return; } const tabsHTML = interfaces .map( (iface) => ` `, ) .join(""); elements.outputs.ifaceTabs.innerHTML = `
${tabsHTML}
`; // Add event listeners to tabs elements.outputs.ifaceTabs .querySelectorAll(".interface-tab") .forEach((tab) => { tab.addEventListener("click", (event) => { const ifaceName = event.currentTarget.dataset.interface; const iface = interfaces.find((i) => i.Name === ifaceName); if (iface) { showInterfaceDetails(iface); } }); }); }; /** * Show detailed interface information with abbreviations * @function showInterfaceDetails * @param {Object} iface - Interface object */ const showInterfaceDetails = (iface) => { state.currentInterface = iface; // Update active tab elements.outputs.ifaceTabs .querySelectorAll(".interface-tab") .forEach((tab) => { tab.classList.toggle("active", tab.dataset.interface === iface.Name); }); const detailsHTML = `
${renderDetailRow("Link File", iface.LinkFile)} ${renderDetailRow("Network File", iface.NetworkFile)} ${renderDetailRow("State", iface.State, getStateClass(iface))} ${renderDetailRow("Online State", iface.OnlineState)} ${renderDetailRow("Type", iface.Type)} ${renderDetailRow("Path", iface.Path)} ${renderDetailRow("Driver", iface.Driver)} ${renderDetailRow("Vendor", iface.Vendor)} ${renderDetailRow("Model", iface.Model)} ${renderDetailRow("Hardware Address", arrayToMac(iface.HardwareAddress))} ${renderDetailRow("MTU", iface.MTU ? `${iface.MTU} (min: ${iface.MTUMin ?? "?"}, max: ${iface.MTUMax ?? "?"})` : "")} ${renderDetailRow("QDisc", iface.QDisc)} ${renderDetailRow("IPv6 Address Generation Mode", iface.IPv6AddressGenerationMode)} ${renderDetailRow("Number of Queues (Tx/Rx)", iface.Queues ? `${iface.Queues.Tx ?? "?"}/${iface.Queues.Rx ?? "?"}` : "")} ${renderDetailRow("Auto negotiation", iface.AutoNegotiation ? "yes" : "no")} ${renderDetailRow("Speed", iface.Speed)} ${renderDetailRow("Duplex", iface.Duplex)} ${renderDetailRow("Port", iface.Port)} ${renderDetailRow("Address", renderAddressList(iface.Addresses))} ${renderDetailRow("DNS", renderDNSServerList(iface.DNS))} ${renderDetailRow("NTP", iface.NTP)} ${renderDetailRow("Activation Policy", iface.ActivationPolicy)} ${renderDetailRow("Required For Online", iface.RequiredForOnline ? "yes" : "no")} ${renderDetailRow("Connected To", iface.ConnectedTo)} ${renderDetailRow("Offered DHCP leases", renderDHCPLeases(iface.DHCPLeases))}
`; elements.outputs.ifaceDetails.innerHTML = detailsHTML; }; /** * Render a detail row with abbreviations * @function renderDetailRow * @param {string} label - Row label * @param {string} value - Row value * @param {string} [valueClass] - CSS class for value * @returns {string} HTML string */ const renderDetailRow = (label, value, valueClass = "") => { if (!value) return ""; // Add abbreviations for common networking terms const abbreviations = { MTU: "Maximum Transmission Unit", QDisc: "Queueing Discipline", Tx: "Transmit", Rx: "Receive", DNS: "Domain Name System", NTP: "Network Time Protocol", DHCP: "Dynamic Host Configuration Protocol", MAC: "Media Access Control", IP: "Internet Protocol", IPv6: "Internet Protocol version 6", }; const abbrLabel = Object.keys(abbreviations).includes(label) ? `${label}` : label; return `
${abbrLabel}: ${value}
`; }; /** * Render address list * @function renderAddressList * @param {Array} addresses - Array of addresses * @returns {string} Formatted addresses */ const renderAddressList = (addresses) => { if (!addresses?.length) return ""; return addresses .map((addr) => { const ip = ipFromArray(addr); return ip ? `
${ip}
` : ""; }) .join(""); }; /** * Render DNS server list * @function renderDNSServerList * @param {Array} dnsServers - Array of DNS servers * @returns {string} Formatted DNS servers */ const renderDNSServerList = (dnsServers) => { if (!dnsServers?.length) return ""; return dnsServers .map((dns) => { const server = ipFromArray(dns.Address ?? dns); return server ? `
${server}
` : ""; }) .join(""); }; /** * Render DHCP leases * @function renderDHCPLeases * @param {Array} leases - Array of DHCP leases * @returns {string} Formatted leases */ const renderDHCPLeases = (leases) => { if (!leases?.length) return ""; return leases .map((lease) => { const ip = lease.IP ?? lease; const to = lease.To ?? lease.MAC ?? ""; return `
${ip} (to ${to})
`; }) .join(""); }; /** * Get CSS class for interface state * @function getStateClass * @param {Object} iface - Interface object * @returns {string} CSS class */ const getStateClass = (iface) => { const state = iface.OperationalState ?? iface.AdministrativeState ?? iface.State ?? ""; return state.toLowerCase().includes("up") || state.toLowerCase().includes("routable") || state.toLowerCase().includes("configured") ? "state-up" : "state-down"; }; /** * Get display text for interface state * @function getStateText * @param {Object} iface - Interface object * @returns {string} State text */ const getStateText = (iface) => { return ( iface.OperationalState ?? iface.AdministrativeState ?? iface.State ?? "unknown" ); }; /** * Convert byte array to MAC address * @function arrayToMac * @param {Array} bytes - Byte array * @returns {string} MAC address */ const arrayToMac = (bytes) => { if (!Array.isArray(bytes)) return ""; return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(":"); }; /** * Convert byte array to IP address * @function ipFromArray * @param {Array|Object} obj - IP data * @returns {string} IP address */ const ipFromArray = (obj) => { let bytes = null; if (Array.isArray(obj)) { bytes = obj; } else if (obj?.Address && Array.isArray(obj.Address)) { bytes = obj.Address; } else { return ""; } // IPv4 if (bytes.length === 4) { return bytes.join("."); } // IPv6 if (bytes.length === 16) { const parts = []; for (let i = 0; i < 16; i += 2) { parts.push(((bytes[i] << 8) | bytes[i + 1]).toString(16)); } return parts .join(":") .replace(/(^|:)0+/g, "$1") .replace(/:{3,}/, "::"); } return ""; }; /** * Convert route object to string * @function routeToString * @param {Object} route - Route object * @returns {string} Route string */ const routeToString = (route) => { if (!route) return ""; const destination = route.Destination ? ipFromArray(route.Destination) : "default"; const gateway = route.Gateway ? ipFromArray(route.Gateway) : ""; return gateway ? `${destination} → ${gateway}` : destination; }; /** * Refresh configuration file list * @function refreshConfigs */ const refreshConfigs = async () => { try { const response = await api("/api/configs"); const data = await response.json(); elements.inputs.configSelect.innerHTML = ""; data.files?.forEach((file) => { const option = new Option(file, file); elements.inputs.configSelect.add(option); }); if (data.files?.length > 0) { await loadConfig(); } else { elements.inputs.cfgEditor.value = ""; } } catch (error) { alert(`Failed to list configs: ${error.message}`); } }; /** * Load selected configuration file * @function loadConfig */ const loadConfig = async () => { const name = elements.inputs.configSelect.value; if (!name) return; try { const response = await api(`/api/config/${encodeURIComponent(name)}`); const text = await response.text(); elements.inputs.cfgEditor.value = text; elements.outputs.validateResult.textContent = ""; } catch (error) { alert(`Failed to load: ${error.message}`); } }; /** * Validate current configuration * @function validateConfig */ const validateConfig = async () => { const name = elements.inputs.configSelect.value; const content = elements.inputs.cfgEditor.value; elements.outputs.validateResult.textContent = "Validating..."; elements.outputs.validateResult.className = "validation-pending"; try { const response = await api("/api/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, content }), }); const result = await response.json(); if (result.ok) { elements.outputs.validateResult.textContent = "✓ Configuration is valid"; elements.outputs.validateResult.className = "validation-success"; } else { elements.outputs.validateResult.textContent = `✗ ${result.output || "Validation failed"}`; elements.outputs.validateResult.className = "validation-error"; } } catch (error) { elements.outputs.validateResult.textContent = `✗ Error: ${error.message}`; elements.outputs.validateResult.className = "validation-error"; } }; /** * Save current configuration * @function saveConfig */ const saveConfig = async () => { const name = elements.inputs.configSelect.value; const content = elements.inputs.cfgEditor.value; const restart = elements.inputs.restartAfterSave.checked; if ( !confirm( `Save file ${name}? This will create a backup and ${restart ? "restart" : "not restart"} networkd.`, ) ) { return; } try { const response = await api("/api/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, content, restart }), }); const result = await response.json(); alert(`Saved: ${result.status ?? "ok"}`); } catch (error) { alert(`Save failed: ${error.message}`); } }; /** * Load system logs * @function loadLogs */ const loadLogs = async () => { try { const response = await api("/api/logs"); const text = await response.text(); elements.outputs.logsArea.textContent = text; } catch (error) { elements.outputs.logsArea.textContent = `Error: ${error.message}`; } }; /** * Restart networkd service * @function restartNetworkd */ const restartNetworkd = async () => { if (!confirm("Restart systemd-networkd? Active connections may be reset.")) return; try { const response = await api("/api/reload", { method: "POST" }); const result = await response.json(); elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; } catch (error) { elements.outputs.cmdResult.textContent = `Error: ${error.message}`; } }; /** * Reboot the device * @function rebootDevice */ const rebootDevice = async () => { if (!confirm("Reboot device now?")) return; try { const response = await api("/api/reboot", { method: "POST" }); const result = await response.json(); elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; } catch (error) { elements.outputs.cmdResult.textContent = `Error: ${error.message}`; } }; // Initialize structured editor when needed const initStructuredEditor = () => { if (!state.structuredEditor) { state.structuredEditor = new StructuredEditor( document.getElementById("structuredEditorContainer"), ); } }; // Initialize when DOM is loaded document.addEventListener("DOMContentLoaded", init); // Export for module usage export { show, api, loadStatus, refreshConfigs, loadConfig, validateConfig, saveConfig, loadLogs, restartNetworkd, rebootDevice, arrayToMac, ipFromArray, routeToString, };