diff options
Diffstat (limited to 'static/app.js')
| -rw-r--r-- | static/app.js | 886 |
1 files changed, 230 insertions, 656 deletions
diff --git a/static/app.js b/static/app.js index 54c1681..8a04c45 100644 --- a/static/app.js +++ b/static/app.js @@ -1,660 +1,234 @@ /* 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"), - }), +import { ApiClient } from "./api-client.js"; +import { ConfigManager } from "./config-manager.js"; +import { InterfaceRenderer } from "./interface-renderer.js"; +import { ThemeManager } from "./theme-manager.js"; + +/** + * Main Application Class + * @class Application + */ +class Application { + /** + * @param {Object} elements - DOM elements + */ + constructor(elements) { + this.elements = elements; + this.state = { + currentInterface: null, + interfaces: [], + editorMode: "raw", + }; + + // Initialize modules + this.themeManager = new ThemeManager(elements); + this.apiClient = new ApiClient(); + this.interfaceRenderer = new InterfaceRenderer(elements, this.state); + this.configManager = new ConfigManager( + elements, + this.apiClient, + this.state, + ); + } + + /** + * Initialize the application + * @method init + */ + init() { + this.themeManager.init(); + this.setupEventListeners(); + this.loadStatus(); + } + + /** + * Set up all event listeners + * @method setupEventListeners + */ + setupEventListeners() { + // Navigation + this.elements.buttons.nav.forEach((button) => { + button.addEventListener("click", (event) => { + this.show(event.currentTarget.dataset.panel); + }); + }); + + // Status panel + this.elements.buttons.refreshStatus?.addEventListener("click", () => + this.loadStatus(), + ); + + // Configs panel - delegated to ConfigManager + this.configManager.setupEventListeners(); + + // Logs panel + this.elements.buttons.refreshLogs?.addEventListener("click", () => + this.loadLogs(), + ); + + // Commands panel + this.elements.buttons.restartNetworkd?.addEventListener("click", () => + this.restartNetworkd(), + ); + this.elements.buttons.rebootDevice?.addEventListener("click", () => + this.rebootDevice(), + ); + + // Touch support + document.addEventListener("touchstart", this.handleTouchStart, { + passive: true, + }); + } + + /** + * Handle touch events for better mobile support + * @method handleTouchStart + * @param {TouchEvent} event + */ + 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 + * @method show + * @param {string} panel - Panel to show + */ + show(panel) { + // Hide all panels and remove active class from buttons + Object.values(this.elements.panels).forEach((p) => + p?.classList.remove("active"), + ); + this.elements.buttons.nav.forEach((btn) => btn?.classList.remove("active")); + + // Show selected panel and activate button + this.elements.panels[panel]?.classList.add("active"); + document.querySelector(`[data-panel="${panel}"]`)?.classList.add("active"); + + // Load panel-specific data + const panelActions = { + status: () => this.loadStatus(), + configs: () => this.configManager.refreshConfigs(), + logs: () => this.loadLogs(), + }; + + panelActions[panel]?.(); + } + + /** + * Load and display network status + * @method loadStatus + */ + async loadStatus() { + try { + const data = await this.apiClient.get("/api/status"); + this.state.interfaces = data.Interfaces ?? []; + this.interfaceRenderer.renderInterfaceTabs(this.state.interfaces); + + // Show first interface by default + if (this.state.interfaces.length > 0 && !this.state.currentInterface) { + this.interfaceRenderer.showInterfaceDetails(this.state.interfaces[0]); + } + } catch (error) { + this.elements.outputs.ifaceDetails.innerHTML = `<div class="error-message">Error loading status: ${error.message}</div>`; + } + } + + /** + * Load system logs + * @method loadLogs + */ + async loadLogs() { + try { + const text = await this.apiClient.getText("/api/logs"); + this.elements.outputs.logsArea.textContent = text; + } catch (error) { + this.elements.outputs.logsArea.textContent = `Error: ${error.message}`; + } + } + + /** + * Restart networkd service + * @method restartNetworkd + */ + async restartNetworkd() { + if (!confirm("Restart systemd-networkd? Active connections may be reset.")) + return; + + try { + const result = await this.apiClient.post("/api/reload"); + this.elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; + } catch (error) { + this.elements.outputs.cmdResult.textContent = `Error: ${error.message}`; + } + } + + /** + * Reboot the device + * @method rebootDevice + */ + async rebootDevice() { + if (!confirm("Reboot device now?")) return; + + try { + const result = await this.apiClient.post("/api/reboot"); + this.elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; + } catch (error) { + this.elements.outputs.cmdResult.textContent = `Error: ${error.message}`; + } + } +} + +// Initialize application when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + const elements = { + themeToggle: document.getElementById("themeToggle"), + themeIcon: document.getElementById("themeIcon"), + panels: { + status: document.getElementById("panelStatus"), + configs: document.getElementById("panelConfigs"), + logs: document.getElementById("panelLogs"), + commands: document.getElementById("panelCommands"), + }, + buttons: { + 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: { + configSelect: document.getElementById("configSelect"), + cfgEditor: document.getElementById("cfgEditor"), + restartAfterSave: document.getElementById("restartAfterSave"), + }, + outputs: { + ifaceTabs: document.getElementById("interfaceTabs"), + ifaceDetails: document.getElementById("interfaceDetails"), + validateResult: document.getElementById("validateResult"), + logsArea: document.getElementById("logsArea"), + cmdResult: document.getElementById("cmdResult"), + }, + }; + + const app = new Application(elements); + app.init(); + + // Make app globally available for debugging + window.app = app; }); -/** - * 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, -}; +export { Application }; |
