diff options
Diffstat (limited to 'static/app.js')
| -rw-r--r-- | static/app.js | 660 |
1 files changed, 660 insertions, 0 deletions
diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..54c1681 --- /dev/null +++ b/static/app.js @@ -0,0 +1,660 @@ +/* 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<Response>} + */ +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 = `<div class="error-message">Error loading status: ${error.message}</div>`; + } +}; + +/** + * Render interface tabs + * @function renderInterfaceTabs + * @param {Array} interfaces - Array of interface objects + */ +const renderInterfaceTabs = (interfaces) => { + if (!interfaces.length) { + elements.outputs.ifaceTabs.innerHTML = + '<div class="no-interfaces">No network interfaces found</div>'; + elements.outputs.ifaceDetails.innerHTML = ""; + return; + } + + const tabsHTML = interfaces + .map( + (iface) => ` + <button class="interface-tab ${iface === state.currentInterface ? "active" : ""}" + data-interface="${iface.Name}"> + ${iface.Name} + <span class="interface-state ${getStateClass(iface)}">${getStateText(iface)}</span> + </button> + `, + ) + .join(""); + + elements.outputs.ifaceTabs.innerHTML = `<div class="interface-tabs-container">${tabsHTML}</div>`; + + // 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 = ` + <div class="interface-detail-grid"> + ${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))} + </div> + `; + + 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) + ? `<abbr title="${abbreviations[label]}">${label}</abbr>` + : label; + + return ` + <div class="detail-row"> + <span class="detail-label">${abbrLabel}:</span> + <span class="detail-value ${valueClass}">${value}</span> + </div> + `; +}; + +/** + * 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 ? `<div class="address-item">${ip}</div>` : ""; + }) + .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 ? `<div class="dns-item">${server}</div>` : ""; + }) + .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 `<div class="lease-item">${ip} (to ${to})</div>`; + }) + .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, +}; |
