From b0c76dcc159ead3d67314da3a71d60bad9385991 Mon Sep 17 00:00:00 2001 From: Petri Hienonen Date: Sun, 28 Sep 2025 13:41:46 +0300 Subject: Split the system --- static/api-client.js | 70 ++++ static/app.js | 886 +++++++++++-------------------------------- static/config-manager.js | 120 ++++++ static/interface-renderer.js | 250 ++++++++++++ static/theme-manager.js | 59 +++ 5 files changed, 729 insertions(+), 656 deletions(-) create mode 100644 static/api-client.js create mode 100644 static/config-manager.js create mode 100644 static/interface-renderer.js create mode 100644 static/theme-manager.js diff --git a/static/api-client.js b/static/api-client.js new file mode 100644 index 0000000..4a1b81e --- /dev/null +++ b/static/api-client.js @@ -0,0 +1,70 @@ +/* jshint esversion: 2024, module: true */ + +/** + * API Client for network operations + * @class ApiClient + */ +class ApiClient { + /** + * API utility function + * @method request + * @param {string} path - API endpoint + * @param {Object} [options] - Fetch options + * @returns {Promise} + */ + async request(path, options = {}) { + const response = await fetch(path, options); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`${response.status} ${text}`); + } + + return response; + } + + /** + * GET request returning JSON + * @method get + * @param {string} path - API endpoint + * @returns {Promise} + */ + async get(path) { + const response = await this.request(path); + return response.json(); + } + + /** + * GET request returning text + * @method getText + * @param {string} path - API endpoint + * @returns {Promise} + */ + async getText(path) { + const response = await this.request(path); + return response.text(); + } + + /** + * POST request with JSON body + * @method post + * @param {string} path - API endpoint + * @param {Object} [data] - Request body + * @returns {Promise} + */ + async post(path, data = null) { + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }; + + if (data) { + options.body = JSON.stringify(data); + } + + const response = await this.request(path, options); + return response.json(); + } +} + +export { ApiClient }; 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 = `
Error loading status: ${error.message}
`; + } + } + + /** + * 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} - */ -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, -}; +export { Application }; diff --git a/static/config-manager.js b/static/config-manager.js new file mode 100644 index 0000000..8dc629b --- /dev/null +++ b/static/config-manager.js @@ -0,0 +1,120 @@ +/* jshint esversion: 2024, module: true */ + +/** + * Configuration Manager for handling config files + * @class ConfigManager + */ +class ConfigManager { + /** + * @param {Object} elements - DOM elements + * @param {ApiClient} apiClient - API client instance + * @param {Object} state - Application state + */ + constructor(elements, apiClient, state) { + this.elements = elements; + this.apiClient = apiClient; + this.state = state; + } + + /** + * Set up configuration event listeners + * @method setupEventListeners + */ + setupEventListeners() { + this.elements.buttons.refreshConfigs?.addEventListener('click', () => this.refreshConfigs()); + this.elements.buttons.saveConfig?.addEventListener('click', () => this.saveConfig()); + this.elements.buttons.validateConfig?.addEventListener('click', () => this.validateConfig()); + this.elements.inputs.configSelect?.addEventListener('change', () => this.loadConfig()); + } + + /** + * Refresh configuration file list + * @method refreshConfigs + */ + async refreshConfigs() { + try { + const data = await this.apiClient.get('/api/configs'); + + this.elements.inputs.configSelect.innerHTML = ''; + data.files?.forEach(file => { + const option = new Option(file, file); + this.elements.inputs.configSelect.add(option); + }); + + if (data.files?.length > 0) { + await this.loadConfig(); + } else { + this.elements.inputs.cfgEditor.value = ''; + } + } catch (error) { + alert(`Failed to list configs: ${error.message}`); + } + } + + /** + * Load selected configuration file + * @method loadConfig + */ + async loadConfig() { + const name = this.elements.inputs.configSelect.value; + if (!name) return; + + try { + const text = await this.apiClient.getText(`/api/config/${encodeURIComponent(name)}`); + this.elements.inputs.cfgEditor.value = text; + this.elements.outputs.validateResult.textContent = ''; + } catch (error) { + alert(`Failed to load: ${error.message}`); + } + } + + /** + * Validate current configuration + * @method validateConfig + */ + async validateConfig() { + const name = this.elements.inputs.configSelect.value; + const content = this.elements.inputs.cfgEditor.value; + + this.elements.outputs.validateResult.textContent = 'Validating...'; + this.elements.outputs.validateResult.className = 'validation-pending'; + + try { + const result = await this.apiClient.post('/api/validate', { name, content }); + + if (result.ok) { + this.elements.outputs.validateResult.textContent = '✓ Configuration is valid'; + this.elements.outputs.validateResult.className = 'validation-success'; + } else { + this.elements.outputs.validateResult.textContent = `✗ ${result.output || 'Validation failed'}`; + this.elements.outputs.validateResult.className = 'validation-error'; + } + } catch (error) { + this.elements.outputs.validateResult.textContent = `✗ Error: ${error.message}`; + this.elements.outputs.validateResult.className = 'validation-error'; + } + } + + /** + * Save current configuration + * @method saveConfig + */ + async saveConfig() { + const name = this.elements.inputs.configSelect.value; + const content = this.elements.inputs.cfgEditor.value; + const restart = this.elements.inputs.restartAfterSave.checked; + + if (!confirm(`Save file ${name}? This will create a backup and ${restart ? 'restart' : 'not restart'} networkd.`)) { + return; + } + + try { + const result = await this.apiClient.post('/api/save', { name, content, restart }); + alert(`Saved: ${result.status ?? 'ok'}`); + } catch (error) { + alert(`Save failed: ${error.message}`); + } + } +} + +export { ConfigManager }; diff --git a/static/interface-renderer.js b/static/interface-renderer.js new file mode 100644 index 0000000..93364c8 --- /dev/null +++ b/static/interface-renderer.js @@ -0,0 +1,250 @@ +/* jshint esversion: 2024, module: true */ + +/** + * Interface Renderer for displaying network interfaces + * @class InterfaceRenderer + */ +class InterfaceRenderer { + /** + * @param {Object} elements - DOM elements + * @param {Object} state - Application state + */ + constructor(elements, state) { + this.elements = elements; + this.state = state; + } + + /** + * Render interface tabs + * @method renderInterfaceTabs + * @param {Array} interfaces - Array of interface objects + */ + renderInterfaceTabs(interfaces) { + if (!interfaces.length) { + this.elements.outputs.ifaceTabs.innerHTML = '
No network interfaces found
'; + this.elements.outputs.ifaceDetails.innerHTML = ''; + return; + } + + const tabsHTML = interfaces.map(iface => ` + + `).join(''); + + this.elements.outputs.ifaceTabs.innerHTML = `
${tabsHTML}
`; + + // Add event listeners to tabs + this.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) { + this.showInterfaceDetails(iface); + } + }); + }); + } + + /** + * Show detailed interface information with abbreviations + * @method showInterfaceDetails + * @param {Object} iface - Interface object + */ + showInterfaceDetails(iface) { + this.state.currentInterface = iface; + + // Update active tab + this.elements.outputs.ifaceTabs.querySelectorAll('.interface-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.interface === iface.Name); + }); + + const detailsHTML = ` +
+ ${this.renderDetailRow('Link File', iface.LinkFile)} + ${this.renderDetailRow('Network File', iface.NetworkFile)} + ${this.renderDetailRow('State', iface.State, this.getStateClass(iface))} + ${this.renderDetailRow('Online State', iface.OnlineState)} + ${this.renderDetailRow('Type', iface.Type)} + ${this.renderDetailRow('Path', iface.Path)} + ${this.renderDetailRow('Driver', iface.Driver)} + ${this.renderDetailRow('Vendor', iface.Vendor)} + ${this.renderDetailRow('Model', iface.Model)} + ${this.renderDetailRow('Hardware Address', this.arrayToMac(iface.HardwareAddress))} + ${this.renderDetailRow('MTU', iface.MTU ? `${iface.MTU} (min: ${iface.MTUMin ?? '?'}, max: ${iface.MTUMax ?? '?'})` : '')} + ${this.renderDetailRow('QDisc', iface.QDisc)} + ${this.renderDetailRow('IPv6 Address Generation Mode', iface.IPv6AddressGenerationMode)} + ${this.renderDetailRow('Number of Queues (Tx/Rx)', iface.Queues ? `${iface.Queues.Tx ?? '?'}/${iface.Queues.Rx ?? '?'}` : '')} + ${this.renderDetailRow('Auto negotiation', iface.AutoNegotiation ? 'yes' : 'no')} + ${this.renderDetailRow('Speed', iface.Speed)} + ${this.renderDetailRow('Duplex', iface.Duplex)} + ${this.renderDetailRow('Port', iface.Port)} + ${this.renderDetailRow('Address', this.renderAddressList(iface.Addresses))} + ${this.renderDetailRow('DNS', this.renderDNSServerList(iface.DNS))} + ${this.renderDetailRow('NTP', iface.NTP)} + ${this.renderDetailRow('Activation Policy', iface.ActivationPolicy)} + ${this.renderDetailRow('Required For Online', iface.RequiredForOnline ? 'yes' : 'no')} + ${this.renderDetailRow('Connected To', iface.ConnectedTo)} + ${this.renderDetailRow('Offered DHCP leases', this.renderDHCPLeases(iface.DHCPLeases))} +
+ `; + + this.elements.outputs.ifaceDetails.innerHTML = detailsHTML; + } + + /** + * Render a detail row with abbreviations + * @method renderDetailRow + * @param {string} label - Row label + * @param {string} value - Row value + * @param {string} [valueClass] - CSS class for value + * @returns {string} HTML string + */ + 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 + * @method renderAddressList + * @param {Array} addresses - Array of addresses + * @returns {string} Formatted addresses + */ + renderAddressList(addresses) { + if (!addresses?.length) return ''; + + return addresses.map(addr => { + const ip = this.ipFromArray(addr); + return ip ? `
${ip}
` : ''; + }).join(''); + } + + /** + * Render DNS server list + * @method renderDNSServerList + * @param {Array} dnsServers - Array of DNS servers + * @returns {string} Formatted DNS servers + */ + renderDNSServerList(dnsServers) { + if (!dnsServers?.length) return ''; + + return dnsServers.map(dns => { + const server = this.ipFromArray(dns.Address ?? dns); + return server ? `
${server}
` : ''; + }).join(''); + } + + /** + * Render DHCP leases + * @method renderDHCPLeases + * @param {Array} leases - Array of DHCP leases + * @returns {string} Formatted leases + */ + 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 + * @method getStateClass + * @param {Object} iface - Interface object + * @returns {string} CSS class + */ + 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 + * @method getStateText + * @param {Object} iface - Interface object + * @returns {string} State text + */ + getStateText(iface) { + return iface.OperationalState ?? iface.AdministrativeState ?? iface.State ?? 'unknown'; + } + + /** + * Convert byte array to MAC address + * @method arrayToMac + * @param {Array} bytes - Byte array + * @returns {string} MAC address + */ + arrayToMac(bytes) { + if (!Array.isArray(bytes)) return ''; + + return bytes.map(byte => byte.toString(16).padStart(2, '0')).join(':'); + } + + /** + * Convert byte array to IP address + * @method ipFromArray + * @param {Array|Object} obj - IP data + * @returns {string} IP address + */ + 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 ''; + } +} + +export { InterfaceRenderer }; diff --git a/static/theme-manager.js b/static/theme-manager.js new file mode 100644 index 0000000..2ad080b --- /dev/null +++ b/static/theme-manager.js @@ -0,0 +1,59 @@ +/* jshint esversion: 2024, module: true */ + +/** + * Theme Manager for handling light/dark themes + * @class ThemeManager + */ +class ThemeManager { + /** + * @param {Object} elements - DOM elements + */ + constructor(elements) { + this.elements = elements; + this.theme = localStorage.getItem('network-ui-theme') || 'dark'; + } + + /** + * Initialize theme manager + * @method init + */ + init() { + this.applyTheme(this.theme); + this.setupEventListeners(); + } + + /** + * Set up theme event listeners + * @method setupEventListeners + */ + setupEventListeners() { + this.elements.themeToggle?.addEventListener('click', () => this.toggleTheme()); + } + + /** + * Toggle between light and dark themes + * @method toggleTheme + */ + toggleTheme() { + const newTheme = this.theme === 'dark' ? 'light' : 'dark'; + this.applyTheme(newTheme); + } + + /** + * Apply theme to document + * @method applyTheme + * @param {string} theme - Theme name ('light' or 'dark') + */ + applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + this.theme = theme; + localStorage.setItem('network-ui-theme', theme); + + // Update theme icon + if (this.elements.themeIcon) { + this.elements.themeIcon.textContent = theme === 'dark' ? '☀️' : '🌙'; + } + } +} + +export { ThemeManager }; -- cgit v1.2.3-70-g09d2