diff options
| -rw-r--r-- | static/api-client.js | 280 | ||||
| -rw-r--r-- | static/app.js | 1185 | ||||
| -rw-r--r-- | static/config-manager.js | 180 | ||||
| -rw-r--r-- | static/enums.js | 56 | ||||
| -rw-r--r-- | static/interface-renderer.js | 333 | ||||
| -rw-r--r-- | static/network-types.js | 180 | ||||
| -rw-r--r-- | static/structured-editor.js | 4 | ||||
| -rw-r--r-- | static/styles.css | 1026 | ||||
| -rw-r--r-- | static/systemd-network.js | 1301 | ||||
| -rw-r--r-- | static/theme-manager.js | 218 | ||||
| -rw-r--r-- | static/utils.js | 308 |
11 files changed, 2987 insertions, 2084 deletions
diff --git a/static/api-client.js b/static/api-client.js index 7a6ffbd..9582dc0 100644 --- a/static/api-client.js +++ b/static/api-client.js @@ -1,150 +1,152 @@ /* jshint esversion: 2024, module: true */ -import { ApiEndpoints } from './enums.js'; +import { ApiEndpoints } from "./enums.js"; /** * Static API Client for network operations * @class ApiClient */ class ApiClient { - /** - * API utility function - * @static - * @param {string} path - API endpoint - * @param {Object} [options] - Fetch options - * @returns {Promise<Response>} - */ - static 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 - * @static - * @param {string} path - API endpoint - * @returns {Promise<Object>} - */ - static async get(path) { - const response = await ApiClient.request(path); - return response.json(); - } - - /** - * GET request returning text - * @static - * @param {string} path - API endpoint - * @returns {Promise<string>} - */ - static async getText(path) { - const response = await ApiClient.request(path); - return response.text(); - } - - /** - * POST request with JSON body - * @static - * @param {string} path - API endpoint - * @param {Object} [data] - Request body - * @returns {Promise<Object>} - */ - static async post(path, data = null) { - const options = { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }; - - if (data) { - options.body = JSON.stringify(data); - } - - const response = await ApiClient.request(path, options); - return response.json(); - } - - /** - * Get network status - * @static - * @returns {Promise<Object>} - */ - static async getNetworkStatus() { - return ApiClient.get(ApiEndpoints.STATUS); - } - - /** - * Get configuration files list - * @static - * @returns {Promise<Object>} - */ - static async getConfigFiles() { - return ApiClient.get(ApiEndpoints.CONFIGS); - } - - /** - * Get configuration file content - * @static - * @param {string} name - File name - * @returns {Promise<string>} - */ - 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<Object>} - */ - 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<Object>} - */ - static async saveConfig(name, content, restart) { - return ApiClient.post(ApiEndpoints.SAVE, { name, content, restart }); - } - - /** - * Get system logs - * @static - * @returns {Promise<string>} - */ - static async getSystemLogs() { - return ApiClient.getText(ApiEndpoints.LOGS); - } - - /** - * Restart networkd service - * @static - * @returns {Promise<Object>} - */ - static async restartNetworkd() { - return ApiClient.post(ApiEndpoints.RELOAD); - } - - /** - * Reboot device - * @static - * @returns {Promise<Object>} - */ - static async rebootDevice() { - return ApiClient.post(ApiEndpoints.REBOOT); - } + /** + * API utility function + * @static + * @param {string} path - API endpoint + * @param {Object} [options] - Fetch options + * @returns {Promise<Response>} + */ + static 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 + * @static + * @param {string} path - API endpoint + * @returns {Promise<Object>} + */ + static async get(path) { + const response = await ApiClient.request(path); + return response.json(); + } + + /** + * GET request returning text + * @static + * @param {string} path - API endpoint + * @returns {Promise<string>} + */ + static async getText(path) { + const response = await ApiClient.request(path); + return response.text(); + } + + /** + * POST request with JSON body + * @static + * @param {string} path - API endpoint + * @param {Object} [data] - Request body + * @returns {Promise<Object>} + */ + static async post(path, data = null) { + const options = { + method: "POST", + headers: { "Content-Type": "application/json" }, + }; + + if (data) { + options.body = JSON.stringify(data); + } + + const response = await ApiClient.request(path, options); + return response.json(); + } + + /** + * Get network status + * @static + * @returns {Promise<Object>} + */ + static async getNetworkStatus() { + return ApiClient.get(ApiEndpoints.STATUS); + } + + /** + * Get configuration files list + * @static + * @returns {Promise<Object>} + */ + static async getConfigFiles() { + return ApiClient.get(ApiEndpoints.CONFIGS); + } + + /** + * Get configuration file content + * @static + * @param {string} name - File name + * @returns {Promise<string>} + */ + 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<Object>} + */ + 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<Object>} + */ + static async saveConfig(name, content, restart) { + return ApiClient.post(ApiEndpoints.SAVE, { name, content, restart }); + } + + /** + * Get system logs + * @static + * @returns {Promise<string>} + */ + static async getSystemLogs() { + return ApiClient.getText(ApiEndpoints.LOGS); + } + + /** + * Restart networkd service + * @static + * @returns {Promise<Object>} + */ + static async restartNetworkd() { + return ApiClient.post(ApiEndpoints.RELOAD); + } + + /** + * Reboot device + * @static + * @returns {Promise<Object>} + */ + static async rebootDevice() { + return ApiClient.post(ApiEndpoints.REBOOT); + } } export { ApiClient }; diff --git a/static/app.js b/static/app.js index 22e52b5..3fdd32e 100644 --- a/static/app.js +++ b/static/app.js @@ -1,61 +1,65 @@ /* jshint esversion: 2024, module: true */ -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'; +import { ApiClient } from "./api-client.js"; +import { ConfigManager } from "./config-manager.js"; +import { EditorMode, ValidationState } from "./enums.js"; +import { InterfaceRenderer } from "./interface-renderer.js"; +import { StructuredEditor } from "./structured-editor.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: EditorMode.STRUCTURED, - currentConfigFile: null, - theme: ThemeMode.DARK - }; - - // Initialize modules - this.themeManager = new ThemeManager(elements); - this.structuredEditor = null; - - // Create editor mode toggle UI - this.createEditorModeToggle(); - } - - /** - * Initialize the application - * @method init - */ - init() { - this.themeManager.init(); - this.setupEventListeners(); - this.loadStatus(); - this.initializeStructuredEditor(); - } - - /** - * Create editor mode toggle UI - * @method createEditorModeToggle - */ - createEditorModeToggle() { - const editorToggleHTML = ` + #elements; + #state; + #themeManager; + #structuredEditor; + + /** + * @param {Object} elements - DOM elements + */ + constructor(elements) { + this.#elements = elements; + this.#state = { + currentInterface: null, + interfaces: [], + editorMode: EditorMode.RAW, + currentConfigFile: null, + }; + + // Initialize modules + this.#themeManager = new ThemeManager(elements); + this.#structuredEditor = null; + + // Create editor mode toggle UI + this.#createEditorModeToggle(); + } + + /** + * Initialize the application + * @method init + */ + init() { + this.#themeManager.init(); + this.#setupEventListeners(); + this.#loadStatus(); + this.#initializeStructuredEditor(); + } + + /** + * Create editor mode toggle UI + * @private + */ + #createEditorModeToggle() { + const editorToggleHTML = ` <div class="editor-mode-toggle" style="margin-bottom: var(--spacing-l);"> - <button class="button small ${this.state.editorMode === EditorMode.RAW ? 'active' : ''}" + <button class="button small ${this.#state.editorMode === EditorMode.RAW ? "active" : ""}" data-mode="raw" id="rawEditorBtn"> 📝 Raw Editor </button> - <button class="button small ${this.state.editorMode === EditorMode.STRUCTURED ? 'active' : ''}" + <button class="button small ${this.#state.editorMode === EditorMode.STRUCTURED ? "active" : ""}" data-mode="structured" id="structuredEditorBtn"> 🏗️ Structured Editor </button> @@ -68,512 +72,595 @@ class Application { </div> `; - // Insert the toggle and containers into the configs panel - const configCard = this.elements.panels.configs.querySelector('.card:last-child'); - configCard.insertAdjacentHTML('afterbegin', editorToggleHTML); - - // Move existing form elements to raw editor container - const rawEditorContainer = document.getElementById('rawEditorContainer'); - const formGroups = Array.from(configCard.querySelectorAll('.form-group, .checkbox-group')); - const configActions = configCard.querySelector('.config-actions') || - configCard.querySelector('div:has(> #validateConfig)'); - - formGroups.forEach(group => { - if (!group.closest('.editor-mode-toggle')) { - rawEditorContainer.appendChild(group); - } - }); - - // Move config actions if they exist - if (configActions) { - rawEditorContainer.appendChild(configActions); - } - - // Update elements reference - this.elements.editorContainers = { - raw: document.getElementById('rawEditorContainer'), - structured: document.getElementById('structuredEditorContainer') - }; - - this.elements.editorButtons = { - raw: document.getElementById('rawEditorBtn'), - structured: document.getElementById('structuredEditorBtn') - }; - } - - /** - * Initialize structured editor - * @method initializeStructuredEditor - */ - initializeStructuredEditor() { - if (this.elements.editorContainers.structured) { - this.structuredEditor = new StructuredEditor(this.elements.editorContainers.structured); - } else { - console.error('Structured editor container not found'); - } - } - - /** - * 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 - raw editor - this.elements.buttons.refreshConfigs?.addEventListener('click', - () => this.handleRefreshConfigs()); - this.elements.buttons.saveConfig?.addEventListener('click', - () => this.handleSaveConfig()); - this.elements.buttons.validateConfig?.addEventListener('click', - () => this.handleValidateConfig()); - this.elements.inputs.configSelect?.addEventListener('change', - () => this.handleConfigFileChange()); - - // Editor mode toggle - this.elements.editorButtons?.raw?.addEventListener('click', - () => this.setEditorMode(EditorMode.RAW)); - this.elements.editorButtons?.structured?.addEventListener('click', - () => this.setEditorMode(EditorMode.STRUCTURED)); - - // 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); - } - }; - - /** - * 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); - } - } - - /** - * 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); - }); - } - - /** - * Handle configuration file change - * @method handleConfigFileChange - */ - async handleConfigFileChange() { - const name = this.elements.inputs.configSelect.value; - if (!name) return; - - this.state.currentConfigFile = name; - - 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'); - return; - } - - try { - const content = await ConfigManager.loadConfig(this.state.currentConfigFile); - - // Parse configuration and get schema - const { NetworkConfiguration } = await import('./systemd-network.js'); - const config = NetworkConfiguration.fromSystemdConfiguration(content); - const schema = config.getSchema(); - - // Load schema into structured editor - this.structuredEditor.loadSchema(schema, this.state.currentConfigFile); - - // Set up event listeners for structured editor - this.structuredEditor.on('addSection', (event) => this.handleAddSection(event.detail)); - this.structuredEditor.on('removeSection', (event) => this.handleRemoveSection(event.detail)); - - } catch (error) { - alert(`Failed to load config for structured editor: ${error.message}`); - } - } - - /** - * 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 {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 === 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 === 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 === EditorMode.STRUCTURED && this.state.currentConfigFile && this.structuredEditor) { - this.loadConfigForStructuredEditor(); - } - } - - /** - * Handle save configuration based on current editor mode - * @method handleSaveConfig - */ - async handleSaveConfig() { - const name = this.state.currentConfigFile; - if (!name) { - alert('Please select a configuration file first.'); - return; - } - - 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 { - let content; - if (this.state.editorMode === EditorMode.RAW) { - content = this.elements.inputs.cfgEditor.value; - } else if (this.structuredEditor) { - content = this.structuredEditor.getConfigurationText(); - } else { - throw new Error('Structured editor not available'); - } - - 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 === EditorMode.STRUCTURED && this.structuredEditor) { - await this.loadConfigForStructuredEditor(); - } - } catch (error) { - alert(`Save failed: ${error.message}`); - } - } - - /** - * 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 => { - if (p) p.classList.remove('active'); - }); - this.elements.buttons.nav.forEach(btn => { - if (btn) btn.classList.remove('active'); - }); - - // Show selected panel and activate button - const targetPanel = this.elements.panels[panel]; - const targetButton = document.querySelector(`[data-panel="${panel}"]`); - - if (targetPanel) targetPanel.classList.add('active'); - if (targetButton) targetButton.classList.add('active'); - - // Load panel-specific data - const panelActions = { - status: () => this.loadStatus(), - configs: () => this.handleRefreshConfigs(), - logs: () => this.loadLogs(), - }; - - panelActions[panel]?.(); - } - - /** - * Load and display network status - * @method loadStatus - */ - async loadStatus() { - try { - const data = await ApiClient.getNetworkStatus(); - this.state.interfaces = data.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.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 = - `<div class="error-message">Error loading status: ${error.message}</div>`; - } - } - - /** - * 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 ApiClient.getSystemLogs(); - 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 ApiClient.restartNetworkd(); - 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 ApiClient.rebootDevice(); - this.elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; - } catch (error) { - this.elements.outputs.cmdResult.textContent = `Error: ${error.message}`; - } - } + // Insert the toggle and containers into the configs panel + const configCard = + this.#elements.panels.configs.querySelector(".card:last-child"); + configCard.insertAdjacentHTML("afterbegin", editorToggleHTML); + + // Move existing form elements to raw editor container + const rawEditorContainer = document.getElementById("rawEditorContainer"); + const formGroups = Array.from( + configCard.querySelectorAll(".form-group, .checkbox-group"), + ); + const configActions = + configCard.querySelector(".config-actions") || + configCard.querySelector("div:has(> #validateConfig)"); + + formGroups.forEach((group) => { + if (!group.closest(".editor-mode-toggle")) { + rawEditorContainer.appendChild(group); + } + }); + + // Move config actions if they exist + if (configActions) { + rawEditorContainer.appendChild(configActions); + } + + // Update elements reference + this.#elements.editorContainers = { + raw: document.getElementById("rawEditorContainer"), + structured: document.getElementById("structuredEditorContainer"), + }; + + this.#elements.editorButtons = { + raw: document.getElementById("rawEditorBtn"), + structured: document.getElementById("structuredEditorBtn"), + }; + } + + /** + * Initialize structured editor + * @private + */ + #initializeStructuredEditor() { + if (this.#elements.editorContainers.structured) { + this.#structuredEditor = new StructuredEditor( + this.#elements.editorContainers.structured, + ); + } else { + console.error("Structured editor container not found"); + } + } + + /** + * Set up all event listeners + * @private + */ + #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 - raw editor + this.#elements.buttons.refreshConfigs?.addEventListener("click", () => + this.#handleRefreshConfigs(), + ); + this.#elements.buttons.saveConfig?.addEventListener("click", () => + this.#handleSaveConfig(), + ); + this.#elements.buttons.validateConfig?.addEventListener("click", () => + this.#handleValidateConfig(), + ); + this.#elements.inputs.configSelect?.addEventListener("change", () => + this.#handleConfigFileChange(), + ); + + // Editor mode toggle + this.#elements.editorButtons?.raw?.addEventListener("click", () => + this.#setEditorMode(EditorMode.RAW), + ); + this.#elements.editorButtons?.structured?.addEventListener("click", () => + this.#setEditorMode(EditorMode.STRUCTURED), + ); + + // 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 + * @private + * @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 + * @private + */ + 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); + } + } + + /** + * Update configuration select element + * @private + * @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); + }); + } + + /** + * Handle configuration file change + * @private + */ + async #handleConfigFileChange() { + const name = this.#elements.inputs.configSelect.value; + if (!name) return; + + this.#state.currentConfigFile = name; + + if (this.#state.editorMode === EditorMode.RAW) { + await this.#loadConfigForRawEditor(); + } else { + await this.#loadConfigForStructuredEditor(); + } + } + + /** + * Load config for raw editor + * @private + */ + 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 + * @private + */ + async #loadConfigForStructuredEditor() { + if (!this.#structuredEditor) { + console.error("Structured editor not initialized"); + return; + } + + try { + const content = await ConfigManager.loadConfig( + this.#state.currentConfigFile, + ); + + // Parse configuration and get schema + const { NetworkConfiguration } = await import("./systemd-network.js"); + const config = NetworkConfiguration.fromSystemdConfiguration(content); + const schema = config.getSchema(); + + // Load schema into structured editor + this.#structuredEditor.loadSchema(schema, this.#state.currentConfigFile); + + // Set up event listeners for structured editor + this.#structuredEditor.on("addSection", (event) => + this.#handleAddSection(event.detail), + ); + this.#structuredEditor.on("removeSection", (event) => + this.#handleRemoveSection(event.detail), + ); + } catch (error) { + alert(`Failed to load config for structured editor: ${error.message}`); + } + } + + /** + * Handle add section from structured editor + * @private + * @param {Object} detail - Event detail + */ + #handleAddSection(detail) { + console.log("Add section:", detail); + // TODO: Implement section addition logic + } + + /** + * Handle remove section from structured editor + * @private + * @param {Object} detail - Event detail + */ + #handleRemoveSection(detail) { + console.log("Remove section:", detail); + // TODO: Implement section removal logic + } + + /** + * Handle validate configuration + * @private + */ + 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 + * @private + * @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 + * @private + */ + #clearValidationResult() { + this.#elements.outputs.validateResult.textContent = ""; + this.#elements.outputs.validateResult.className = ""; + } + + /** + * Set editor mode (raw or structured) + * @private + * @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 === 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 === 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 === EditorMode.STRUCTURED && + this.#state.currentConfigFile && + this.#structuredEditor + ) { + this.#loadConfigForStructuredEditor(); + } + } + + /** + * Handle save configuration based on current editor mode + * @private + */ + async #handleSaveConfig() { + const name = this.#state.currentConfigFile; + if (!name) { + alert("Please select a configuration file first."); + return; + } + + 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 { + let content; + if (this.#state.editorMode === EditorMode.RAW) { + content = this.#elements.inputs.cfgEditor.value; + } else if (this.#structuredEditor) { + content = this.#structuredEditor.getConfigurationText(); + } else { + throw new Error("Structured editor not available"); + } + + 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 === EditorMode.STRUCTURED && + this.#structuredEditor + ) { + await this.#loadConfigForStructuredEditor(); + } + } catch (error) { + alert(`Save failed: ${error.message}`); + } + } + + /** + * Load and display network status + * @private + */ + async #loadStatus() { + try { + const data = await ApiClient.getNetworkStatus(); + this.#state.interfaces = data.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.#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 = `<div class="error-message">Error loading status: ${error.message}</div>`; + } + } + + /** + * Show interface details + * @private + * @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 + * @private + */ + async #loadLogs() { + try { + const text = await ApiClient.getSystemLogs(); + this.#elements.outputs.logsArea.textContent = text; + } catch (error) { + this.#elements.outputs.logsArea.textContent = `Error: ${error.message}`; + } + } + + /** + * Restart networkd service + * @private + */ + async #restartNetworkd() { + if (!confirm("Restart systemd-networkd? Active connections may be reset.")) + return; + + try { + const result = await ApiClient.restartNetworkd(); + this.#elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; + } catch (error) { + this.#elements.outputs.cmdResult.textContent = `Error: ${error.message}`; + } + } + + /** + * Reboot the device + * @private + */ + async #rebootDevice() { + if (!confirm("Reboot device now?")) return; + + try { + const result = await ApiClient.rebootDevice(); + this.#elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; + } catch (error) { + this.#elements.outputs.cmdResult.textContent = `Error: ${error.message}`; + } + } + + /** + * 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) => { + if (p) p.classList.remove("active"); + }); + this.#elements.buttons.nav.forEach((btn) => { + if (btn) btn.classList.remove("active"); + }); + + // Show selected panel and activate button + const targetPanel = this.#elements.panels[panel]; + const targetButton = document.querySelector(`[data-panel="${panel}"]`); + + if (targetPanel) targetPanel.classList.add("active"); + if (targetButton) targetButton.classList.add("active"); + + // Load panel-specific data + const panelActions = { + status: () => this.#loadStatus(), + configs: () => this.#handleRefreshConfigs(), + logs: () => this.#loadLogs(), + }; + + panelActions[panel]?.(); + } + + /** + * Get current application state (for debugging) + * @method getState + * @returns {Object} Application state + */ + getState() { + return { ...this.#state }; + } + + /** + * Get theme manager instance + * @method getThemeManager + * @returns {ThemeManager} Theme manager instance + */ + getThemeManager() { + return this.#themeManager; + } } // 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; +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; }); export { Application }; diff --git a/static/config-manager.js b/static/config-manager.js index edb46b2..eea6c91 100644 --- a/static/config-manager.js +++ b/static/config-manager.js @@ -1,102 +1,110 @@ /* jshint esversion: 2024, module: true */ -import { ApiClient } from './api-client.js'; -import { ValidationState } from './enums.js'; +import { ApiClient } from "./api-client.js"; +import { ValidationState } from "./enums.js"; /** * Static Configuration Manager for handling config files * @class ConfigManager */ class ConfigManager { - /** - * Refresh configuration file list - * @static - * @returns {Promise<Array>} Array of file names - */ - 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 + * @static + * @returns {Promise<Array>} Array of file names + */ + static async refreshConfigs() { + try { + const data = await ApiClient.getConfigFiles(); + return data.files || []; + } catch (error) { + throw new Error(`Failed to list configs: ${error.message}`); + } + } - /** - * Load selected configuration file - * @static - * @param {string} name - File name - * @returns {Promise<string>} File content - */ - static async loadConfig(name) { - try { - return await ApiClient.getConfigFile(name); - } catch (error) { - throw new Error(`Failed to load: ${error.message}`); - } - } + /** + * Load selected configuration file + * @static + * @param {string} name - File name + * @returns {Promise<string>} File content + */ + static async loadConfig(name) { + try { + return await ApiClient.getConfigFile(name); + } catch (error) { + throw new Error(`Failed to load: ${error.message}`); + } + } - /** - * Validate current configuration - * @static - * @param {string} name - File name - * @param {string} content - File content - * @returns {Promise<Object>} Validation result - */ - static async validateConfig(name, content) { - try { - return await ApiClient.validateConfig(name, content); - } catch (error) { - throw new Error(`Validation failed: ${error.message}`); - } - } + /** + * Validate current configuration + * @static + * @param {string} name - File name + * @param {string} content - File content + * @returns {Promise<Object>} Validation result + */ + static async validateConfig(name, content) { + try { + return await ApiClient.validateConfig(name, content); + } catch (error) { + throw new Error(`Validation failed: ${error.message}`); + } + } - /** - * Save current configuration - * @static - * @param {string} name - File name - * @param {string} content - File content - * @param {boolean} restart - Restart service - * @returns {Promise<Object>} Save result - */ - static async saveConfig(name, content, restart) { - try { - return await ApiClient.saveConfig(name, content, restart); - } catch (error) { - throw new Error(`Save failed: ${error.message}`); - } - } + /** + * Save current configuration + * @static + * @param {string} name - File name + * @param {string} content - File content + * @param {boolean} restart - Restart service + * @returns {Promise<Object>} Save result + */ + static async saveConfig(name, content, restart) { + try { + return await ApiClient.saveConfig(name, content, restart); + } catch (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 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 ''; - } - } + /** + * 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 }; diff --git a/static/enums.js b/static/enums.js index e494e87..c36011c 100644 --- a/static/enums.js +++ b/static/enums.js @@ -7,61 +7,61 @@ // Editor modes const EditorMode = { - RAW: Symbol("raw"), - STRUCTURED: Symbol("structured") + 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") + 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") + UP: Symbol("up"), + DOWN: Symbol("down"), + UNKNOWN: Symbol("unknown"), }; Object.freeze(InterfaceState); // Theme modes const ThemeMode = { - LIGHT: Symbol("light"), - DARK: Symbol("dark") + LIGHT: Symbol("light"), + DARK: Symbol("dark"), }; Object.freeze(ThemeMode); // Validation states const ValidationState = { - PENDING: Symbol("pending"), - SUCCESS: Symbol("success"), - ERROR: Symbol("error") + 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' + 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 + EditorMode, + PanelType, + InterfaceState, + ThemeMode, + ValidationState, + ApiEndpoints, }; diff --git a/static/interface-renderer.js b/static/interface-renderer.js index 8c01696..5145cf3 100644 --- a/static/interface-renderer.js +++ b/static/interface-renderer.js @@ -1,162 +1,213 @@ /* jshint esversion: 2024, module: true */ -import { Utils } from './utils.js'; -import { InterfaceState } from './enums.js'; +import { Utils } from "./utils.js"; /** * Static Interface Renderer for displaying network interfaces * @class InterfaceRenderer */ class InterfaceRenderer { - /** - * Render interface tabs - * @static - * @param {Array} interfaces - Array of interface objects - * @param {Object} currentInterface - Current interface object - * @returns {string} HTML string - */ - static renderInterfaceTabs(interfaces, currentInterface) { - if (!interfaces.length) { - return '<div class="no-interfaces">No network interfaces found</div>'; - } - - 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 ` - <button class="interface-tab ${isActive ? 'active' : ''}" + /** + * Render interface tabs + * @static + * @param {Array} interfaces - Array of interface objects + * @param {Object} currentInterface - Current interface object + * @returns {string} HTML string + */ + static renderInterfaceTabs(interfaces, currentInterface) { + if (!interfaces.length) { + return '<div class="no-interfaces">No network interfaces found</div>'; + } + + 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 ` + <button class="interface-tab ${isActive ? "active" : ""}" data-interface="${Utils.sanitizeHTML(iface.Name)}"> ${Utils.sanitizeHTML(iface.Name)} <span class="interface-state ${stateClass}">${Utils.sanitizeHTML(stateText)}</span> </button> `; - }).join(''); - - return `<div class="interface-tabs-container">${tabsHTML}</div>`; - } - - /** - * Show detailed interface information - * @static - * @param {Object} iface - Interface object - * @returns {string} HTML string - */ - 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 `<div class="interface-detail-grid">${details}</div>`; - } - - /** - * Render a detail row with abbreviations - * @static - * @param {string} label - Row label - * @param {string} value - Row value - * @param {string} [valueClass] - CSS class for value - * @returns {string} HTML string - */ - static renderDetailRow(label, value, valueClass = '') { - if (!value) return ''; - - 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 ` + }) + .join(""); + + return `<div class="interface-tabs-container">${tabsHTML}</div>`; + } + + /** + * Show detailed interface information + * @static + * @param {Object} iface - Interface object + * @returns {string} HTML string + */ + 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 `<div class="interface-detail-grid">${details}</div>`; + } + + /** + * Render a detail row with abbreviations + * @static + * @param {string} label - Row label + * @param {string} value - Row value + * @param {string} [valueClass] - CSS class for value + * @returns {string} HTML string + */ + static renderDetailRow(label, value, valueClass = "") { + if (!value) return ""; + + 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}">${Utils.sanitizeHTML(value)}</span> </div> `; - } - - /** - * Render address list - * @static - * @param {Array} addresses - Array of addresses - * @returns {string} Formatted addresses - */ - static renderAddressList(addresses) { - if (!addresses?.length) return ''; - - return addresses.map(addr => { - const ip = Utils.ipFromArray(addr); - return ip ? `<div class="address-item">${Utils.sanitizeHTML(ip)}</div>` : ''; - }).join(''); - } - - /** - * Render DNS server list - * @static - * @param {Array} dnsServers - Array of DNS servers - * @returns {string} Formatted DNS servers - */ - static renderDNSServerList(dnsServers) { - if (!dnsServers?.length) return ''; - - return dnsServers.map(dns => { - const server = Utils.ipFromArray(dns.Address ?? dns); - return server ? `<div class="dns-item">${Utils.sanitizeHTML(server)}</div>` : ''; - }).join(''); - } - - /** - * Render DHCP leases - * @static - * @param {Array} leases - Array of DHCP leases - * @returns {string} Formatted leases - */ - static 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">${Utils.sanitizeHTML(ip)} (to ${Utils.sanitizeHTML(to)})</div>`; - }).join(''); - } + } + + /** + * Render address list + * @static + * @param {Array} addresses - Array of addresses + * @returns {string} Formatted addresses + */ + static renderAddressList(addresses) { + if (!addresses?.length) return ""; + + return addresses + .map((addr) => { + const ip = Utils.ipFromArray(addr); + return ip + ? `<div class="address-item">${Utils.sanitizeHTML(ip)}</div>` + : ""; + }) + .join(""); + } + + /** + * Render DNS server list + * @static + * @param {Array} dnsServers - Array of DNS servers + * @returns {string} Formatted DNS servers + */ + static renderDNSServerList(dnsServers) { + if (!dnsServers?.length) return ""; + + return dnsServers + .map((dns) => { + const server = Utils.ipFromArray(dns.Address ?? dns); + return server + ? `<div class="dns-item">${Utils.sanitizeHTML(server)}</div>` + : ""; + }) + .join(""); + } + + /** + * Render DHCP leases + * @static + * @param {Array} leases - Array of DHCP leases + * @returns {string} Formatted leases + */ + static 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">${Utils.sanitizeHTML(ip)} (to ${Utils.sanitizeHTML(to)})</div>`; + }) + .join(""); + } } export { InterfaceRenderer }; diff --git a/static/network-types.js b/static/network-types.js new file mode 100644 index 0000000..a37e7a6 --- /dev/null +++ b/static/network-types.js @@ -0,0 +1,180 @@ +/* jshint esversion: 2024, module: true */ + +/** + * Network configuration types and enumerations + * @module NetworkTypes + */ + +// Field types +const FieldType = { + STRING: Symbol("string"), + MAC_ADDRESS: Symbol("mac-address"), + IPV4_ADDRESS: Symbol("ipv4-address"), + IPV6_ADDRESS: Symbol("ipv6-address"), + IP_PREFIX: Symbol("ip-prefix"), + BOOLEAN: Symbol("boolean"), + NUMBER: Symbol("number"), + PORT: Symbol("port"), + MTU: Symbol("mtu"), + STRINGS: Symbol("strings"), + IP_ADDRESSES: Symbol("ip-addresses"), + DHCP_MODE: Symbol("dhcp-mode"), + IP_FORWARD: Symbol("ip-forward"), + PRIVACY_EXTENSIONS: Symbol("privacy-extensions"), + LLMNR: Symbol("llmnr"), + MDNS: Symbol("mdns"), + DNSSEC: Symbol("dnssec"), + USE_DOMAINS: Symbol("use-domains"), + CLIENT_IDENTIFIER: Symbol("client-identifier"), + ROUTE_SCOPE: Symbol("route-scope"), + ROUTE_TYPE: Symbol("route-type"), + SLAAC: Symbol("slaac"), +}; +Object.freeze(FieldType); + +// DHCP modes +const DHCPMode = { + YES: "yes", + NO: "no", + IPV4: "ipv4", + IPV6: "ipv6", +}; +Object.freeze(DHCPMode); + +// Boolean values +const BooleanYesNo = { + YES: "yes", + NO: "no", +}; +Object.freeze(BooleanYesNo); + +// IP forwarding options +const IPForward = { + YES: "yes", + NO: "no", + IPV4: "ipv4", + IPV6: "ipv6", +}; +Object.freeze(IPForward); + +// IPv6 privacy extensions +const IPv6PrivacyExtensions = { + YES: "yes", + NO: "no", + PREFER_PUBLIC: "prefer-public", +}; +Object.freeze(IPv6PrivacyExtensions); + +// LLMNR options +const LLMNROptions = { + YES: "yes", + NO: "no", + RESOLVE: "resolve", +}; +Object.freeze(LLMNROptions); + +// Multicast DNS options +const MulticastDNS = { + YES: "yes", + NO: "no", + RESOLVE: "resolve", +}; +Object.freeze(MulticastDNS); + +// DNSSEC options +const DNSSECOptions = { + YES: "yes", + NO: "no", + ALLOW_DOWNGRADE: "allow-downgrade", +}; +Object.freeze(DNSSECOptions); + +// Use domains options +const UseDomains = { + YES: "yes", + NO: "no", + ROUTE: "route", +}; +Object.freeze(UseDomains); + +// Client identifier options +const ClientIdentifier = { + MAC: "mac", + DUID: "duid", +}; +Object.freeze(ClientIdentifier); + +// Route scope options +const RouteScope = { + GLOBAL: "global", + LINK: "link", + HOST: "host", +}; +Object.freeze(RouteScope); + +// Route type options +const RouteType = { + UNICAST: "unicast", + LOCAL: "local", + BROADCAST: "broadcast", + ANYCAST: "anycast", + MULTICAST: "multicast", + BLACKHOLE: "blackhole", + UNREACHABLE: "unreachable", + PROHIBIT: "prohibit", +}; +Object.freeze(RouteType); + +// Wake-on-LAN options +const WakeOnLAN = { + PHY: "phy", + UNICAST: "unicast", + BROADCAST: "broadcast", + ARP: "arp", + MAGIC: "magic", +}; +Object.freeze(WakeOnLAN); + +// Port types +const PortType = { + TP: "tp", + AUI: "aui", + BNC: "bnc", + MII: "mii", + FIBRE: "fibre", +}; +Object.freeze(PortType); + +// Duplex modes +const DuplexMode = { + HALF: "half", + FULL: "full", +}; +Object.freeze(DuplexMode); + +// SLAAC options +const SLAACOptions = { + YES: "yes", + NO: "no", + PREFER_TEMPORARY: "prefer-temporary", +}; +Object.freeze(SLAACOptions); + +export { + FieldType, + DHCPMode, + BooleanYesNo, + IPForward, + IPv6PrivacyExtensions, + LLMNROptions, + MulticastDNS, + DNSSECOptions, + UseDomains, + ClientIdentifier, + RouteScope, + RouteType, + WakeOnLAN, + PortType, + DuplexMode, + SLAACOptions, +}; diff --git a/static/structured-editor.js b/static/structured-editor.js index c419024..e5bd45f 100644 --- a/static/structured-editor.js +++ b/static/structured-editor.js @@ -296,9 +296,7 @@ class StructuredEditor { this.schema[section].items ) { // Array section item - if ( - this.schema[section].items[index]?.[key] - ) { + if (this.schema[section].items[index]?.[key]) { this.schema[section].items[index][key].value = value || null; } } diff --git a/static/styles.css b/static/styles.css index ca6468f..0092e12 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1,915 +1,917 @@ :root { - /* Light theme (default) */ - --color-neutral-background: #f7f7f7; - --color-neutral-background-selected: #e6e6e6; - --color-neutral-foreground: #242424; - --color-neutral-foreground-subtle: #616161; - --color-surface: #ffffff; - --border-color: #e1e1e1; - --shadow-color: rgba(0,0,0,0.12); - - /* Brand colors remain the same */ - --color-brand-background: #0078d4; - --color-brand-background-hover: #106ebe; - --color-brand-foreground: #0078d4; - --color-brand-foreground-hover: #004578; - --color-status-success: #107c10; - --color-status-warning: #d83b01; - --color-status-error: #d13438; - --color-status-info: #0078d4; - - /* Shadows */ - --shadow-2: 0 0 2px var(--shadow-color), 0 2px 4px var(--shadow-color); - --shadow-4: 0 0 2px var(--shadow-color), 0 4px 8px var(--shadow-color); - --shadow-8: 0 0 2px var(--shadow-color), 0 8px 16px var(--shadow-color); - - /* Typography */ - --font-weight-regular: 400; - --font-weight-semibold: 600; - --font-weight-bold: 700; - - /* Spacing */ - --spacing-xs: 4px; - --spacing-s: 8px; - --spacing-m: 12px; - --spacing-l: 16px; - --spacing-xl: 20px; - --spacing-xxl: 24px; - - /* Border Radius */ - --border-radius-medium: 4px; - --border-radius-large: 8px; + /* Light theme (default) */ + --color-neutral-background: #f7f7f7; + --color-neutral-background-selected: #e6e6e6; + --color-neutral-foreground: #242424; + --color-neutral-foreground-subtle: #616161; + --color-surface: #ffffff; + --border-color: #e1e1e1; + --shadow-color: rgba(0, 0, 0, 0.12); + + /* Brand colors remain the same */ + --color-brand-background: #0078d4; + --color-brand-background-hover: #106ebe; + --color-brand-foreground: #0078d4; + --color-brand-foreground-hover: #004578; + --color-status-success: #107c10; + --color-status-warning: #d83b01; + --color-status-error: #d13438; + --color-status-info: #0078d4; + + /* Shadows */ + --shadow-2: 0 0 2px var(--shadow-color), 0 2px 4px var(--shadow-color); + --shadow-4: 0 0 2px var(--shadow-color), 0 4px 8px var(--shadow-color); + --shadow-8: 0 0 2px var(--shadow-color), 0 8px 16px var(--shadow-color); + + /* Typography */ + --font-weight-regular: 400; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-s: 8px; + --spacing-m: 12px; + --spacing-l: 16px; + --spacing-xl: 20px; + --spacing-xxl: 24px; + + /* Border Radius */ + --border-radius-medium: 4px; + --border-radius-large: 8px; } [data-theme="dark"] { - --color-neutral-background: #1f1f1f; - --color-neutral-background-selected: #2d2d2d; - --color-neutral-foreground: #ffffff; - --color-neutral-foreground-subtle: #a0a0a0; - --color-surface: #2d2d2d; - --border-color: #404040; - --shadow-color: rgba(0,0,0,0.3); + --color-neutral-background: #1f1f1f; + --color-neutral-background-selected: #2d2d2d; + --color-neutral-foreground: #ffffff; + --color-neutral-foreground-subtle: #a0a0a0; + --color-surface: #2d2d2d; + --border-color: #404040; + --shadow-color: rgba(0, 0, 0, 0.3); } /* Update all color references to use theme variables */ body { - font-family: "Segoe UI", system-ui, -apple-system, sans-serif; - margin: 0; - background-color: var(--color-neutral-background); - color: var(--color-neutral-foreground); - line-height: 1.4; - transition: background-color 0.3s ease, color 0.3s ease; + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; + margin: 0; + background-color: var(--color-neutral-background); + color: var(--color-neutral-foreground); + line-height: 1.4; + transition: + background-color 0.3s ease, + color 0.3s ease; } header { - background: var(--color-surface); - padding: var(--spacing-l) var(--spacing-xl); - border-bottom: 1px solid var(--border-color); - box-shadow: var(--shadow-2); + background: var(--color-surface); + padding: var(--spacing-l) var(--spacing-xl); + border-bottom: 1px solid var(--border-color); + box-shadow: var(--shadow-2); } /* Add theme toggle button */ .theme-toggle { - position: absolute; - top: var(--spacing-l); - right: var(--spacing-xl); - background: none; - border: none; - font-size: 20px; - cursor: pointer; - padding: var(--spacing-s); - border-radius: var(--border-radius-medium); - transition: background-color 0.2s ease; + position: absolute; + top: var(--spacing-l); + right: var(--spacing-xl); + background: none; + border: none; + font-size: 20px; + cursor: pointer; + padding: var(--spacing-s); + border-radius: var(--border-radius-medium); + transition: background-color 0.2s ease; } .theme-toggle:hover { - background-color: var(--color-neutral-background-selected); + background-color: var(--color-neutral-background-selected); } * { - box-sizing: border-box; + box-sizing: border-box; } header h2 { - margin: 0 0 var(--spacing-xs) 0; - font-weight: var(--font-weight-semibold); - font-size: 24px; - color: var(--color-neutral-foreground); + margin: 0 0 var(--spacing-xs) 0; + font-weight: var(--font-weight-semibold); + font-size: 24px; + color: var(--color-neutral-foreground); } .subtitle { - font-size: 14px; - color: var(--color-neutral-foreground-subtle); - margin: 0; + font-size: 14px; + color: var(--color-neutral-foreground-subtle); + margin: 0; } /* Main Layout */ main { - display: flex; - min-height: calc(100vh - 80px); + display: flex; + min-height: calc(100vh - 80px); } /* Navigation */ nav { - width: 260px; - background: var(--color-surface); - border-right: 1px solid var(--border-color); - padding: var(--spacing-l); - display: flex; - flex-direction: column; - gap: var(--spacing-xs); + width: 260px; + background: var(--color-surface); + border-right: 1px solid var(--border-color); + padding: var(--spacing-l); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); } .nav-button { - display: flex; - align-items: center; - gap: var(--spacing-s); - padding: var(--spacing-m) var(--spacing-l); - background: none; - border: 1px solid transparent; - border-radius: var(--border-radius-medium); - font-family: inherit; - font-size: 14px; - font-weight: var(--font-weight-regular); - color: var(--color-neutral-foreground); - cursor: pointer; - transition: all 0.1s ease; - text-align: left; - text-decoration: none; + display: flex; + align-items: center; + gap: var(--spacing-s); + padding: var(--spacing-m) var(--spacing-l); + background: none; + border: 1px solid transparent; + border-radius: var(--border-radius-medium); + font-family: inherit; + font-size: 14px; + font-weight: var(--font-weight-regular); + color: var(--color-neutral-foreground); + cursor: pointer; + transition: all 0.1s ease; + text-align: left; + text-decoration: none; } .nav-button:hover { - background-color: var(--color-neutral-background-selected); - border-color: var(--border-color); + background-color: var(--color-neutral-background-selected); + border-color: var(--border-color); } .nav-button.active { - background-color: var(--color-brand-background); - color: white; - font-weight: var(--font-weight-semibold); - border-color: var(--color-brand-background); + background-color: var(--color-brand-background); + color: white; + font-weight: var(--font-weight-semibold); + border-color: var(--color-brand-background); } .nav-button.accent { - background-color: var(--color-brand-background); - color: white; - font-weight: var(--font-weight-semibold); - margin-top: var(--spacing-xl); - border-color: var(--color-brand-background); + background-color: var(--color-brand-background); + color: white; + font-weight: var(--font-weight-semibold); + margin-top: var(--spacing-xl); + border-color: var(--color-brand-background); } .nav-button.accent:hover { - background-color: var(--color-brand-background-hover); - border-color: var(--color-brand-background-hover); + background-color: var(--color-brand-background-hover); + border-color: var(--color-brand-background-hover); } .nav-button.warning { - background-color: var(--color-status-warning); - color: white; - border-color: var(--color-status-warning); + background-color: var(--color-status-warning); + color: white; + border-color: var(--color-status-warning); } .nav-button.warning:hover { - background-color: #c13501; - border-color: #c13501; + background-color: #c13501; + border-color: #c13501; } /* Tips Section */ .tips-section { - margin-top: auto; - padding-top: var(--spacing-xl); - border-top: 1px solid var(--border-color); + margin-top: auto; + padding-top: var(--spacing-xl); + border-top: 1px solid var(--border-color); } .tips-title { - font-weight: var(--font-weight-semibold); - font-size: 14px; - margin-bottom: var(--spacing-s); - color: var(--color-neutral-foreground); + font-weight: var(--font-weight-semibold); + font-size: 14px; + margin-bottom: var(--spacing-s); + color: var(--color-neutral-foreground); } .tips-list { - margin: 0; - padding-left: var(--spacing-l); - font-size: 13px; - color: var(--color-neutral-foreground-subtle); - line-height: 1.5; + margin: 0; + padding-left: var(--spacing-l); + font-size: 13px; + color: var(--color-neutral-foreground-subtle); + line-height: 1.5; } .tips-list li { - margin-bottom: var(--spacing-xs); + margin-bottom: var(--spacing-xs); } /* Content Area */ section { - flex: 1; - padding: var(--spacing-xl); - overflow: auto; - display: none; + flex: 1; + padding: var(--spacing-xl); + overflow: auto; + display: none; } section.active { - display: block; + display: block; } .card { - background: var(--color-surface); - border-radius: var(--border-radius-large); - padding: var(--spacing-xl); - margin-bottom: var(--spacing-l); - box-shadow: var(--shadow-2); - border: 1px solid var(--border-color); + background: var(--color-surface); + border-radius: var(--border-radius-large); + padding: var(--spacing-xl); + margin-bottom: var(--spacing-l); + box-shadow: var(--shadow-2); + border: 1px solid var(--border-color); } .card-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: var(--spacing-l); + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--spacing-l); } .card-title { - margin: 0; - font-weight: var(--font-weight-semibold); - font-size: 20px; - color: var(--color-neutral-foreground); + margin: 0; + font-weight: var(--font-weight-semibold); + font-size: 20px; + color: var(--color-neutral-foreground); } .card-description { - margin: var(--spacing-xs) 0 0 0; - font-size: 14px; - color: var(--color-neutral-foreground-subtle); + margin: var(--spacing-xs) 0 0 0; + font-size: 14px; + color: var(--color-neutral-foreground-subtle); } /* Buttons */ .button { - display: inline-flex; - align-items: center; - gap: var(--spacing-s); - padding: var(--spacing-m) var(--spacing-l); - background: var(--color-brand-background); - color: white; - border: none; - border-radius: var(--border-radius-medium); - font-family: inherit; - font-size: 14px; - font-weight: var(--font-weight-semibold); - cursor: pointer; - transition: background-color 0.1s ease; - text-decoration: none; + display: inline-flex; + align-items: center; + gap: var(--spacing-s); + padding: var(--spacing-m) var(--spacing-l); + background: var(--color-brand-background); + color: white; + border: none; + border-radius: var(--border-radius-medium); + font-family: inherit; + font-size: 14px; + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: background-color 0.1s ease; + text-decoration: none; } .button:hover { - background-color: var(--color-brand-background-hover); + background-color: var(--color-brand-background-hover); } .button.secondary { - background: transparent; - color: var(--color-brand-foreground); - border: 1px solid var(--color-brand-foreground); + background: transparent; + color: var(--color-brand-foreground); + border: 1px solid var(--color-brand-foreground); } .button.secondary:hover { - background-color: var(--color-neutral-background); + background-color: var(--color-neutral-background); } .button.warning { - background: var(--color-status-warning); + background: var(--color-status-warning); } .button.warning:hover { - background-color: #c13501; + background-color: #c13501; } /* Interface Grid */ .interface-grid { - display: grid; - gap: var(--spacing-l); - margin-top: var(--spacing-l); + display: grid; + gap: var(--spacing-l); + margin-top: var(--spacing-l); } .interface-card { - background: var(--color-surface); - border-radius: var(--border-radius-large); - padding: var(--spacing-xl); - box-shadow: var(--shadow-2); - border: 1px solid var(--border-color); - border-left: 4px solid var(--color-brand-background); + background: var(--color-surface); + border-radius: var(--border-radius-large); + padding: var(--spacing-xl); + box-shadow: var(--shadow-2); + border: 1px solid var(--border-color); + border-left: 4px solid var(--color-brand-background); } .interface-card.wan { - border-left-color: var(--color-status-warning); + border-left-color: var(--color-status-warning); } .interface-card.lan { - border-left-color: var(--color-status-success); + border-left-color: var(--color-status-success); } .interface-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: var(--spacing-l); + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--spacing-l); } .interface-name { - font-weight: var(--font-weight-semibold); - font-size: 18px; - margin: 0; - color: var(--color-neutral-foreground); + font-weight: var(--font-weight-semibold); + font-size: 18px; + margin: 0; + color: var(--color-neutral-foreground); } .interface-type { - display: inline-block; - padding: var(--spacing-xs) var(--spacing-s); - background: var(--color-neutral-background); - border-radius: var(--border-radius-medium); - font-size: 12px; - font-weight: var(--font-weight-semibold); - color: var(--color-neutral-foreground-subtle); - margin-left: var(--spacing-s); + display: inline-block; + padding: var(--spacing-xs) var(--spacing-s); + background: var(--color-neutral-background); + border-radius: var(--border-radius-medium); + font-size: 12px; + font-weight: var(--font-weight-semibold); + color: var(--color-neutral-foreground-subtle); + margin-left: var(--spacing-s); } .interface-details { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: var(--spacing-l); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-l); } .detail-group h4 { - margin: 0 0 var(--spacing-xs) 0; - font-size: 14px; - font-weight: var(--font-weight-semibold); - color: var(--color-neutral-foreground); + margin: 0 0 var(--spacing-xs) 0; + font-size: 14px; + font-weight: var(--font-weight-semibold); + color: var(--color-neutral-foreground); } .detail-value { - font-size: 14px; - color: var(--color-neutral-foreground-subtle); - line-height: 1.5; + font-size: 14px; + color: var(--color-neutral-foreground-subtle); + line-height: 1.5; } .state-badge { - display: inline-block; - padding: 2px 8px; - border-radius: 10px; - font-size: 12px; - font-weight: var(--font-weight-semibold); + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: var(--font-weight-semibold); } .state-up { - background: #dff6dd; - color: var(--color-status-success); + background: #dff6dd; + color: var(--color-status-success); } .state-down { - background: #f4d5d5; - color: var(--color-status-error); + background: #f4d5d5; + color: var(--color-status-error); } /* Forms */ /* Forms */ .form-group { - margin-bottom: var(--spacing-l); + margin-bottom: var(--spacing-l); } .form-label { - display: block; - margin-bottom: var(--spacing-s); - font-weight: var(--font-weight-semibold); - font-size: 14px; - color: var(--color-neutral-foreground); + display: block; + margin-bottom: var(--spacing-s); + font-weight: var(--font-weight-semibold); + font-size: 14px; + color: var(--color-neutral-foreground); } .select { - width: 100%; - padding: var(--spacing-m); - border: 1px solid var(--border-color); - border-radius: var(--border-radius-medium); - font-family: inherit; - font-size: 14px; - background: var(--color-surface); - color: var(--color-neutral-foreground); - transition: border-color 0.1s ease; + width: 100%; + padding: var(--spacing-m); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-medium); + font-family: inherit; + font-size: 14px; + background: var(--color-surface); + color: var(--color-neutral-foreground); + transition: border-color 0.1s ease; } .select:focus { - outline: none; - border-color: var(--color-brand-background); + outline: none; + border-color: var(--color-brand-background); } .textarea { - width: 100%; - height: 360px; - padding: var(--spacing-m); - border: 1px solid var(--border-color); - border-radius: var(--border-radius-medium); - font-family: 'Consolas', 'Monaco', monospace; - font-size: 13px; - line-height: 1.5; - resize: vertical; - background: var(--color-surface); - color: var(--color-neutral-foreground); - transition: border-color 0.1s ease; + width: 100%; + height: 360px; + padding: var(--spacing-m); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-medium); + font-family: "Consolas", "Monaco", monospace; + font-size: 13px; + line-height: 1.5; + resize: vertical; + background: var(--color-surface); + color: var(--color-neutral-foreground); + transition: border-color 0.1s ease; } .textarea:focus { - outline: none; - border-color: var(--color-brand-background); + outline: none; + border-color: var(--color-brand-background); } .checkbox-group { - display: flex; - align-items: center; - gap: var(--spacing-s); - margin: var(--spacing-l) 0; + display: flex; + align-items: center; + gap: var(--spacing-s); + margin: var(--spacing-l) 0; } .checkbox { - width: 16px; - height: 16px; - margin: 0; + width: 16px; + height: 16px; + margin: 0; } .checkbox-label { - font-size: 14px; - color: var(--color-neutral-foreground); - margin: 0; + font-size: 14px; + color: var(--color-neutral-foreground); + margin: 0; } /* Validation Results */ .validation-result { - padding: var(--spacing-m); - border-radius: var(--border-radius-medium); - font-size: 14px; - margin-top: var(--spacing-m); + padding: var(--spacing-m); + border-radius: var(--border-radius-medium); + font-size: 14px; + margin-top: var(--spacing-m); } .validation-success { - background: #dff6dd; - color: var(--color-status-success); - border: 1px solid #107c10; + background: #dff6dd; + color: var(--color-status-success); + border: 1px solid #107c10; } .validation-error { - background: #f4d5d5; - color: var(--color-status-error); - border: 1px solid var(--color-status-error); + background: #f4d5d5; + color: var(--color-status-error); + border: 1px solid var(--color-status-error); } /* Logs */ .logs-container { - background: #1e1e1e; - color: #d4d4d4; - padding: var(--spacing-l); - border-radius: var(--border-radius-medium); - font-family: 'Consolas', 'Monaco', monospace; - font-size: 13px; - line-height: 1.5; - overflow: auto; - max-height: 600px; - white-space: pre-wrap; + background: #1e1e1e; + color: #d4d4d4; + padding: var(--spacing-l); + border-radius: var(--border-radius-medium); + font-family: "Consolas", "Monaco", monospace; + font-size: 13px; + line-height: 1.5; + overflow: auto; + max-height: 600px; + white-space: pre-wrap; } /* Command Results */ .command-result { - padding: var(--spacing-m); - border-radius: var(--border-radius-medium); - background: var(--color-neutral-background); - font-size: 14px; - margin-top: var(--spacing-m); + padding: var(--spacing-m); + border-radius: var(--border-radius-medium); + background: var(--color-neutral-background); + font-size: 14px; + margin-top: var(--spacing-m); } /* Tips Section */ .tips-section { - margin-top: auto; - padding-top: var(--spacing-xl); - border-top: 1px solid #e1e1e1; + margin-top: auto; + padding-top: var(--spacing-xl); + border-top: 1px solid #e1e1e1; } .tips-title { - font-weight: var(--font-weight-semibold); - font-size: 14px; - margin-bottom: var(--spacing-s); - color: var(--color-neutral-foreground); + font-weight: var(--font-weight-semibold); + font-size: 14px; + margin-bottom: var(--spacing-s); + color: var(--color-neutral-foreground); } .tips-list { - margin: 0; - padding-left: var(--spacing-l); - font-size: 13px; - color: var(--color-neutral-foreground-subtle); - line-height: 1.5; + margin: 0; + padding-left: var(--spacing-l); + font-size: 13px; + color: var(--color-neutral-foreground-subtle); + line-height: 1.5; } .tips-list li { - margin-bottom: var(--spacing-xs); + margin-bottom: var(--spacing-xs); } /* Responsive */ @media (max-width: 768px) { - main { - flex-direction: column; - } + main { + flex-direction: column; + } - nav { - width: 100%; - flex-direction: row; - overflow-x: auto; - padding: var(--spacing-m); - } + nav { + width: 100%; + flex-direction: row; + overflow-x: auto; + padding: var(--spacing-m); + } - .nav-button { - white-space: nowrap; - } + .nav-button { + white-space: nowrap; + } - .interface-details { - grid-template-columns: 1fr; - } + .interface-details { + grid-template-columns: 1fr; + } - .card-header { - flex-direction: column; - gap: var(--spacing-m); - } + .card-header { + flex-direction: column; + gap: var(--spacing-m); + } } /* Interface Tabs */ /* Interface Tabs */ .interface-tabs-container { - display: flex; - gap: var(--spacing-xs); - margin-bottom: var(--spacing-l); - flex-wrap: wrap; + display: flex; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-l); + flex-wrap: wrap; } .interface-tab { - display: flex; - align-items: center; - gap: var(--spacing-s); - padding: var(--spacing-m) var(--spacing-l); - background: var(--color-neutral-background); - border: 1px solid var(--border-color); - border-radius: var(--border-radius-medium); - font-family: inherit; - font-size: 14px; - font-weight: var(--font-weight-regular); - color: var(--color-neutral-foreground); - cursor: pointer; - transition: all 0.1s ease; + display: flex; + align-items: center; + gap: var(--spacing-s); + padding: var(--spacing-m) var(--spacing-l); + background: var(--color-neutral-background); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-medium); + font-family: inherit; + font-size: 14px; + font-weight: var(--font-weight-regular); + color: var(--color-neutral-foreground); + cursor: pointer; + transition: all 0.1s ease; } .interface-tab:hover { - background: var(--color-neutral-background-selected); - border-color: var(--color-brand-background); + background: var(--color-neutral-background-selected); + border-color: var(--color-brand-background); } .interface-tab.active { - background: var(--color-brand-background); - color: white; - border-color: var(--color-brand-background); + background: var(--color-brand-background); + color: white; + border-color: var(--color-brand-background); } .interface-state { - font-size: 12px; - padding: 2px 6px; - border-radius: 10px; - background: var(--color-neutral-foreground-subtle); - color: white; + font-size: 12px; + padding: 2px 6px; + border-radius: 10px; + background: var(--color-neutral-foreground-subtle); + color: white; } .interface-tab.active .interface-state { - background: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.3); } /* Interface Details */ .interface-detail-grid { - display: flex; - flex-direction: column; - gap: var(--spacing-s); - font-family: 'Monaco', 'Consolas', monospace; - font-size: 13px; + display: flex; + flex-direction: column; + gap: var(--spacing-s); + font-family: "Monaco", "Consolas", monospace; + font-size: 13px; } .detail-row { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: var(--spacing-l); - padding: var(--spacing-xs) 0; - border-bottom: 1px solid #f0f0f0; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-l); + padding: var(--spacing-xs) 0; + border-bottom: 1px solid #f0f0f0; } .detail-row:last-child { - border-bottom: none; + border-bottom: none; } .detail-label { - font-weight: var(--font-weight-semibold); - color: var(--color-neutral-foreground); - min-width: 200px; - flex-shrink: 0; + font-weight: var(--font-weight-semibold); + color: var(--color-neutral-foreground); + min-width: 200px; + flex-shrink: 0; } .detail-value { - color: var(--color-neutral-foreground-subtle); - text-align: right; - flex: 1; - word-break: break-word; + color: var(--color-neutral-foreground-subtle); + text-align: right; + flex: 1; + word-break: break-word; } /* Address and list items */ .address-item, .dns-item, .lease-item { - margin-bottom: 2px; - padding: 1px 0; + margin-bottom: 2px; + padding: 1px 0; } .address-item:last-child, .dns-item:last-child, .lease-item:last-child { - margin-bottom: 0; + margin-bottom: 0; } /* State badges in details */ .detail-value.state-up { - color: var(--color-status-success); - font-weight: var(--font-weight-semibold); + color: var(--color-status-success); + font-weight: var(--font-weight-semibold); } .detail-value.state-down { - color: var(--color-status-error); - font-weight: var(--font-weight-semibold); + color: var(--color-status-error); + font-weight: var(--font-weight-semibold); } /* Error and empty states */ .error-message { - padding: var(--spacing-l); - background: var(--color-status-error); - color: white; - border-radius: var(--border-radius-medium); - text-align: center; + padding: var(--spacing-l); + background: var(--color-status-error); + color: white; + border-radius: var(--border-radius-medium); + text-align: center; } .no-interfaces { - padding: var(--spacing-xl); - text-align: center; - color: var(--color-neutral-foreground-subtle); - font-style: italic; + padding: var(--spacing-xl); + text-align: center; + color: var(--color-neutral-foreground-subtle); + font-style: italic; } /* Validation states */ .validation-pending { - color: var(--color-neutral-foreground-subtle); + color: var(--color-neutral-foreground-subtle); } .validation-success { - color: var(--color-status-success); - font-weight: var(--font-weight-semibold); + color: var(--color-status-success); + font-weight: var(--font-weight-semibold); } .validation-error { - color: var(--color-status-error); - font-weight: var(--font-weight-semibold); + color: var(--color-status-error); + font-weight: var(--font-weight-semibold); } .validation-result { - padding: var(--spacing-m); - border-radius: var(--border-radius-medium); - font-size: 14px; - margin-top: var(--spacing-m); + padding: var(--spacing-m); + border-radius: var(--border-radius-medium); + font-size: 14px; + margin-top: var(--spacing-m); } .validation-success { - background: var(--color-status-success); - color: white; - border: 1px solid var(--color-status-success); + background: var(--color-status-success); + color: white; + border: 1px solid var(--color-status-success); } .validation-error { - background: var(--color-status-error); - color: white; - border: 1px solid var(--color-status-error); + background: var(--color-status-error); + color: white; + border: 1px solid var(--color-status-error); } /* Responsive improvements */ @media (max-width: 768px) { - .detail-row { - flex-direction: column; - gap: var(--spacing-xs); - align-items: stretch; - } + .detail-row { + flex-direction: column; + gap: var(--spacing-xs); + align-items: stretch; + } - .detail-label { - min-width: auto; - font-size: 12px; - } + .detail-label { + min-width: auto; + font-size: 12px; + } - .detail-value { - text-align: left; - } + .detail-value { + text-align: left; + } - .interface-tabs-container { - flex-direction: column; - } + .interface-tabs-container { + flex-direction: column; + } - .interface-tab { - justify-content: space-between; - } + .interface-tab { + justify-content: space-between; + } } .interface-detail-grid { - display: grid; - grid-template-columns: auto 1fr; - gap: var(--spacing-s) var(--spacing-l); - font-family: 'Monaco', 'Consolas', monospace; - font-size: 13px; - align-items: start; + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-s) var(--spacing-l); + font-family: "Monaco", "Consolas", monospace; + font-size: 13px; + align-items: start; } .detail-row { - display: contents; + display: contents; } .detail-label { - font-weight: var(--font-weight-semibold); - color: var(--color-neutral-foreground); - text-align: right; - padding: var(--spacing-xs) 0; - border-bottom: 1px solid var(--border-color); + font-weight: var(--font-weight-semibold); + color: var(--color-neutral-foreground); + text-align: right; + padding: var(--spacing-xs) 0; + border-bottom: 1px solid var(--border-color); } .detail-value { - color: var(--color-neutral-foreground-subtle); - text-align: left; - padding: var(--spacing-xs) 0; - border-bottom: 1px solid var(--border-color); - word-break: break-word; + color: var(--color-neutral-foreground-subtle); + text-align: left; + padding: var(--spacing-xs) 0; + border-bottom: 1px solid var(--border-color); + word-break: break-word; } .detail-row:last-child .detail-label, .detail-row:last-child .detail-value { - border-bottom: none; + border-bottom: none; } /* Abbreviation styling */ abbr { - text-decoration: underline dotted; - cursor: help; + text-decoration: underline dotted; + cursor: help; } /* Address and list items */ .address-item, .dns-item, .lease-item { - margin-bottom: 2px; - padding: 1px 0; + margin-bottom: 2px; + padding: 1px 0; } .address-item:last-child, .dns-item:last-child, .lease-item:last-child { - margin-bottom: 0; + margin-bottom: 0; } /* Structured Editor Styles */ .structured-editor { - margin-top: var(--spacing-l); + margin-top: var(--spacing-l); } .editor-sections { - display: flex; - flex-direction: column; - gap: var(--spacing-l); + display: flex; + flex-direction: column; + gap: var(--spacing-l); } /* Editor Mode Toggle */ .editor-mode-toggle { - display: flex; - gap: var(--spacing-s); - margin-bottom: var(--spacing-l); - padding-bottom: var(--spacing-l); - border-bottom: 1px solid var(--border-color); + display: flex; + gap: var(--spacing-s); + margin-bottom: var(--spacing-l); + padding-bottom: var(--spacing-l); + border-bottom: 1px solid var(--border-color); } .editor-mode-toggle .button.small { - flex: 1; - padding: var(--spacing-m) var(--spacing-l); + flex: 1; + padding: var(--spacing-m) var(--spacing-l); } .editor-mode-toggle .button.small.active { - background-color: var(--color-brand-background); - color: white; - border-color: var(--color-brand-background); + background-color: var(--color-brand-background); + color: white; + border-color: var(--color-brand-background); } .config-actions { - display: flex; - gap: var(--spacing-s); - align-items: center; - margin-top: var(--spacing-l); - padding-top: var(--spacing-l); - border-top: 1px solid var(--border-color); + display: flex; + gap: var(--spacing-s); + align-items: center; + margin-top: var(--spacing-l); + padding-top: var(--spacing-l); + border-top: 1px solid var(--border-color); } .config-section { - background: var(--color-surface); - border: 1px solid var(--border-color); - border-radius: var(--border-radius-medium); - padding: var(--spacing-l); + background: var(--color-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-medium); + padding: var(--spacing-l); } .config-section h4 { - margin: 0 0 var(--spacing-m) 0; - color: var(--color-brand-foreground); - font-family: monospace; + margin: 0 0 var(--spacing-m) 0; + color: var(--color-brand-foreground); + font-family: monospace; } .config-table { - display: grid; - grid-template-columns: auto 1fr; - gap: var(--spacing-s) var(--spacing-m); - align-items: center; + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-s) var(--spacing-m); + align-items: center; } .config-row { - display: contents; + display: contents; } .config-label { - text-align: right; - font-weight: var(--font-weight-semibold); - font-size: 13px; - color: var(--color-neutral-foreground); + text-align: right; + font-weight: var(--font-weight-semibold); + font-size: 13px; + color: var(--color-neutral-foreground); } .config-input, .config-select { - padding: var(--spacing-s); - border: 1px solid var(--border-color); - border-radius: var(--border-radius-medium); - background: var(--color-surface); - color: var(--color-neutral-foreground); - font-family: inherit; - font-size: 13px; + padding: var(--spacing-s); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-medium); + background: var(--color-surface); + color: var(--color-neutral-foreground); + font-family: inherit; + font-size: 13px; } .config-input:focus, .config-select:focus { - outline: none; - border-color: var(--color-brand-background); + outline: none; + border-color: var(--color-brand-background); } .button.small { - padding: var(--spacing-xs) var(--spacing-s); - font-size: 12px; - grid-column: 1 / -1; - justify-self: end; - margin-top: var(--spacing-s); + padding: var(--spacing-xs) var(--spacing-s); + font-size: 12px; + grid-column: 1 / -1; + justify-self: end; + margin-top: var(--spacing-s); } .editor-actions { - display: flex; - gap: var(--spacing-s); - margin-top: var(--spacing-l); - padding-top: var(--spacing-l); - border-top: 1px solid var(--border-color); + display: flex; + gap: var(--spacing-s); + margin-top: var(--spacing-l); + padding-top: var(--spacing-l); + border-top: 1px solid var(--border-color); } /* Structured Editor Enhancements */ .no-items { - color: var(--color-neutral-foreground-subtle); - font-style: italic; - text-align: center; - padding: var(--spacing-m); + color: var(--color-neutral-foreground-subtle); + font-style: italic; + text-align: center; + padding: var(--spacing-m); } .config-section { - background: var(--color-surface); - border: 1px solid var(--border-color); - border-radius: var(--border-radius-medium); - padding: var(--spacing-l); - transition: border-color 0.2s ease; + background: var(--color-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-medium); + padding: var(--spacing-l); + transition: border-color 0.2s ease; } .config-section:hover { - border-color: var(--color-brand-background); + border-color: var(--color-brand-background); } .config-section h4 { - margin: 0 0 var(--spacing-m) 0; - color: var(--color-brand-foreground); - font-family: monospace; - border-bottom: 1px solid var(--border-color); - padding-bottom: var(--spacing-s); + margin: 0 0 var(--spacing-m) 0; + color: var(--color-brand-foreground); + font-family: monospace; + border-bottom: 1px solid var(--border-color); + padding-bottom: var(--spacing-s); } .button.warning { - background: var(--color-status-warning); - border-color: var(--color-status-warning); + background: var(--color-status-warning); + border-color: var(--color-status-warning); } .button.warning:hover { - background: #c13501; - border-color: #c13501; + background: #c13501; + border-color: #c13501; } diff --git a/static/systemd-network.js b/static/systemd-network.js index 0c57ec1..69020f1 100644 --- a/static/systemd-network.js +++ b/static/systemd-network.js @@ -1,54 +1,132 @@ /* jshint esversion: 2024, module: true */ -/** - * Systemd Network Configuration Parser - * Based on systemd.network(5) documentation - * @module SystemdNetwork - */ +import { + BooleanYesNo, + ClientIdentifier, + DHCPMode, + DNSSECOptions, + DuplexMode, + FieldType, + IPForward, + IPv6PrivacyExtensions, + LLMNROptions, + MulticastDNS, + PortType, + RouteScope, + RouteType, + UseDomains, + WakeOnLAN, +} from "./network-types.js"; /** * Base field type with standardized interface * @class BaseField */ class BaseField { - /** - * @param {*} value - Field value - * @param {string} type - Field type - * @param {string} description - Field description - * @param {Object} options - Additional options (pattern, enum, etc.) - */ - constructor(value = null, type = 'string', description = '', options = {}) { - this.value = value; - this.type = type; - this.description = description; - this.options = options; - } - - /** - * Validate field value - * @returns {boolean} - */ - validate() { - if (this.value === null || this.value === undefined) return true; - - if (this.options.pattern && typeof this.value === 'string') { - return this.options.pattern.test(this.value); - } - - if (this.options.enum && this.options.enum.length > 0) { - return this.options.enum.includes(this.value); - } - - return true; - } - - /** - * Convert to string representation - * @returns {string} - */ - toString() { - return this.value !== null ? String(this.value) : ''; - } + #value; + #type; + #description; + #options; + + /** + * @param {*} value - Field value + * @param {Symbol} type - Field type from FieldType enum + * @param {string} description - Field description + * @param {Object} options - Additional options (pattern, enum, etc.) + */ + constructor( + value = null, + type = FieldType.STRING, + description = "", + options = {}, + ) { + this.#value = value; + this.#type = type; + this.#description = description; + this.#options = options; + } + + /** + * Get field value + * @returns {*} + */ + get value() { + return this.#value; + } + + /** + * Set field value + * @param {*} newValue + */ + set value(newValue) { + this.#value = newValue; + } + + /** + * Get field type + * @returns {Symbol} + */ + get type() { + return this.#type; + } + + /** + * Get field description + * @returns {string} + */ + get description() { + return this.#description; + } + + /** + * Get field options + * @returns {Object} + */ + get options() { + return { ...this.#options }; + } + + /** + * Validate field value + * @returns {boolean} + */ + validate() { + if ( + this.#value === null || + this.#value === undefined || + this.#value === "" + ) { + return true; + } + + if (this.#options.pattern && typeof this.#value === "string") { + return this.#options.pattern.test(this.#value); + } + + if (this.#options.enum && this.#options.enum.length > 0) { + return this.#options.enum.includes(this.#value); + } + + return true; + } + + /** + * Convert to string representation + * @returns {string} + */ + toString() { + return this.#value !== null ? String(this.#value) : ""; + } + + /** + * Check if field has a value + * @returns {boolean} + */ + hasValue() { + return ( + this.#value !== null && this.#value !== undefined && this.#value !== "" + ); + } } /** @@ -56,11 +134,11 @@ class BaseField { * @class MACAddress */ class MACAddress extends BaseField { - constructor(value = null) { - super(value, 'mac-address', 'Hardware address (MAC)', { - pattern: /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/ - }); - } + constructor(value = null) { + super(value, FieldType.MAC_ADDRESS, "Hardware address (MAC)", { + pattern: /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/, + }); + } } /** @@ -68,11 +146,11 @@ class MACAddress extends BaseField { * @class IPv4Address */ class IPv4Address extends BaseField { - constructor(value = null) { - super(value, 'ipv4-address', 'IPv4 address', { - pattern: /^(\d{1,3}\.){3}\d{1,3}$/ - }); - } + constructor(value = null) { + super(value, FieldType.IPV4_ADDRESS, "IPv4 address", { + pattern: /^(\d{1,3}\.){3}\d{1,3}$/, + }); + } } /** @@ -80,23 +158,11 @@ class IPv4Address extends BaseField { * @class IPv6Address */ class IPv6Address extends BaseField { - constructor(value = null) { - super(value, 'ipv6-address', 'IPv6 address', { - pattern: /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/ - }); - } -} - -/** - * Boolean type with yes/no values - * @class BooleanYesNo - */ -class BooleanYesNo extends BaseField { - constructor(value = null) { - super(value, 'boolean', 'Boolean (yes/no)', { - enum: ['yes', 'no'] - }); - } + constructor(value = null) { + super(value, FieldType.IPV6_ADDRESS, "IPv6 address", { + pattern: /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/, + }); + } } /** @@ -104,11 +170,12 @@ class BooleanYesNo extends BaseField { * @class Port */ class Port extends BaseField { - constructor(value = null) { - super(value, 'port', 'Network port (1-65535)', { - pattern: /^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/ - }); - } + constructor(value = null) { + super(value, FieldType.PORT, "Network port (1-65535)", { + pattern: + /^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/, + }); + } } /** @@ -116,11 +183,11 @@ class Port extends BaseField { * @class MTU */ class MTU extends BaseField { - constructor(value = null) { - super(value, 'mtu', 'Maximum Transmission Unit', { - pattern: /^[1-9][0-9]*$/ - }); - } + constructor(value = null) { + super(value, FieldType.MTU, "Maximum Transmission Unit", { + pattern: /^[1-9][0-9]*$/, + }); + } } /** @@ -128,19 +195,43 @@ class MTU extends BaseField { * @class MatchSection */ class MatchSection { - constructor() { - this.MACAddress = new BaseField(null, 'mac-addresses', 'Space-separated MAC addresses'); - this.OriginalName = new BaseField(null, 'strings', 'Original interface names'); - this.Path = new BaseField(null, 'strings', 'Device path patterns'); - this.Driver = new BaseField(null, 'strings', 'Driver names'); - this.Type = new BaseField(null, 'strings', 'Interface types (ether, wifi, etc.)'); - this.Name = new BaseField(null, 'strings', 'Interface names'); - this.Property = new BaseField(null, 'string', 'Device property'); - this.Host = new BaseField(null, 'string', 'Host name'); - this.Virtualization = new BaseField(null, 'string', 'Virtualization detection'); - this.KernelCommandLine = new BaseField(null, 'string', 'Kernel command line'); - this.Architecture = new BaseField(null, 'string', 'System architecture'); - } + constructor() { + this.MACAddress = new BaseField( + null, + FieldType.STRINGS, + "Space-separated MAC addresses", + ); + this.OriginalName = new BaseField( + null, + FieldType.STRINGS, + "Original interface names", + ); + this.Path = new BaseField(null, FieldType.STRINGS, "Device path patterns"); + this.Driver = new BaseField(null, FieldType.STRINGS, "Driver names"); + this.Type = new BaseField( + null, + FieldType.STRINGS, + "Interface types (ether, wifi, etc.)", + ); + this.Name = new BaseField(null, FieldType.STRINGS, "Interface names"); + this.Property = new BaseField(null, FieldType.STRING, "Device property"); + this.Host = new BaseField(null, FieldType.STRING, "Host name"); + this.Virtualization = new BaseField( + null, + FieldType.STRING, + "Virtualization detection", + ); + this.KernelCommandLine = new BaseField( + null, + FieldType.STRING, + "Kernel command line", + ); + this.Architecture = new BaseField( + null, + FieldType.STRING, + "System architecture", + ); + } } /** @@ -148,24 +239,32 @@ class MatchSection { * @class LinkSection */ class LinkSection { - constructor() { - this.MACAddress = new MACAddress(); - this.MTUBytes = new MTU(); - this.BitsPerSecond = new BaseField(null, 'number', 'Link speed in bits per second'); - this.Duplex = new BaseField(null, 'string', 'Duplex mode', { - enum: ['half', 'full'] - }); - this.AutoNegotiation = new BooleanYesNo(); - this.WakeOnLan = new BaseField(null, 'string', 'Wake-on-LAN', { - enum: ['phy', 'unicast', 'broadcast', 'arp', 'magic', ''] - }); - this.Port = new BaseField(null, 'string', 'Port type', { - enum: ['tp', 'aui', 'bnc', 'mii', 'fibre', ''] - }); - this.Advertise = new BaseField(null, 'strings', 'Advertised features'); - this.RxFlowControl = new BooleanYesNo(); - this.TxFlowControl = new BooleanYesNo(); - } + constructor() { + this.MACAddress = new MACAddress(); + this.MTUBytes = new MTU(); + this.BitsPerSecond = new BaseField( + null, + FieldType.NUMBER, + "Link speed in bits per second", + ); + this.Duplex = new BaseField(null, FieldType.STRING, "Duplex mode", { + enum: Object.values(DuplexMode), + }); + this.AutoNegotiation = new BooleanYesNo(); + this.WakeOnLan = new BaseField(null, FieldType.STRING, "Wake-on-LAN", { + enum: Object.values(WakeOnLAN), + }); + this.Port = new BaseField(null, FieldType.STRING, "Port type", { + enum: Object.values(PortType), + }); + this.Advertise = new BaseField( + null, + FieldType.STRINGS, + "Advertised features", + ); + this.RxFlowControl = new BooleanYesNo(); + this.TxFlowControl = new BooleanYesNo(); + } } /** @@ -173,35 +272,55 @@ class LinkSection { * @class NetworkSection */ class NetworkSection { - constructor() { - this.Description = new BaseField(null, 'string', 'Interface description'); - this.DHCP = new BaseField(null, 'dhcp-mode', 'DHCP client', { - enum: ['yes', 'no', 'ipv4', 'ipv6'] - }); - this.DHCPServer = new BooleanYesNo(); - this.DNS = new BaseField(null, 'ip-addresses', 'DNS servers'); - this.NTP = new BaseField(null, 'ip-addresses', 'NTP servers'); - this.IPForward = new BaseField(null, 'ip-forward', 'IP forwarding', { - enum: ['yes', 'no', 'ipv4', 'ipv6'] - }); - this.IPv6PrivacyExtensions = new BaseField(null, 'privacy-extensions', 'IPv6 privacy extensions', { - enum: ['yes', 'no', 'prefer-public'] - }); - this.IPv6AcceptRA = new BooleanYesNo(); - this.LLMNR = new BaseField(null, 'llmnr', 'LLMNR support', { - enum: ['yes', 'no', 'resolve'] - }); - this.MulticastDNS = new BaseField(null, 'mdns', 'Multicast DNS', { - enum: ['yes', 'no', 'resolve'] - }); - this.DNSSEC = new BaseField(null, 'dnssec', 'DNSSEC support', { - enum: ['yes', 'no', 'allow-downgrade'] - }); - this.Domains = new BaseField(null, 'strings', 'DNS search domains'); - this.ConfigureWithoutCarrier = new BooleanYesNo(); - this.IgnoreCarrierLoss = new BooleanYesNo(); - this.KeepConfiguration = new BaseField(null, 'number', 'Keep configuration time in seconds'); - } + constructor() { + this.Description = new BaseField( + null, + FieldType.STRING, + "Interface description", + ); + this.DHCP = new BaseField(null, FieldType.DHCP_MODE, "DHCP client", { + enum: Object.values(DHCPMode), + }); + this.DHCPServer = new BooleanYesNo(); + this.DNS = new BaseField(null, FieldType.IP_ADDRESSES, "DNS servers"); + this.NTP = new BaseField(null, FieldType.IP_ADDRESSES, "NTP servers"); + this.IPForward = new BaseField( + null, + FieldType.IP_FORWARD, + "IP forwarding", + { + enum: Object.values(IPForward), + }, + ); + this.IPv6PrivacyExtensions = new BaseField( + null, + FieldType.PRIVACY_EXTENSIONS, + "IPv6 privacy extensions", + { + enum: Object.values(IPv6PrivacyExtensions), + }, + ); + this.IPv6AcceptRA = new BooleanYesNo(); + this.LLMNR = new BaseField(null, FieldType.LLMNR, "LLMNR support", { + enum: Object.values(LLMNROptions), + }); + this.MulticastDNS = new BaseField(null, FieldType.MDNS, "Multicast DNS", { + enum: Object.values(MulticastDNS), + }); + this.DNSSEC = new BaseField(null, FieldType.DNSSEC, "DNSSEC support", { + enum: Object.values(DNSSECOptions), + }); + this.Domains = new BaseField(null, FieldType.STRINGS, "DNS search domains"); + this.ConfigureWithoutCarrier = new BooleanYesNo(); + this.IgnoreCarrierLoss = new BooleanYesNo(); + this.KeepConfiguration = new BaseField( + null, + FieldType.NUMBER, + "Keep configuration time in seconds", + ); + this.LLDP = new BooleanYesNo(); + this.EmitLLDP = new BooleanYesNo(); + } } /** @@ -209,19 +328,142 @@ class NetworkSection { * @class DHCPSection */ class DHCPSection { - constructor() { - this.UseDNS = new BooleanYesNo(); - this.UseNTP = new BooleanYesNo(); - this.UseMTU = new BooleanYesNo(); - this.UseHostname = new BooleanYesNo(); - this.UseDomains = new BaseField(null, 'use-domains', 'Use domains from DHCP', { - enum: ['yes', 'no', 'route'] - }); - this.ClientIdentifier = new BaseField(null, 'client-identifier', 'DHCP client identifier', { - enum: ['mac', 'duid'] - }); - this.RouteMetric = new BaseField(null, 'number', 'Route metric for DHCP routes'); - } + constructor() { + this.UseDNS = new BooleanYesNo(); + this.UseNTP = new BooleanYesNo(); + this.UseMTU = new BooleanYesNo(); + this.UseHostname = new BooleanYesNo(); + this.UseDomains = new BaseField( + null, + FieldType.USE_DOMAINS, + "Use domains from DHCP", + { + enum: Object.values(UseDomains), + }, + ); + this.ClientIdentifier = new BaseField( + null, + FieldType.CLIENT_IDENTIFIER, + "DHCP client identifier", + { + enum: Object.values(ClientIdentifier), + }, + ); + this.RouteMetric = new BaseField( + null, + FieldType.NUMBER, + "Route metric for DHCP routes", + ); + this.UseRoutes = new BooleanYesNo(); + this.SendRelease = new BooleanYesNo(); + } +} + +/** + * [DHCPv4] section configuration + * @class DHCPv4Section + */ +class DHCPv4Section { + constructor() { + this.ClientIdentifier = new BaseField( + null, + FieldType.CLIENT_IDENTIFIER, + "DHCPv4 client identifier", + { + enum: Object.values(ClientIdentifier), + }, + ); + this.UseDNS = new BooleanYesNo(); + this.UseNTP = new BooleanYesNo(); + this.UseMTU = new BooleanYesNo(); + this.UseHostname = new BooleanYesNo(); + this.UseDomains = new BaseField( + null, + FieldType.USE_DOMAINS, + "Use domains from DHCPv4", + { + enum: Object.values(UseDomains), + }, + ); + this.SendRelease = new BooleanYesNo(); + } +} + +/** + * [DHCPv6] section configuration + * @class DHCPv6Section + */ +class DHCPv6Section { + constructor() { + this.UseDNS = new BooleanYesNo(); + this.UseNTP = new BooleanYesNo(); + this.UseHostname = new BooleanYesNo(); + this.UseDomains = new BaseField( + null, + FieldType.USE_DOMAINS, + "Use domains from DHCPv6", + { + enum: Object.values(UseDomains), + }, + ); + this.WithoutRA = new BooleanYesNo(); + this.UseAddress = new BooleanYesNo(); + } +} + +/** + * [IPv6AcceptRA] section configuration + * @class IPv6AcceptRASection + */ +class IPv6AcceptRASection { + constructor() { + this.UseDNS = new BooleanYesNo(); + this.UseDomains = new BaseField( + null, + FieldType.USE_DOMAINS, + "Use domains from RA", + { + enum: Object.values(UseDomains), + }, + ); + this.UseAutonomousPrefix = new BooleanYesNo(); + this.UseOnLinkPrefix = new BooleanYesNo(); + this.UseRoutePrefix = new BooleanYesNo(); + this.RouteMetric = new BaseField( + null, + FieldType.NUMBER, + "Route metric for RA routes", + ); + } +} + +/** + * [SLAAC] section configuration + * @class SLAACSection + */ +class SLAACSection { + constructor() { + this.UseDNS = new BooleanYesNo(); + this.UseDomains = new BaseField( + null, + FieldType.USE_DOMAINS, + "Use domains from SLAAC", + { + enum: Object.values(UseDomains), + }, + ); + this.UseAddress = new BooleanYesNo(); + this.RouteMetric = new BaseField( + null, + FieldType.NUMBER, + "Route metric for SLAAC routes", + ); + this.Critical = new BooleanYesNo(); + this.PreferTemporaryAddress = new BooleanYesNo(); + this.UseAutonomousPrefix = new BooleanYesNo(); + this.UseOnLinkPrefix = new BooleanYesNo(); + this.UseRoutePrefix = new BooleanYesNo(); + } } /** @@ -229,14 +471,23 @@ class DHCPSection { * @class AddressSection */ class AddressSection { - constructor() { - this.Address = new BaseField(null, 'ip-prefix', 'IP address with prefix'); - this.Peer = new BaseField(null, 'ip-address', 'Peer address'); - this.Broadcast = new BaseField(null, 'ip-address', 'Broadcast address'); - this.Label = new BaseField(null, 'string', 'Address label'); - this.Scope = new BaseField(null, 'number', 'Address scope'); - this.Flags = new BaseField(null, 'strings', 'Address flags'); - } + constructor() { + this.Address = new BaseField( + null, + FieldType.IP_PREFIX, + "IP address with prefix", + ); + this.Peer = new BaseField(null, FieldType.IP_ADDRESS, "Peer address"); + this.Broadcast = new BaseField( + null, + FieldType.IP_ADDRESS, + "Broadcast address", + ); + this.Label = new BaseField(null, FieldType.STRING, "Address label"); + this.Scope = new BaseField(null, FieldType.NUMBER, "Address scope"); + this.Flags = new BaseField(null, FieldType.STRINGS, "Address flags"); + this.Lifetime = new BaseField(null, FieldType.STRING, "Address lifetime"); + } } /** @@ -244,20 +495,40 @@ class AddressSection { * @class RouteSection */ class RouteSection { - constructor() { - this.Gateway = new BaseField(null, 'ip-address', 'Gateway address'); - this.GatewayOnLink = new BooleanYesNo(); - this.Destination = new BaseField(null, 'ip-prefix', 'Destination prefix'); - this.Source = new BaseField(null, 'ip-address', 'Source address'); - this.PreferredSource = new BaseField(null, 'ip-address', 'Preferred source address'); - this.Metric = new BaseField(null, 'number', 'Route metric'); - this.Scope = new BaseField(null, 'route-scope', 'Route scope', { - enum: ['global', 'link', 'host'] - }); - this.Type = new BaseField(null, 'route-type', 'Route type', { - enum: ['unicast', 'local', 'broadcast', 'anycast', 'multicast', 'blackhole', 'unreachable', 'prohibit'] - }); - } + constructor() { + this.Gateway = new BaseField(null, FieldType.IP_ADDRESS, "Gateway address"); + this.GatewayOnLink = new BooleanYesNo(); + this.Destination = new BaseField( + null, + FieldType.IP_PREFIX, + "Destination prefix", + ); + this.Source = new BaseField(null, FieldType.IP_ADDRESS, "Source address"); + this.PreferredSource = new BaseField( + null, + FieldType.IP_ADDRESS, + "Preferred source address", + ); + this.Metric = new BaseField(null, FieldType.NUMBER, "Route metric"); + this.Scope = new BaseField(null, FieldType.ROUTE_SCOPE, "Route scope", { + enum: Object.values(RouteScope), + }); + this.Type = new BaseField(null, FieldType.ROUTE_TYPE, "Route type", { + enum: Object.values(RouteType), + }); + this.InitialCongestionWindow = new BaseField( + null, + FieldType.NUMBER, + "Initial congestion window", + ); + this.InitialAdvertisedReceiveWindow = new BaseField( + null, + FieldType.NUMBER, + "Initial advertised receive window", + ); + this.Table = new BaseField(null, FieldType.NUMBER, "Routing table"); + this.Protocol = new BaseField(null, FieldType.NUMBER, "Routing protocol"); + } } /** @@ -265,264 +536,432 @@ class RouteSection { * @class NetworkConfiguration */ class NetworkConfiguration { - constructor() { - this.Match = new MatchSection(); - this.Link = new LinkSection(); - this.Network = new NetworkSection(); - this.DHCP = new DHCPSection(); - this.Address = []; - this.Route = []; - } - - /** - * Get schema for structured editor - * @returns {Object} Schema definition - */ - getSchema() { - return { - Match: this._getSectionSchema(this.Match), - Link: this._getSectionSchema(this.Link), - Network: this._getSectionSchema(this.Network), - DHCP: this._getSectionSchema(this.DHCP), - Address: this._getArraySectionSchema(AddressSection, 'Address'), - Route: this._getArraySectionSchema(RouteSection, 'Route') - }; - } - - /** - * Get schema for a single section - * @private - * @param {Object} section - Section instance - * @returns {Object} Section schema - */ - _getSectionSchema(section) { - const schema = {}; - for (const [key, field] of Object.entries(section)) { - schema[key] = { - value: field.value, - type: field.type, - description: field.description, - options: field.options - }; - } - return schema; - } - - /** - * Get schema for array sections (Address, Route) - * @private - * @param {Class} SectionClass - Section class - * @param {string} sectionName - Section name - * @returns {Object} Array section schema - */ - _getArraySectionSchema(SectionClass, sectionName) { - const template = new SectionClass(); - return { - itemSchema: this._getSectionSchema(template), - items: this[sectionName].map(item => this._getSectionSchema(item)) - }; - } - - /** - * Parse systemd network configuration from text - * @param {string} configText - Configuration file content - * @returns {NetworkConfiguration} - */ - static fromSystemdConfiguration(configText) { - const config = new NetworkConfiguration(); - const lines = configText.split('\n'); - let currentSection = null; - let currentAddress = null; - let currentRoute = null; - - for (const line of lines) { - const trimmed = line.trim(); - - // Skip empty lines and comments - if (!trimmed || trimmed.startsWith('#')) continue; - - // Section header - const sectionMatch = trimmed.match(/^\[(\w+)\]$/); - if (sectionMatch) { - currentSection = sectionMatch[1].toLowerCase(); - - // Start new array sections - if (currentSection === 'address') { - currentAddress = new AddressSection(); - config.Address.push(currentAddress); - } else if (currentSection === 'route') { - currentRoute = new RouteSection(); - config.Route.push(currentRoute); - } - continue; - } - - // Key-value pair - const kvMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/); - if (kvMatch && currentSection) { - const key = kvMatch[1]; - const value = kvMatch[2]; - - config._setValue(currentSection, key, value, currentAddress, currentRoute); - } - } - - return config; - } - - /** - * Set configuration value - * @private - * @param {string} section - Section name - * @param {string} key - Key name - * @param {string} value - Value - * @param {AddressSection} currentAddress - Current address section - * @param {RouteSection} currentRoute - Current route section - */ - _setValue(section, key, value, currentAddress, currentRoute) { - switch (section) { - case 'match': - this._setMatchValue(key, value); - break; - case 'link': - this._setLinkValue(key, value); - break; - case 'network': - this._setNetworkValue(key, value); - break; - case 'dhcp': - this._setDHCPValue(key, value); - break; - case 'address': - if (currentAddress) { - this._setAddressValue(currentAddress, key, value); - } - break; - case 'route': - if (currentRoute) { - this._setRouteValue(currentRoute, key, value); - } - break; - } - } - - _setMatchValue(key, value) { - if (this.Match[key] !== undefined) { - this.Match[key].value = value; - } - } - - _setLinkValue(key, value) { - if (this.Link[key] !== undefined) { - this.Link[key].value = value; - } - } - - _setNetworkValue(key, value) { - if (this.Network[key] !== undefined) { - this.Network[key].value = value; - } - } - - _setDHCPValue(key, value) { - if (this.DHCP[key] !== undefined) { - this.DHCP[key].value = value; - } - } - - _setAddressValue(address, key, value) { - if (address[key] !== undefined) { - address[key].value = value; - } - } - - _setRouteValue(route, key, value) { - if (route[key] !== undefined) { - route[key].value = value; - } - } - - /** - * Convert to systemd network configuration format - * @returns {string} - */ - toSystemdConfiguration() { - const sections = []; - - // [Match] section - if (this._hasSectionValues(this.Match)) { - sections.push('[Match]'); - sections.push(...this._formatSection(this.Match)); - } - - // [Link] section - if (this._hasSectionValues(this.Link)) { - sections.push('[Link]'); - sections.push(...this._formatSection(this.Link)); - } - - // [Network] section - if (this._hasSectionValues(this.Network)) { - sections.push('[Network]'); - sections.push(...this._formatSection(this.Network)); - } - - // [DHCP] section - if (this._hasSectionValues(this.DHCP)) { - sections.push('[DHCP]'); - sections.push(...this._formatSection(this.DHCP)); - } - - // [Address] sections - this.Address.forEach(addr => { - if (this._hasSectionValues(addr)) { - sections.push('[Address]'); - sections.push(...this._formatSection(addr)); - } - }); - - // [Route] sections - this.Route.forEach(route => { - if (this._hasSectionValues(route)) { - sections.push('[Route]'); - sections.push(...this._formatSection(route)); - } - }); - - return `${sections.join('\n')}\n`; - } - - _hasSectionValues(section) { - return Object.values(section).some(field => - field.value !== null && field.value !== undefined && field.value !== '' - ); - } - - _formatSection(section) { - const lines = []; - for (const [key, field] of Object.entries(section)) { - if (field.value !== null && field.value !== undefined && field.value !== '') { - lines.push(`${key}=${field.toString()}`); - } - } - return lines; - } + #match; + #link; + #network; + #dhcp; + #dhcpv4; + #dhcpv6; + #ipv6AcceptRA; + #slaac; + #address; + #route; + + constructor() { + this.#match = new MatchSection(); + this.#link = new LinkSection(); + this.#network = new NetworkSection(); + this.#dhcp = new DHCPSection(); + this.#dhcpv4 = new DHCPv4Section(); + this.#dhcpv6 = new DHCPv6Section(); + this.#ipv6AcceptRA = new IPv6AcceptRASection(); + this.#slaac = new SLAACSection(); + this.#address = []; + this.#route = []; + } + + /** + * Get Match section + * @returns {MatchSection} + */ + get Match() { + return this.#match; + } + + /** + * Get Link section + * @returns {LinkSection} + */ + get Link() { + return this.#link; + } + + /** + * Get Network section + * @returns {NetworkSection} + */ + get Network() { + return this.#network; + } + + /** + * Get DHCP section + * @returns {DHCPSection} + */ + get DHCP() { + return this.#dhcp; + } + + /** + * Get DHCPv4 section + * @returns {DHCPv4Section} + */ + get DHCPv4() { + return this.#dhcpv4; + } + + /** + * Get DHCPv6 section + * @returns {DHCPv6Section} + */ + get DHCPv6() { + return this.#dhcpv6; + } + + /** + * Get IPv6AcceptRA section + * @returns {IPv6AcceptRASection} + */ + get IPv6AcceptRA() { + return this.#ipv6AcceptRA; + } + + /** + * Get SLAAC section + * @returns {SLAACSection} + */ + get SLAAC() { + return this.#slaac; + } + + /** + * Get Address sections + * @returns {Array<AddressSection>} + */ + get Address() { + return [...this.#address]; + } + + /** + * Get Route sections + * @returns {Array<RouteSection>} + */ + get Route() { + return [...this.#route]; + } + + /** + * Get schema for structured editor + * @returns {Object} Schema definition + */ + getSchema() { + return { + Match: this.#getSectionSchema(this.#match), + Link: this.#getSectionSchema(this.#link), + Network: this.#getSectionSchema(this.#network), + DHCP: this.#getSectionSchema(this.#dhcp), + DHCPv4: this.#getSectionSchema(this.#dhcpv4), + DHCPv6: this.#getSectionSchema(this.#dhcpv6), + IPv6AcceptRA: this.#getSectionSchema(this.#ipv6AcceptRA), + SLAAC: this.#getSectionSchema(this.#slaac), + Address: this.#getArraySectionSchema(AddressSection, "Address"), + Route: this.#getArraySectionSchema(RouteSection, "Route"), + }; + } + + /** + * Get schema for a single section + * @private + * @param {Object} section - Section instance + * @returns {Object} Section schema + */ + #getSectionSchema(section) { + const schema = {}; + for (const [key, field] of Object.entries(section)) { + schema[key] = { + value: field.value, + type: field.type, + description: field.description, + options: field.options, + }; + } + return schema; + } + + /** + * Get schema for array sections (Address, Route) + * @private + * @param {Class} SectionClass - Section class + * @param {string} sectionName - Section name + * @returns {Object} Array section schema + */ + #getArraySectionSchema(SectionClass, sectionName) { + const template = new SectionClass(); + return { + itemSchema: this.#getSectionSchema(template), + items: this[sectionName].map((item) => this.#getSectionSchema(item)), + }; + } + + /** + * Parse systemd network configuration from text + * @static + * @param {string} configText - Configuration file content + * @returns {NetworkConfiguration} + */ + static fromSystemdConfiguration(configText) { + const config = new NetworkConfiguration(); + const lines = configText.split("\n"); + let currentSection = null; + let currentAddress = null; + let currentRoute = null; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith("#")) continue; + + // Section header + const sectionMatch = trimmed.match(/^\[(\w+)\]$/); + if (sectionMatch) { + currentSection = sectionMatch[1].toLowerCase(); + + // Start new array sections + if (currentSection === "address") { + currentAddress = new AddressSection(); + config.#address.push(currentAddress); + } else if (currentSection === "route") { + currentRoute = new RouteSection(); + config.#route.push(currentRoute); + } + continue; + } + + // Key-value pair + const kvMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/); + if (kvMatch && currentSection) { + const key = kvMatch[1]; + const value = kvMatch[2]; + + config.#setValue( + currentSection, + key, + value, + currentAddress, + currentRoute, + ); + } + } + + return config; + } + + /** + * Set configuration value + * @private + * @param {string} section - Section name + * @param {string} key - Key name + * @param {string} value - Value + * @param {AddressSection} currentAddress - Current address section + * @param {RouteSection} currentRoute - Current route section + */ + #setValue(section, key, value, currentAddress, currentRoute) { + switch (section) { + case "match": + this.#setMatchValue(key, value); + break; + case "link": + this.#setLinkValue(key, value); + break; + case "network": + this.#setNetworkValue(key, value); + break; + case "dhcp": + this.#setDHCPValue(key, value); + break; + case "dhcpv4": + this.#setDHCPv4Value(key, value); + break; + case "dhcpv6": + this.#setDHCPv6Value(key, value); + break; + case "ipv6acceptra": + this.#setIPv6AcceptRAValue(key, value); + break; + case "slaac": + this.#setSLAACValue(key, value); + break; + case "address": + if (currentAddress) { + this.#setAddressValue(currentAddress, key, value); + } + break; + case "route": + if (currentRoute) { + this.#setRouteValue(currentRoute, key, value); + } + break; + } + } + + #setMatchValue(key, value) { + if (this.#match[key] !== undefined) { + this.#match[key].value = value; + } + } + + #setLinkValue(key, value) { + if (this.#link[key] !== undefined) { + this.#link[key].value = value; + } + } + + #setNetworkValue(key, value) { + if (this.#network[key] !== undefined) { + this.#network[key].value = value; + } + } + + #setDHCPValue(key, value) { + if (this.#dhcp[key] !== undefined) { + this.#dhcp[key].value = value; + } + } + + #setDHCPv4Value(key, value) { + if (this.#dhcpv4[key] !== undefined) { + this.#dhcpv4[key].value = value; + } + } + + #setDHCPv6Value(key, value) { + if (this.#dhcpv6[key] !== undefined) { + this.#dhcpv6[key].value = value; + } + } + + #setIPv6AcceptRAValue(key, value) { + if (this.#ipv6AcceptRA[key] !== undefined) { + this.#ipv6AcceptRA[key].value = value; + } + } + + #setSLAACValue(key, value) { + if (this.#slaac[key] !== undefined) { + this.#slaac[key].value = value; + } + } + + #setAddressValue(address, key, value) { + if (address[key] !== undefined) { + address[key].value = value; + } + } + + #setRouteValue(route, key, value) { + if (route[key] !== undefined) { + route[key].value = value; + } + } + + /** + * Convert to systemd network configuration format + * @returns {string} + */ + toSystemdConfiguration() { + const sections = []; + + // [Match] section + if (this.#hasSectionValues(this.#match)) { + sections.push("[Match]"); + sections.push(...this.#formatSection(this.#match)); + } + + // [Link] section + if (this.#hasSectionValues(this.#link)) { + sections.push("[Link]"); + sections.push(...this.#formatSection(this.#link)); + } + + // [Network] section + if (this.#hasSectionValues(this.#network)) { + sections.push("[Network]"); + sections.push(...this.#formatSection(this.#network)); + } + + // [DHCP] section + if (this.#hasSectionValues(this.#dhcp)) { + sections.push("[DHCP]"); + sections.push(...this.#formatSection(this.#dhcp)); + } + + // [DHCPv4] section + if (this.#hasSectionValues(this.#dhcpv4)) { + sections.push("[DHCPv4]"); + sections.push(...this.#formatSection(this.#dhcpv4)); + } + + // [DHCPv6] section + if (this.#hasSectionValues(this.#dhcpv6)) { + sections.push("[DHCPv6]"); + sections.push(...this.#formatSection(this.#dhcpv6)); + } + + // [IPv6AcceptRA] section + if (this.#hasSectionValues(this.#ipv6AcceptRA)) { + sections.push("[IPv6AcceptRA]"); + sections.push(...this.#formatSection(this.#ipv6AcceptRA)); + } + + // [SLAAC] section + if (this.#hasSectionValues(this.#slaac)) { + sections.push("[SLAAC]"); + sections.push(...this.#formatSection(this.#slaac)); + } + + // [Address] sections + this.#address.forEach((addr) => { + if (this.#hasSectionValues(addr)) { + sections.push("[Address]"); + sections.push(...this.#formatSection(addr)); + } + }); + + // [Route] sections + this.#route.forEach((route) => { + if (this.#hasSectionValues(route)) { + sections.push("[Route]"); + sections.push(...this.#formatSection(route)); + } + }); + + return sections.length > 0 ? `${sections.join("\n")}\n` : ""; + } + + #hasSectionValues(section) { + return Object.values(section).some((field) => field.hasValue()); + } + + #formatSection(section) { + const lines = []; + for (const [key, field] of Object.entries(section)) { + if (field.hasValue()) { + lines.push(`${key}=${field.toString()}`); + } + } + return lines; + } } // Export classes export { - BaseField, - MACAddress, - IPv4Address, - IPv6Address, - BooleanYesNo, - Port, - MTU, - MatchSection, - LinkSection, - NetworkSection, - DHCPSection, - AddressSection, - RouteSection, - NetworkConfiguration + BaseField, + MACAddress, + IPv4Address, + IPv6Address, + BooleanYesNo, + Port, + MTU, + MatchSection, + LinkSection, + NetworkSection, + DHCPSection, + DHCPv4Section, + DHCPv6Section, + IPv6AcceptRASection, + SLAACSection, + AddressSection, + RouteSection, + NetworkConfiguration, }; diff --git a/static/theme-manager.js b/static/theme-manager.js index 2ad080b..14e1469 100644 --- a/static/theme-manager.js +++ b/static/theme-manager.js @@ -1,59 +1,177 @@ /* jshint esversion: 2024, module: true */ +import { ThemeMode } from "./enums.js"; + +/** + * Static Theme Utilities + * @class ThemeUtils + */ +class ThemeUtils { + /** + * Get theme from localStorage + * @static + * @returns {Symbol} Theme mode + */ + static getStoredTheme() { + const stored = localStorage.getItem("network-ui-theme"); + return stored === "light" ? ThemeMode.LIGHT : ThemeMode.DARK; + } + + /** + * Store theme in localStorage + * @static + * @param {Symbol} theme - Theme mode + */ + static storeTheme(theme) { + const themeString = theme === ThemeMode.LIGHT ? "light" : "dark"; + localStorage.setItem("network-ui-theme", themeString); + } + + /** + * Get theme icon based on theme mode + * @static + * @param {Symbol} theme - Theme mode + * @returns {string} Icon character + */ + static getThemeIcon(theme) { + return theme === ThemeMode.DARK ? "☀️" : "🌙"; + } + + /** + * Apply theme to document + * @static + * @param {Symbol} theme - Theme mode + */ + static applyThemeToDocument(theme) { + const themeString = theme === ThemeMode.LIGHT ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", themeString); + } + + /** + * Toggle theme mode + * @static + * @param {Symbol} currentTheme - Current theme mode + * @returns {Symbol} New theme mode + */ + static toggleTheme(currentTheme) { + return currentTheme === ThemeMode.DARK ? ThemeMode.LIGHT : ThemeMode.DARK; + } +} + /** * 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' ? '☀️' : '🌙'; - } - } + #elements; + #theme; + + /** + * @param {Object} elements - DOM elements + */ + constructor(elements) { + this.#elements = elements; + this.#theme = ThemeMode.DARK; + } + + /** + * Initialize theme manager + * @method init + */ + init() { + this.#theme = ThemeUtils.getStoredTheme(); + this.#applyTheme(this.#theme); + this.#setupEventListeners(); + } + + /** + * Set up theme event listeners + * @private + */ + #setupEventListeners() { + this.#elements.themeToggle?.addEventListener("click", () => + this.#handleThemeToggle(), + ); + } + + /** + * Handle theme toggle + * @private + */ + #handleThemeToggle() { + const newTheme = ThemeUtils.toggleTheme(this.#theme); + this.#applyTheme(newTheme); + } + + /** + * Apply theme to UI + * @private + * @param {Symbol} theme - Theme mode + */ + #applyTheme(theme) { + this.#theme = theme; + + // Apply to document + ThemeUtils.applyThemeToDocument(theme); + + // Store preference + ThemeUtils.storeTheme(theme); + + // Update theme icon + this.#updateThemeIcon(); + } + + /** + * Update theme icon + * @private + */ + #updateThemeIcon() { + if (this.#elements.themeIcon) { + this.#elements.themeIcon.textContent = ThemeUtils.getThemeIcon( + this.#theme, + ); + } + } + + /** + * Get current theme + * @method getCurrentTheme + * @returns {Symbol} Current theme mode + */ + getCurrentTheme() { + return this.#theme; + } + + /** + * Set theme programmatically + * @method setTheme + * @param {Symbol} theme - Theme mode + */ + setTheme(theme) { + if (theme === ThemeMode.LIGHT || theme === ThemeMode.DARK) { + this.#applyTheme(theme); + } else { + console.warn("Invalid theme mode:", theme); + } + } + + /** + * Check if dark theme is active + * @method isDarkTheme + * @returns {boolean} True if dark theme is active + */ + isDarkTheme() { + return this.#theme === ThemeMode.DARK; + } + + /** + * Check if light theme is active + * @method isLightTheme + * @returns {boolean} True if light theme is active + */ + isLightTheme() { + return this.#theme === ThemeMode.LIGHT; + } } -export { ThemeManager }; +export { ThemeManager, ThemeUtils }; diff --git a/static/utils.js b/static/utils.js index 2b0b816..32b10be 100644 --- a/static/utils.js +++ b/static/utils.js @@ -1,155 +1,173 @@ /* jshint esversion: 2024, module: true */ -import { InterfaceState } from './enums.js'; +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); - }; - } + /** + * 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 +export { Utils }; |
