From 8489f1f83da5dcc5818401393b1f6a430eea677c Mon Sep 17 00:00:00 2001 From: Petri Hienonen Date: Sun, 28 Sep 2025 15:08:14 +0300 Subject: Intial version --- static/api-client.js | 104 +++++++++++++++--- static/app.js | 244 +++++++++++++++++++++++++++++++++++-------- static/config-manager.js | 130 ++++++++++++----------- static/enums.js | 67 ++++++++++++ static/index.html | 4 +- static/interface-renderer.js | 232 +++++++++++++--------------------------- static/utils.js | 155 +++++++++++++++++++++++++++ 7 files changed, 658 insertions(+), 278 deletions(-) create mode 100644 static/enums.js create mode 100644 static/utils.js (limited to 'static') diff --git a/static/api-client.js b/static/api-client.js index 4a1b81e..7a6ffbd 100644 --- a/static/api-client.js +++ b/static/api-client.js @@ -1,18 +1,20 @@ /* jshint esversion: 2024, module: true */ +import { ApiEndpoints } from './enums.js'; + /** - * API Client for network operations + * Static API Client for network operations * @class ApiClient */ class ApiClient { /** * API utility function - * @method request + * @static * @param {string} path - API endpoint * @param {Object} [options] - Fetch options * @returns {Promise} */ - async request(path, options = {}) { + static async request(path, options = {}) { const response = await fetch(path, options); if (!response.ok) { @@ -25,34 +27,34 @@ class ApiClient { /** * GET request returning JSON - * @method get + * @static * @param {string} path - API endpoint * @returns {Promise} */ - async get(path) { - const response = await this.request(path); + static async get(path) { + const response = await ApiClient.request(path); return response.json(); } /** * GET request returning text - * @method getText + * @static * @param {string} path - API endpoint * @returns {Promise} */ - async getText(path) { - const response = await this.request(path); + static async getText(path) { + const response = await ApiClient.request(path); return response.text(); } /** * POST request with JSON body - * @method post + * @static * @param {string} path - API endpoint * @param {Object} [data] - Request body * @returns {Promise} */ - async post(path, data = null) { + static async post(path, data = null) { const options = { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -62,9 +64,87 @@ class ApiClient { options.body = JSON.stringify(data); } - const response = await this.request(path, options); + const response = await ApiClient.request(path, options); return response.json(); } + + /** + * Get network status + * @static + * @returns {Promise} + */ + static async getNetworkStatus() { + return ApiClient.get(ApiEndpoints.STATUS); + } + + /** + * Get configuration files list + * @static + * @returns {Promise} + */ + static async getConfigFiles() { + return ApiClient.get(ApiEndpoints.CONFIGS); + } + + /** + * Get configuration file content + * @static + * @param {string} name - File name + * @returns {Promise} + */ + static async getConfigFile(name) { + return ApiClient.getText(`${ApiEndpoints.CONFIG}/${encodeURIComponent(name)}`); + } + + /** + * Validate configuration + * @static + * @param {string} name - File name + * @param {string} content - File content + * @returns {Promise} + */ + static async validateConfig(name, content) { + return ApiClient.post(ApiEndpoints.VALIDATE, { name, content }); + } + + /** + * Save configuration file + * @static + * @param {string} name - File name + * @param {string} content - File content + * @param {boolean} restart - Restart service + * @returns {Promise} + */ + static async saveConfig(name, content, restart) { + return ApiClient.post(ApiEndpoints.SAVE, { name, content, restart }); + } + + /** + * Get system logs + * @static + * @returns {Promise} + */ + static async getSystemLogs() { + return ApiClient.getText(ApiEndpoints.LOGS); + } + + /** + * Restart networkd service + * @static + * @returns {Promise} + */ + static async restartNetworkd() { + return ApiClient.post(ApiEndpoints.RELOAD); + } + + /** + * Reboot device + * @static + * @returns {Promise} + */ + static async rebootDevice() { + return ApiClient.post(ApiEndpoints.REBOOT); + } } export { ApiClient }; diff --git a/static/app.js b/static/app.js index 3599cba..22e52b5 100644 --- a/static/app.js +++ b/static/app.js @@ -2,6 +2,7 @@ import { ApiClient } from './api-client.js'; import { ConfigManager } from './config-manager.js'; +import { EditorMode, ThemeMode, ValidationState } from './enums.js'; import { InterfaceRenderer } from './interface-renderer.js'; import { StructuredEditor } from './structured-editor.js'; import { ThemeManager } from './theme-manager.js'; @@ -19,17 +20,13 @@ class Application { this.state = { currentInterface: null, interfaces: [], - editorMode: 'raw', // 'raw' or 'structured' - currentConfigFile: null + editorMode: EditorMode.STRUCTURED, + currentConfigFile: null, + theme: ThemeMode.DARK }; // 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); - - // Structured editor will be initialized after DOM is ready this.structuredEditor = null; // Create editor mode toggle UI @@ -44,8 +41,6 @@ class Application { this.themeManager.init(); this.setupEventListeners(); this.loadStatus(); - - // Initialize structured editor now that DOM is ready this.initializeStructuredEditor(); } @@ -56,11 +51,11 @@ class Application { createEditorModeToggle() { const editorToggleHTML = `
- - @@ -135,19 +130,19 @@ class Application { // Configs panel - raw editor this.elements.buttons.refreshConfigs?.addEventListener('click', - () => this.configManager.refreshConfigs()); + () => this.handleRefreshConfigs()); this.elements.buttons.saveConfig?.addEventListener('click', () => this.handleSaveConfig()); this.elements.buttons.validateConfig?.addEventListener('click', - () => this.configManager.validateConfig()); + () => this.handleValidateConfig()); this.elements.inputs.configSelect?.addEventListener('change', () => this.handleConfigFileChange()); // Editor mode toggle this.elements.editorButtons?.raw?.addEventListener('click', - () => this.setEditorMode('raw')); + () => this.setEditorMode(EditorMode.RAW)); this.elements.editorButtons?.structured?.addEventListener('click', - () => this.setEditorMode('structured')); + () => this.setEditorMode(EditorMode.STRUCTURED)); // Logs panel this.elements.buttons.refreshLogs?.addEventListener('click', () => this.loadLogs()); @@ -160,15 +155,53 @@ class Application { document.addEventListener('touchstart', this.handleTouchStart, { passive: true }); } - // Add event handlers for structured editor - handleAddSection(detail) { - console.log('Add section:', detail); - // Implement section addition logic + /** + * 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); + } + }; + + /** + * Handle refresh configuration files + * @method handleRefreshConfigs + */ + async handleRefreshConfigs() { + try { + const files = await ConfigManager.refreshConfigs(); + this.updateConfigSelect(files); + + if (files.length > 0) { + this.state.currentConfigFile = files[0]; + await this.handleConfigFileChange(); + } else { + this.elements.inputs.cfgEditor.value = ''; + this.state.currentConfigFile = null; + } + } catch (error) { + alert(error.message); + } } - handleRemoveSection(detail) { - console.log('Remove section:', detail); - // Implement section removal logic + /** + * Update configuration select element + * @method updateConfigSelect + * @param {Array} files - File names + */ + updateConfigSelect(files) { + this.elements.inputs.configSelect.innerHTML = ''; + files.forEach(file => { + const option = new Option(file, file); + this.elements.inputs.configSelect.add(option); + }); } /** @@ -181,18 +214,31 @@ class Application { this.state.currentConfigFile = name; - if (this.state.editorMode === 'raw') { - await this.configManager.loadConfig(); + if (this.state.editorMode === EditorMode.RAW) { + await this.loadConfigForRawEditor(); } else { await this.loadConfigForStructuredEditor(); } } + /** + * Load config for raw editor + * @method loadConfigForRawEditor + */ + async loadConfigForRawEditor() { + try { + const content = await ConfigManager.loadConfig(this.state.currentConfigFile); + this.elements.inputs.cfgEditor.value = content; + this.clearValidationResult(); + } catch (error) { + alert(error.message); + } + } + /** * Load config for structured editor * @method loadConfigForStructuredEditor */ - async loadConfigForStructuredEditor() { if (!this.structuredEditor) { console.error('Structured editor not initialized'); @@ -200,11 +246,11 @@ class Application { } try { - const text = await this.apiClient.getText(`/api/config/${encodeURIComponent(this.state.currentConfigFile)}`); + const content = await ConfigManager.loadConfig(this.state.currentConfigFile); // Parse configuration and get schema const { NetworkConfiguration } = await import('./systemd-network.js'); - const config = NetworkConfiguration.fromSystemdConfiguration(text); + const config = NetworkConfiguration.fromSystemdConfiguration(content); const schema = config.getSchema(); // Load schema into structured editor @@ -219,27 +265,105 @@ class Application { } } + /** + * Handle add section from structured editor + * @method handleAddSection + * @param {Object} detail - Event detail + */ + handleAddSection(detail) { + console.log('Add section:', detail); + // TODO: Implement section addition logic + // This would involve updating the schema and re-rendering + } + + /** + * Handle remove section from structured editor + * @method handleRemoveSection + * @param {Object} detail - Event detail + */ + handleRemoveSection(detail) { + console.log('Remove section:', detail); + // TODO: Implement section removal logic + // This would involve updating the schema and re-rendering + } + + /** + * Handle validate configuration + * @method handleValidateConfig + */ + async handleValidateConfig() { + const name = this.state.currentConfigFile; + if (!name) { + alert('Please select a configuration file first.'); + return; + } + + let content; + if (this.state.editorMode === EditorMode.RAW) { + content = this.elements.inputs.cfgEditor.value; + } else if (this.structuredEditor) { + content = this.structuredEditor.getConfigurationText(); + } else { + alert('Structured editor not available'); + return; + } + + this.setValidationResult(ValidationState.PENDING); + + try { + const result = await ConfigManager.validateConfig(name, content); + + if (result.ok) { + this.setValidationResult(ValidationState.SUCCESS); + } else { + this.setValidationResult(ValidationState.ERROR, result.output); + } + } catch (error) { + this.setValidationResult(ValidationState.ERROR, error.message); + } + } + + /** + * Set validation result + * @method setValidationResult + * @param {Symbol} state - Validation state + * @param {string} [message] - Additional message + */ + setValidationResult(state, message = '') { + this.elements.outputs.validateResult.textContent = ConfigManager.getValidationMessage(state, message); + this.elements.outputs.validateResult.className = ConfigManager.getValidationClass(state); + } + + /** + * Clear validation result + * @method clearValidationResult + */ + clearValidationResult() { + this.elements.outputs.validateResult.textContent = ''; + this.elements.outputs.validateResult.className = ''; + } + /** * Set editor mode (raw or structured) * @method setEditorMode - * @param {string} mode - Editor mode + * @param {Symbol} mode - Editor mode */ setEditorMode(mode) { this.state.editorMode = mode; // Update UI if (this.elements.editorButtons?.raw && this.elements.editorButtons?.structured) { - this.elements.editorButtons.raw.classList.toggle('active', mode === 'raw'); - this.elements.editorButtons.structured.classList.toggle('active', mode === 'structured'); + this.elements.editorButtons.raw.classList.toggle('active', mode === EditorMode.RAW); + this.elements.editorButtons.structured.classList.toggle('active', mode === EditorMode.STRUCTURED); } if (this.elements.editorContainers?.raw && this.elements.editorContainers?.structured) { - this.elements.editorContainers.raw.style.display = mode === 'raw' ? 'block' : 'none'; - this.elements.editorContainers.structured.style.display = mode === 'structured' ? 'block' : 'none'; + this.elements.editorContainers.raw.style.display = mode === EditorMode.RAW ? 'block' : 'none'; + this.elements.editorContainers.structured.style.display = mode === EditorMode.STRUCTURED ? 'block' : 'none'; } // If switching to structured mode and we have a config file loaded, load it - if (mode === 'structured' && this.state.currentConfigFile && this.structuredEditor) { + if (mode === EditorMode.STRUCTURED && this.state.currentConfigFile && this.structuredEditor) { this.loadConfigForStructuredEditor(); } } @@ -263,7 +387,7 @@ class Application { try { let content; - if (this.state.editorMode === 'raw') { + if (this.state.editorMode === EditorMode.RAW) { content = this.elements.inputs.cfgEditor.value; } else if (this.structuredEditor) { content = this.structuredEditor.getConfigurationText(); @@ -271,12 +395,12 @@ class Application { throw new Error('Structured editor not available'); } - const result = await this.apiClient.post('/api/save', { name, content, restart }); + const result = await ConfigManager.saveConfig(name, content, restart); alert(`Saved: ${result.status ?? 'ok'}`); // Refresh the config in structured editor if needed - if (this.state.editorMode === 'structured' && this.structuredEditor) { - await this.structuredEditor.loadConfiguration(content, name); + if (this.state.editorMode === EditorMode.STRUCTURED && this.structuredEditor) { + await this.loadConfigForStructuredEditor(); } } catch (error) { alert(`Save failed: ${error.message}`); @@ -307,7 +431,7 @@ class Application { // Load panel-specific data const panelActions = { status: () => this.loadStatus(), - configs: () => this.configManager.refreshConfigs(), + configs: () => this.handleRefreshConfigs(), logs: () => this.loadLogs(), }; @@ -320,27 +444,59 @@ class Application { */ async loadStatus() { try { - const data = await this.apiClient.get('/api/status'); + const data = await ApiClient.getNetworkStatus(); this.state.interfaces = data.Interfaces ?? []; - this.interfaceRenderer.renderInterfaceTabs(this.state.interfaces); + + // Render interface tabs + const tabsHTML = InterfaceRenderer.renderInterfaceTabs(this.state.interfaces, this.state.currentInterface); + this.elements.outputs.ifaceTabs.innerHTML = tabsHTML; // Show first interface by default if (this.state.interfaces.length > 0 && !this.state.currentInterface) { - this.interfaceRenderer.showInterfaceDetails(this.state.interfaces[0]); + this.showInterfaceDetails(this.state.interfaces[0]); } + + // 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 = this.state.interfaces.find(i => i.Name === ifaceName); + if (iface) { + this.showInterfaceDetails(iface); + } + }); + }); + } catch (error) { this.elements.outputs.ifaceDetails.innerHTML = `
Error loading status: ${error.message}
`; } } + /** + * Show interface details + * @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 = InterfaceRenderer.showInterfaceDetails(iface); + this.elements.outputs.ifaceDetails.innerHTML = detailsHTML; + } + /** * Load system logs * @method loadLogs */ async loadLogs() { try { - const text = await this.apiClient.getText('/api/logs'); + const text = await ApiClient.getSystemLogs(); this.elements.outputs.logsArea.textContent = text; } catch (error) { this.elements.outputs.logsArea.textContent = `Error: ${error.message}`; @@ -355,7 +511,7 @@ class Application { if (!confirm('Restart systemd-networkd? Active connections may be reset.')) return; try { - const result = await this.apiClient.post('/api/reload'); + const result = await ApiClient.restartNetworkd(); this.elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; } catch (error) { this.elements.outputs.cmdResult.textContent = `Error: ${error.message}`; @@ -370,7 +526,7 @@ class Application { if (!confirm('Reboot device now?')) return; try { - const result = await this.apiClient.post('/api/reboot'); + const result = await ApiClient.rebootDevice(); this.elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; } catch (error) { this.elements.outputs.cmdResult.textContent = `Error: ${error.message}`; diff --git a/static/config-manager.js b/static/config-manager.js index ebaf9f2..edb46b2 100644 --- a/static/config-manager.js +++ b/static/config-manager.js @@ -1,92 +1,102 @@ /* jshint esversion: 2024, module: true */ +import { ApiClient } from './api-client.js'; +import { ValidationState } from './enums.js'; + /** - * Configuration Manager for handling config files + * Static 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 + * Refresh configuration file list + * @static + * @returns {Promise} Array of file names */ - constructor(elements, apiClient, state) { - this.elements = elements; - this.apiClient = apiClient; - this.state = state; + static async refreshConfigs() { + try { + const data = await ApiClient.getConfigFiles(); + return data.files || []; + } catch (error) { + throw new Error(`Failed to list configs: ${error.message}`); + } } /** - * Refresh configuration file list - * @method refreshConfigs + * Load selected configuration file + * @static + * @param {string} name - File name + * @returns {Promise} File content */ - async refreshConfigs() { + static async loadConfig(name) { 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) { - this.state.currentConfigFile = data.files[0]; - await this.loadConfig(); - } else { - this.elements.inputs.cfgEditor.value = ''; - this.state.currentConfigFile = null; - } + return await ApiClient.getConfigFile(name); } catch (error) { - alert(`Failed to list configs: ${error.message}`); + throw new Error(`Failed to load: ${error.message}`); } } /** - * Load selected configuration file - * @method loadConfig + * Validate current configuration + * @static + * @param {string} name - File name + * @param {string} content - File content + * @returns {Promise} Validation result */ - async loadConfig() { - const name = this.elements.inputs.configSelect.value; - if (!name) return; - - this.state.currentConfigFile = name; - + static async validateConfig(name, content) { try { - const text = await this.apiClient.getText(`/api/config/${encodeURIComponent(name)}`); - this.elements.inputs.cfgEditor.value = text; - this.elements.outputs.validateResult.textContent = ''; + return await ApiClient.validateConfig(name, content); } catch (error) { - alert(`Failed to load: ${error.message}`); + throw new Error(`Validation failed: ${error.message}`); } } /** - * Validate current configuration - * @method validateConfig + * Save current configuration + * @static + * @param {string} name - File name + * @param {string} content - File content + * @param {boolean} restart - Restart service + * @returns {Promise} Save result */ - 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'; - + static async saveConfig(name, content, restart) { 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'; - } + return await ApiClient.saveConfig(name, content, restart); } catch (error) { - this.elements.outputs.validateResult.textContent = `✗ Error: ${error.message}`; - this.elements.outputs.validateResult.className = 'validation-error'; + throw new Error(`Save failed: ${error.message}`); + } + } + + /** + * Get validation state class + * @static + * @param {Symbol} state - Validation state + * @returns {string} CSS class + */ + static getValidationClass(state) { + switch (state) { + case ValidationState.SUCCESS: return 'validation-success'; + case ValidationState.ERROR: return 'validation-error'; + case ValidationState.PENDING: return 'validation-pending'; + default: return ''; + } + } + + /** + * Get validation message + * @static + * @param {Symbol} state - Validation state + * @param {string} [message] - Additional message + * @returns {string} Validation message + */ + static getValidationMessage(state, message = '') { + switch (state) { + case ValidationState.SUCCESS: return '✓ Configuration is valid'; + case ValidationState.ERROR: return `✗ ${message || 'Validation failed'}`; + case ValidationState.PENDING: return 'Validating...'; + default: return ''; } } } -export { ConfigManager }; \ No newline at end of file +export { ConfigManager }; diff --git a/static/enums.js b/static/enums.js new file mode 100644 index 0000000..e494e87 --- /dev/null +++ b/static/enums.js @@ -0,0 +1,67 @@ +/* jshint esversion: 2024, module: true */ + +/** + * Application state enumerations + * @module Enums + */ + +// Editor modes +const EditorMode = { + RAW: Symbol("raw"), + STRUCTURED: Symbol("structured") +}; +Object.freeze(EditorMode); + +// Panel types +const PanelType = { + STATUS: Symbol("status"), + CONFIGS: Symbol("configs"), + LOGS: Symbol("logs"), + COMMANDS: Symbol("commands") +}; +Object.freeze(PanelType); + +// Interface states +const InterfaceState = { + UP: Symbol("up"), + DOWN: Symbol("down"), + UNKNOWN: Symbol("unknown") +}; +Object.freeze(InterfaceState); + +// Theme modes +const ThemeMode = { + LIGHT: Symbol("light"), + DARK: Symbol("dark") +}; +Object.freeze(ThemeMode); + +// Validation states +const ValidationState = { + PENDING: Symbol("pending"), + SUCCESS: Symbol("success"), + ERROR: Symbol("error") +}; +Object.freeze(ValidationState); + +// API endpoints +const ApiEndpoints = { + STATUS: '/api/status', + CONFIGS: '/api/configs', + CONFIG: '/api/config', + VALIDATE: '/api/validate', + SAVE: '/api/save', + LOGS: '/api/logs', + RELOAD: '/api/reload', + REBOOT: '/api/reboot' +}; +Object.freeze(ApiEndpoints); + +export { + EditorMode, + PanelType, + InterfaceState, + ThemeMode, + ValidationState, + ApiEndpoints +}; diff --git a/static/index.html b/static/index.html index 5bea6f8..752509c 100644 --- a/static/index.html +++ b/static/index.html @@ -11,7 +11,7 @@
-

Network Classroom

+

Network Configuration

Educational interface for learning network configuration with systemd-networkd

- `).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); - } - }); - }); + const tabsHTML = interfaces.map(iface => { + const state = Utils.getInterfaceState(iface); + const stateClass = Utils.getStateClass(state); + const stateText = Utils.getStateText(iface); + const isActive = iface === currentInterface; + + return ` + + `; + }).join(''); + + return `
${tabsHTML}
`; } /** - * Show detailed interface information with abbreviations - * @method showInterfaceDetails + * Show detailed interface information + * @static * @param {Object} iface - Interface object + * @returns {string} HTML string */ - 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; + static showInterfaceDetails(iface) { + const details = [ + InterfaceRenderer.renderDetailRow('Link File', iface.LinkFile), + InterfaceRenderer.renderDetailRow('Network File', iface.NetworkFile), + InterfaceRenderer.renderDetailRow('State', iface.State, Utils.getStateClass(Utils.getInterfaceState(iface))), + InterfaceRenderer.renderDetailRow('Online State', iface.OnlineState), + InterfaceRenderer.renderDetailRow('Type', iface.Type), + InterfaceRenderer.renderDetailRow('Path', iface.Path), + InterfaceRenderer.renderDetailRow('Driver', iface.Driver), + InterfaceRenderer.renderDetailRow('Vendor', iface.Vendor), + InterfaceRenderer.renderDetailRow('Model', iface.Model), + InterfaceRenderer.renderDetailRow('Hardware Address', Utils.arrayToMac(iface.HardwareAddress)), + InterfaceRenderer.renderDetailRow('MTU', iface.MTU ? `${iface.MTU} (min: ${iface.MTUMin ?? '?'}, max: ${iface.MTUMax ?? '?'})` : ''), + InterfaceRenderer.renderDetailRow('QDisc', iface.QDisc), + InterfaceRenderer.renderDetailRow('IPv6 Address Generation Mode', iface.IPv6AddressGenerationMode), + InterfaceRenderer.renderDetailRow('Number of Queues (Tx/Rx)', iface.Queues ? `${iface.Queues.Tx ?? '?'}/${iface.Queues.Rx ?? '?'}` : ''), + InterfaceRenderer.renderDetailRow('Auto negotiation', iface.AutoNegotiation ? 'yes' : 'no'), + InterfaceRenderer.renderDetailRow('Speed', iface.Speed), + InterfaceRenderer.renderDetailRow('Duplex', iface.Duplex), + InterfaceRenderer.renderDetailRow('Port', iface.Port), + InterfaceRenderer.renderDetailRow('Address', InterfaceRenderer.renderAddressList(iface.Addresses)), + InterfaceRenderer.renderDetailRow('DNS', InterfaceRenderer.renderDNSServerList(iface.DNS)), + InterfaceRenderer.renderDetailRow('NTP', iface.NTP), + InterfaceRenderer.renderDetailRow('Activation Policy', iface.ActivationPolicy), + InterfaceRenderer.renderDetailRow('Required For Online', iface.RequiredForOnline ? 'yes' : 'no'), + InterfaceRenderer.renderDetailRow('Connected To', iface.ConnectedTo), + InterfaceRenderer.renderDetailRow('Offered DHCP leases', InterfaceRenderer.renderDHCPLeases(iface.DHCPLeases)) + ].filter(Boolean).join(''); + + return `
${details}
`; } /** * Render a detail row with abbreviations - * @method renderDetailRow + * @static * @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 = '') { + static renderDetailRow(label, value, valueClass = '') { if (!value) return ''; - // Add abbreviations for common networking terms const abbreviations = { 'MTU': 'Maximum Transmission Unit', 'QDisc': 'Queueing Discipline', @@ -126,125 +107,56 @@ class InterfaceRenderer { return `
${abbrLabel}: - ${value} + ${Utils.sanitizeHTML(value)}
`; } /** * Render address list - * @method renderAddressList + * @static * @param {Array} addresses - Array of addresses * @returns {string} Formatted addresses */ - renderAddressList(addresses) { + static renderAddressList(addresses) { if (!addresses?.length) return ''; return addresses.map(addr => { - const ip = this.ipFromArray(addr); - return ip ? `
${ip}
` : ''; + const ip = Utils.ipFromArray(addr); + return ip ? `
${Utils.sanitizeHTML(ip)}
` : ''; }).join(''); } /** * Render DNS server list - * @method renderDNSServerList + * @static * @param {Array} dnsServers - Array of DNS servers * @returns {string} Formatted DNS servers */ - renderDNSServerList(dnsServers) { + static renderDNSServerList(dnsServers) { if (!dnsServers?.length) return ''; return dnsServers.map(dns => { - const server = this.ipFromArray(dns.Address ?? dns); - return server ? `
${server}
` : ''; + const server = Utils.ipFromArray(dns.Address ?? dns); + return server ? `
${Utils.sanitizeHTML(server)}
` : ''; }).join(''); } /** * Render DHCP leases - * @method renderDHCPLeases + * @static * @param {Array} leases - Array of DHCP leases * @returns {string} Formatted leases */ - renderDHCPLeases(leases) { + static 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})
`; + return `
${Utils.sanitizeHTML(ip)} (to ${Utils.sanitizeHTML(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/utils.js b/static/utils.js new file mode 100644 index 0000000..2b0b816 --- /dev/null +++ b/static/utils.js @@ -0,0 +1,155 @@ +/* jshint esversion: 2024, module: true */ + +import { InterfaceState } from './enums.js'; + +/** + * Static utility functions + * @module Utils + */ +class Utils { + /** + * Convert byte array to MAC address + * @static + * @param {Array} bytes - Byte array + * @returns {string} MAC address + */ + static arrayToMac(bytes) { + if (!Array.isArray(bytes)) return ''; + return bytes.map(byte => byte.toString(16).padStart(2, '0')).join(':'); + } + + /** + * Convert byte array to IP address + * @static + * @param {Array|Object} obj - IP data + * @returns {string} IP address + */ + static 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 + * @static + * @param {Object} route - Route object + * @returns {string} Route string + */ + static routeToString(route) { + if (!route) return ''; + const destination = route.Destination ? Utils.ipFromArray(route.Destination) : 'default'; + const gateway = route.Gateway ? Utils.ipFromArray(route.Gateway) : ''; + return gateway ? `${destination} → ${gateway}` : destination; + } + + /** + * Get interface state from interface object + * @static + * @param {Object} iface - Interface object + * @returns {Symbol} Interface state + */ + static getInterfaceState(iface) { + const state = iface.OperationalState ?? iface.AdministrativeState ?? iface.State ?? ''; + const stateLower = state.toLowerCase(); + + if (stateLower.includes('up') || stateLower.includes('routable') || stateLower.includes('configured')) { + return InterfaceState.UP; + } else if (stateLower.includes('down') || stateLower.includes('off')) { + return InterfaceState.DOWN; + } else { + return InterfaceState.UNKNOWN; + } + } + + /** + * Get CSS class for interface state + * @static + * @param {Symbol} state - Interface state + * @returns {string} CSS class + */ + static getStateClass(state) { + switch (state) { + case InterfaceState.UP: return 'state-up'; + case InterfaceState.DOWN: return 'state-down'; + default: return 'state-unknown'; + } + } + + /** + * Get display text for interface state + * @static + * @param {Object} iface - Interface object + * @returns {string} State text + */ + static getStateText(iface) { + return iface.OperationalState ?? iface.AdministrativeState ?? iface.State ?? 'unknown'; + } + + /** + * Sanitize HTML string + * @static + * @param {string} str - String to sanitize + * @returns {string} Sanitized string + */ + static sanitizeHTML(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + /** + * Create DOM element from HTML string + * @static + * @param {string} html - HTML string + * @returns {HTMLElement} DOM element + */ + static createElementFromHTML(html) { + const template = document.createElement('template'); + template.innerHTML = html.trim(); + return template.content.firstElementChild; + } + + /** + * Debounce function + * @static + * @param {Function} func - Function to debounce + * @param {number} wait - Wait time in ms + * @returns {Function} Debounced function + */ + static debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } +} + +export { Utils }; \ No newline at end of file -- cgit v1.2.3-70-g09d2