diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-09-28 14:15:46 +0300 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-09-28 14:15:46 +0300 |
| commit | 645dd2b87927b7efadedb6dd5232d36a7800ce28 (patch) | |
| tree | a510862ef65f5fe0e44624751dd47e66b582b9ed | |
| parent | b0c76dcc159ead3d67314da3a71d60bad9385991 (diff) | |
| download | network-645dd2b87927b7efadedb6dd5232d36a7800ce28.tar.zst | |
Structured editor works
| -rw-r--r-- | static/app.js | 605 | ||||
| -rw-r--r-- | static/config-manager.js | 50 | ||||
| -rw-r--r-- | static/index.html | 5 | ||||
| -rw-r--r-- | static/structured-editor.js | 468 | ||||
| -rw-r--r-- | static/styles.css | 67 |
5 files changed, 764 insertions, 431 deletions
diff --git a/static/app.js b/static/app.js index 8a04c45..ca205f3 100644 --- a/static/app.js +++ b/static/app.js @@ -1,234 +1,399 @@ /* jshint esversion: 2024, module: true */ -import { ApiClient } from "./api-client.js"; -import { ConfigManager } from "./config-manager.js"; -import { InterfaceRenderer } from "./interface-renderer.js"; -import { ThemeManager } from "./theme-manager.js"; +import { ThemeManager } from './theme-manager.js'; +import { ApiClient } from './api-client.js'; +import { InterfaceRenderer } from './interface-renderer.js'; +import { ConfigManager } from './config-manager.js'; +import { StructuredEditor } from './structured-editor.js'; /** * Main Application Class * @class Application */ class Application { - /** - * @param {Object} elements - DOM elements - */ - constructor(elements) { - this.elements = elements; - this.state = { - currentInterface: null, - interfaces: [], - editorMode: "raw", - }; - - // Initialize modules - this.themeManager = new ThemeManager(elements); - this.apiClient = new ApiClient(); - this.interfaceRenderer = new InterfaceRenderer(elements, this.state); - this.configManager = new ConfigManager( - elements, - this.apiClient, - this.state, - ); - } - - /** - * Initialize the application - * @method init - */ - init() { - this.themeManager.init(); - this.setupEventListeners(); - this.loadStatus(); - } - - /** - * Set up all event listeners - * @method setupEventListeners - */ - setupEventListeners() { - // Navigation - this.elements.buttons.nav.forEach((button) => { - button.addEventListener("click", (event) => { - this.show(event.currentTarget.dataset.panel); - }); - }); - - // Status panel - this.elements.buttons.refreshStatus?.addEventListener("click", () => - this.loadStatus(), - ); - - // Configs panel - delegated to ConfigManager - this.configManager.setupEventListeners(); - - // Logs panel - this.elements.buttons.refreshLogs?.addEventListener("click", () => - this.loadLogs(), - ); - - // Commands panel - this.elements.buttons.restartNetworkd?.addEventListener("click", () => - this.restartNetworkd(), - ); - this.elements.buttons.rebootDevice?.addEventListener("click", () => - this.rebootDevice(), - ); - - // Touch support - document.addEventListener("touchstart", this.handleTouchStart, { - passive: true, - }); - } - - /** - * Handle touch events for better mobile support - * @method handleTouchStart - * @param {TouchEvent} event - */ - handleTouchStart = (event) => { - // Add visual feedback for touch - if ( - event.target.classList.contains("button") || - event.target.classList.contains("nav-button") - ) { - event.target.style.opacity = "0.7"; - setTimeout(() => { - event.target.style.opacity = ""; - }, 150); - } - }; - - /** - * Show specified panel and hide others - * @method show - * @param {string} panel - Panel to show - */ - show(panel) { - // Hide all panels and remove active class from buttons - Object.values(this.elements.panels).forEach((p) => - p?.classList.remove("active"), - ); - this.elements.buttons.nav.forEach((btn) => btn?.classList.remove("active")); - - // Show selected panel and activate button - this.elements.panels[panel]?.classList.add("active"); - document.querySelector(`[data-panel="${panel}"]`)?.classList.add("active"); - - // Load panel-specific data - const panelActions = { - status: () => this.loadStatus(), - configs: () => this.configManager.refreshConfigs(), - logs: () => this.loadLogs(), - }; - - panelActions[panel]?.(); - } - - /** - * Load and display network status - * @method loadStatus - */ - async loadStatus() { - try { - const data = await this.apiClient.get("/api/status"); - this.state.interfaces = data.Interfaces ?? []; - this.interfaceRenderer.renderInterfaceTabs(this.state.interfaces); - - // Show first interface by default - if (this.state.interfaces.length > 0 && !this.state.currentInterface) { - this.interfaceRenderer.showInterfaceDetails(this.state.interfaces[0]); - } - } catch (error) { - this.elements.outputs.ifaceDetails.innerHTML = `<div class="error-message">Error loading status: ${error.message}</div>`; - } - } - - /** - * Load system logs - * @method loadLogs - */ - async loadLogs() { - try { - const text = await this.apiClient.getText("/api/logs"); - this.elements.outputs.logsArea.textContent = text; - } catch (error) { - this.elements.outputs.logsArea.textContent = `Error: ${error.message}`; - } - } - - /** - * Restart networkd service - * @method restartNetworkd - */ - async restartNetworkd() { - if (!confirm("Restart systemd-networkd? Active connections may be reset.")) - return; - - try { - const result = await this.apiClient.post("/api/reload"); - this.elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; - } catch (error) { - this.elements.outputs.cmdResult.textContent = `Error: ${error.message}`; - } - } - - /** - * Reboot the device - * @method rebootDevice - */ - async rebootDevice() { - if (!confirm("Reboot device now?")) return; - - try { - const result = await this.apiClient.post("/api/reboot"); - this.elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; - } catch (error) { - this.elements.outputs.cmdResult.textContent = `Error: ${error.message}`; - } - } + /** + * @param {Object} elements - DOM elements + */ + constructor(elements) { + this.elements = elements; + this.state = { + currentInterface: null, + interfaces: [], + editorMode: 'raw', // 'raw' or 'structured' + currentConfigFile: null + }; + + // Initialize modules + this.themeManager = new ThemeManager(elements); + this.apiClient = new ApiClient(); + this.interfaceRenderer = new InterfaceRenderer(elements, this.state); + this.configManager = new ConfigManager(elements, this.apiClient, this.state); + + // Structured editor will be initialized after DOM is ready + this.structuredEditor = null; + + // Create editor mode toggle UI + this.createEditorModeToggle(); + } + + /** + * Initialize the application + * @method init + */ + init() { + this.themeManager.init(); + this.setupEventListeners(); + this.loadStatus(); + + // Initialize structured editor now that DOM is ready + this.initializeStructuredEditor(); + } + + /** + * Create editor mode toggle UI + * @method createEditorModeToggle + */ + createEditorModeToggle() { + const editorToggleHTML = ` + <div class="editor-mode-toggle" style="margin-bottom: var(--spacing-l);"> + <button class="button small ${this.state.editorMode === 'raw' ? 'active' : ''}" + data-mode="raw" id="rawEditorBtn"> + 📝 Raw Editor + </button> + <button class="button small ${this.state.editorMode === 'structured' ? 'active' : ''}" + data-mode="structured" id="structuredEditorBtn"> + 🏗️ Structured Editor + </button> + </div> + <div id="rawEditorContainer"> + <!-- Existing raw editor will go here --> + </div> + <div id="structuredEditorContainer" style="display: none;"> + <!-- Structured editor will be rendered here --> + </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.configManager.refreshConfigs()); + this.elements.buttons.saveConfig?.addEventListener('click', + () => this.handleSaveConfig()); + this.elements.buttons.validateConfig?.addEventListener('click', + () => this.configManager.validateConfig()); + this.elements.inputs.configSelect?.addEventListener('change', + () => this.handleConfigFileChange()); + + // Editor mode toggle + this.elements.editorButtons?.raw?.addEventListener('click', + () => this.setEditorMode('raw')); + this.elements.editorButtons?.structured?.addEventListener('click', + () => this.setEditorMode('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 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 === 'raw') { + await this.configManager.loadConfig(); + } else { + await this.loadConfigForStructuredEditor(); + } + } + + /** + * Load config for structured editor + * @method loadConfigForStructuredEditor + */ + async loadConfigForStructuredEditor() { + if (!this.structuredEditor) { + console.error('Structured editor not initialized'); + return; + } + + try { + const text = await this.apiClient.getText(`/api/config/${encodeURIComponent(this.state.currentConfigFile)}`); + await this.structuredEditor.loadConfiguration(text, this.state.currentConfigFile); + } catch (error) { + alert(`Failed to load config for structured editor: ${error.message}`); + } + } + + /** + * Set editor mode (raw or structured) + * @method setEditorMode + * @param {string} mode - Editor mode + */ + setEditorMode(mode) { + this.state.editorMode = mode; + + // Update UI + if (this.elements.editorButtons?.raw && this.elements.editorButtons?.structured) { + this.elements.editorButtons.raw.classList.toggle('active', mode === 'raw'); + this.elements.editorButtons.structured.classList.toggle('active', mode === 'structured'); + } + + if (this.elements.editorContainers?.raw && this.elements.editorContainers?.structured) { + this.elements.editorContainers.raw.style.display = mode === 'raw' ? 'block' : 'none'; + this.elements.editorContainers.structured.style.display = mode === 'structured' ? 'block' : 'none'; + } + + // If switching to structured mode and we have a config file loaded, load it + if (mode === '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 === '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 this.apiClient.post('/api/save', { name, content, restart }); + alert(`Saved: ${result.status ?? 'ok'}`); + + // Refresh the config in structured editor if needed + if (this.state.editorMode === 'structured' && this.structuredEditor) { + await this.structuredEditor.loadConfiguration(content, name); + } + } 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.configManager.refreshConfigs(), + logs: () => this.loadLogs(), + }; + + panelActions[panel]?.(); + } + + /** + * Load and display network status + * @method loadStatus + */ + async loadStatus() { + try { + const data = await this.apiClient.get('/api/status'); + this.state.interfaces = data.Interfaces ?? []; + this.interfaceRenderer.renderInterfaceTabs(this.state.interfaces); + + // Show first interface by default + if (this.state.interfaces.length > 0 && !this.state.currentInterface) { + this.interfaceRenderer.showInterfaceDetails(this.state.interfaces[0]); + } + } catch (error) { + this.elements.outputs.ifaceDetails.innerHTML = + `<div class="error-message">Error loading status: ${error.message}</div>`; + } + } + + /** + * Load system logs + * @method loadLogs + */ + async loadLogs() { + try { + const text = await this.apiClient.getText('/api/logs'); + this.elements.outputs.logsArea.textContent = text; + } catch (error) { + this.elements.outputs.logsArea.textContent = `Error: ${error.message}`; + } + } + + /** + * Restart networkd service + * @method restartNetworkd + */ + async restartNetworkd() { + if (!confirm('Restart systemd-networkd? Active connections may be reset.')) return; + + try { + const result = await this.apiClient.post('/api/reload'); + this.elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; + } catch (error) { + this.elements.outputs.cmdResult.textContent = `Error: ${error.message}`; + } + } + + /** + * Reboot the device + * @method rebootDevice + */ + async rebootDevice() { + if (!confirm('Reboot device now?')) return; + + try { + const result = await this.apiClient.post('/api/reboot'); + this.elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; + } catch (error) { + this.elements.outputs.cmdResult.textContent = `Error: ${error.message}`; + } + } } // Initialize application when DOM is loaded -document.addEventListener("DOMContentLoaded", () => { - const elements = { - themeToggle: document.getElementById("themeToggle"), - themeIcon: document.getElementById("themeIcon"), - panels: { - status: document.getElementById("panelStatus"), - configs: document.getElementById("panelConfigs"), - logs: document.getElementById("panelLogs"), - commands: document.getElementById("panelCommands"), - }, - buttons: { - nav: document.querySelectorAll(".nav-button"), - refreshStatus: document.getElementById("refreshStatus"), - refreshConfigs: document.getElementById("refreshConfigs"), - saveConfig: document.getElementById("saveConfig"), - validateConfig: document.getElementById("validateConfig"), - refreshLogs: document.getElementById("refreshLogs"), - restartNetworkd: document.getElementById("restartNetworkd"), - rebootDevice: document.getElementById("rebootDevice"), - }, - inputs: { - configSelect: document.getElementById("configSelect"), - cfgEditor: document.getElementById("cfgEditor"), - restartAfterSave: document.getElementById("restartAfterSave"), - }, - outputs: { - ifaceTabs: document.getElementById("interfaceTabs"), - ifaceDetails: document.getElementById("interfaceDetails"), - validateResult: document.getElementById("validateResult"), - logsArea: document.getElementById("logsArea"), - cmdResult: document.getElementById("cmdResult"), - }, - }; - - const app = new Application(elements); - app.init(); - - // Make app globally available for debugging - window.app = app; +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 8dc629b..ebaf9f2 100644 --- a/static/config-manager.js +++ b/static/config-manager.js @@ -17,34 +17,25 @@ class ConfigManager { } /** - * Set up configuration event listeners - * @method setupEventListeners - */ - setupEventListeners() { - this.elements.buttons.refreshConfigs?.addEventListener('click', () => this.refreshConfigs()); - this.elements.buttons.saveConfig?.addEventListener('click', () => this.saveConfig()); - this.elements.buttons.validateConfig?.addEventListener('click', () => this.validateConfig()); - this.elements.inputs.configSelect?.addEventListener('change', () => this.loadConfig()); - } - - /** * Refresh configuration file list * @method refreshConfigs */ async refreshConfigs() { try { const data = await this.apiClient.get('/api/configs'); - + this.elements.inputs.configSelect.innerHTML = ''; data.files?.forEach(file => { const option = new Option(file, file); this.elements.inputs.configSelect.add(option); }); - + if (data.files?.length > 0) { + this.state.currentConfigFile = data.files[0]; await this.loadConfig(); } else { this.elements.inputs.cfgEditor.value = ''; + this.state.currentConfigFile = null; } } catch (error) { alert(`Failed to list configs: ${error.message}`); @@ -58,7 +49,9 @@ class ConfigManager { async loadConfig() { const name = this.elements.inputs.configSelect.value; if (!name) return; - + + this.state.currentConfigFile = name; + try { const text = await this.apiClient.getText(`/api/config/${encodeURIComponent(name)}`); this.elements.inputs.cfgEditor.value = text; @@ -75,13 +68,13 @@ class ConfigManager { async validateConfig() { const name = this.elements.inputs.configSelect.value; const content = this.elements.inputs.cfgEditor.value; - + this.elements.outputs.validateResult.textContent = 'Validating...'; this.elements.outputs.validateResult.className = 'validation-pending'; - + try { const result = await this.apiClient.post('/api/validate', { name, content }); - + if (result.ok) { this.elements.outputs.validateResult.textContent = '✓ Configuration is valid'; this.elements.outputs.validateResult.className = 'validation-success'; @@ -94,27 +87,6 @@ class ConfigManager { this.elements.outputs.validateResult.className = 'validation-error'; } } - - /** - * Save current configuration - * @method saveConfig - */ - async saveConfig() { - const name = this.elements.inputs.configSelect.value; - const content = this.elements.inputs.cfgEditor.value; - const restart = this.elements.inputs.restartAfterSave.checked; - - if (!confirm(`Save file ${name}? This will create a backup and ${restart ? 'restart' : 'not restart'} networkd.`)) { - return; - } - - try { - const result = await this.apiClient.post('/api/save', { name, content, restart }); - alert(`Saved: ${result.status ?? 'ok'}`); - } catch (error) { - alert(`Save failed: ${error.message}`); - } - } } -export { ConfigManager }; +export { ConfigManager };
\ No newline at end of file diff --git a/static/index.html b/static/index.html index 0e87967..5bea6f8 100644 --- a/static/index.html +++ b/static/index.html @@ -79,11 +79,13 @@ </div> <div class="card"> + <!-- Editor mode toggle and containers will be inserted here by JavaScript --> <div class="form-group"> <label class="form-label">Configuration File</label> <select id="configSelect" class="select"></select> </div> + <!-- Raw editor elements (will be moved to rawEditorContainer by JavaScript) --> <div class="form-group"> <label class="form-label">File Contents</label> <textarea id="cfgEditor" class="textarea" spellcheck="false"></textarea> @@ -96,7 +98,7 @@ </label> </div> - <div style="display: flex; gap: var(--spacing-s); align-items: center;"> + <div class="config-actions"> <button class="button secondary" id="validateConfig"> Validate Configuration </button> @@ -105,6 +107,7 @@ </div> </section> + <section id="panelLogs"> <div class="card"> <div class="card-header"> diff --git a/static/structured-editor.js b/static/structured-editor.js index dc46bef..4de9217 100644 --- a/static/structured-editor.js +++ b/static/structured-editor.js @@ -2,44 +2,54 @@ /** * Structured Editor for systemd-networkd configuration - * @module StructuredEditor + * @class StructuredEditor */ +class StructuredEditor { + constructor(container) { + this.container = container; + this.config = null; + this.currentFile = ""; + this.systemdNetworkModule = null; + } -import { NetworkConfiguration } from './systemd-network.js'; + /** + * Load configuration from text + * @param {string} configText - Configuration text + * @param {string} filename - File name + */ + async loadConfiguration(configText, filename) { + // Dynamically import the systemd-network module + if (!this.systemdNetworkModule) { + this.systemdNetworkModule = await import("./systemd-network.js"); + } -class StructuredEditor { - constructor(container) { - this.container = container; - this.config = new NetworkConfiguration(); - this.currentFile = ''; - } - - /** - * Load configuration from text - * @param {string} configText - Configuration text - * @param {string} filename - File name - */ - loadConfiguration(configText, filename) { - this.config = NetworkConfiguration.fromSystemdConfiguration(configText); - this.currentFile = filename; - this.render(); - } - - /** - * Render the structured editor - */ - render() { - this.container.innerHTML = this._createEditorHTML(); - this._attachEventListeners(); - } - - /** - * Create editor HTML structure - * @private - * @returns {string} - */ - _createEditorHTML() { - return ` + const { NetworkConfiguration } = this.systemdNetworkModule; + this.config = NetworkConfiguration.fromSystemdConfiguration(configText); + this.currentFile = filename; + this.render(); + } + + /** + * Render the structured editor + */ + render() { + if (!this.config) { + this.container.innerHTML = + '<div class="error-message">No configuration loaded</div>'; + return; + } + + this.container.innerHTML = this._createEditorHTML(); + this._attachEventListeners(); + } + + /** + * Create editor HTML structure + * @private + * @returns {string} + */ + _createEditorHTML() { + return ` <div class="structured-editor"> <div class="editor-sections"> ${this._createMatchSection()} @@ -50,204 +60,320 @@ class StructuredEditor { ${this._createRouteSections()} </div> <div class="editor-actions"> - <button class="button" id="addAddressSection">Add Address</button> - <button class="button" id="addRouteSection">Add Route</button> - <button class="button secondary" id="showRawConfig">Show Raw</button> + <button class="button secondary" id="addAddressSection">Add Address</button> + <button class="button secondary" id="addRouteSection">Add Route</button> </div> </div> `; - } + } - _createMatchSection() { - const match = this.config.Match; - return ` + _createMatchSection() { + const match = this.config.Match; + return ` <div class="config-section"> <h4>[Match]</h4> <div class="config-table"> - ${this._createInputRow('MACAddress', match.MACAddress.join(' '), 'Space-separated MAC addresses')} - ${this._createInputRow('Name', match.Name.join(' '), 'Interface names')} - ${this._createInputRow('Driver', match.Driver.join(' '), 'Driver names')} - ${this._createInputRow('Type', match.Type.join(' '), 'Interface types')} + ${this._createInputRow("MACAddress", match.MACAddress?.join(" ") || "", "Space-separated MAC addresses")} + ${this._createInputRow("Name", match.Name?.join(" ") || "", "Interface names")} + ${this._createInputRow("Driver", match.Driver?.join(" ") || "", "Driver names")} + ${this._createInputRow("Type", match.Type?.join(" ") || "", "Interface types")} </div> </div> `; - } + } - _createLinkSection() { - const link = this.config.Link; - return ` + _createLinkSection() { + const link = this.config.Link; + return ` <div class="config-section"> <h4>[Link]</h4> <div class="config-table"> - ${this._createInputRow('MACAddress', link.MACAddress, 'Hardware address')} - ${this._createInputRow('MTUBytes', link.MTUBytes, 'Maximum transmission unit')} - ${this._createSelectRow('WakeOnLan', link.WakeOnLan, ['', 'phy', 'unicast', 'broadcast', 'arp', 'magic'], 'Wake-on-LAN')} + ${this._createInputRow("MACAddress", link.MACAddress || "", "Hardware address")} + ${this._createInputRow("MTUBytes", link.MTUBytes || "", "Maximum transmission unit")} + ${this._createSelectRow("WakeOnLan", link.WakeOnLan || "", ["", "phy", "unicast", "broadcast", "arp", "magic"], "Wake-on-LAN")} </div> </div> `; - } + } - _createNetworkSection() { - const network = this.config.Network; - return ` + _createNetworkSection() { + const network = this.config.Network; + return ` <div class="config-section"> <h4>[Network]</h4> <div class="config-table"> - ${this._createInputRow('Description', network.Description, 'Interface description')} - ${this._createSelectRow('DHCP', network.DHCP.join(' '), ['', 'yes', 'no', 'ipv4', 'ipv6'], 'DHCP client')} - ${this._createInputRow('DNS', network.DNS.join(' '), 'DNS servers')} - ${this._createInputRow('NTP', network.NTP.join(' '), 'NTP servers')} - ${this._createSelectRow('IPv6PrivacyExtensions', network.IPv6PrivacyExtensions, ['', 'yes', 'no', 'prefer-public'], 'IPv6 privacy extensions')} + ${this._createInputRow("Description", network.Description || "", "Interface description")} + ${this._createSelectRow("DHCP", network.DHCP?.join(" ") || "", ["", "yes", "no", "ipv4", "ipv6"], "DHCP client")} + ${this._createInputRow("DNS", network.DNS?.join(" ") || "", "DNS servers")} + ${this._createInputRow("NTP", network.NTP?.join(" ") || "", "NTP servers")} + ${this._createSelectRow("IPv6PrivacyExtensions", network.IPv6PrivacyExtensions || "", ["", "yes", "no", "prefer-public"], "IPv6 privacy extensions")} </div> </div> `; - } + } - _createDHCPSection() { - const dhcp = this.config.DHCP; - return ` + _createDHCPSection() { + const dhcp = this.config.DHCP; + return ` <div class="config-section"> <h4>[DHCP]</h4> <div class="config-table"> - ${this._createSelectRow('UseDNS', dhcp.UseDNS, ['', 'yes', 'no'], 'Use DNS from DHCP')} - ${this._createSelectRow('UseNTP', dhcp.UseNTP, ['', 'yes', 'no'], 'Use NTP from DHCP')} - ${this._createInputRow('RouteMetric', dhcp.RouteMetric, 'Route metric')} + ${this._createSelectRow("UseDNS", dhcp.UseDNS || "", ["", "yes", "no"], "Use DNS from DHCP")} + ${this._createSelectRow("UseNTP", dhcp.UseNTP || "", ["", "yes", "no"], "Use NTP from DHCP")} + ${this._createInputRow("RouteMetric", dhcp.RouteMetric || "", "Route metric")} </div> </div> `; - } + } + + _createAddressSections() { + if (!this.config.Address || this.config.Address.length === 0) { + return '<div class="config-section"><h4>[Address]</h4><p class="no-items">No address sections</p></div>'; + } - _createAddressSections() { - return this.config.Address.map((addr, index) => ` + return this.config.Address.map( + (addr, index) => ` <div class="config-section"> - <h4>[Address] ${index > 0 ? `#${index + 1}` : ''}</h4> + <h4>[Address] ${index > 0 ? `#${index + 1}` : ""}</h4> <div class="config-table"> - ${this._createInputRow('Address', addr.Address, 'IP address with prefix')} - ${this._createInputRow('Peer', addr.Peer, 'Peer address')} - <button class="button small remove-section" data-type="address" data-index="${index}">Remove</button> + ${this._createInputRow("Address", addr.Address || "", "IP address with prefix")} + ${this._createInputRow("Peer", addr.Peer || "", "Peer address")} + <button class="button small warning remove-section" data-type="address" data-index="${index}">Remove</button> </div> </div> - `).join(''); - } + `, + ).join(""); + } - _createRouteSections() { - return this.config.Route.map((route, index) => ` + _createRouteSections() { + if (!this.config.Route || this.config.Route.length === 0) { + return '<div class="config-section"><h4>[Route]</h4><p class="no-items">No route sections</p></div>'; + } + + return this.config.Route.map( + (route, index) => ` <div class="config-section"> - <h4>[Route] ${index > 0 ? `#${index + 1}` : ''}</h4> + <h4>[Route] ${index > 0 ? `#${index + 1}` : ""}</h4> <div class="config-table"> - ${this._createInputRow('Gateway', route.Gateway, 'Gateway address')} - ${this._createInputRow('Destination', route.Destination, 'Destination prefix')} - ${this._createInputRow('Metric', route.Metric, 'Route metric')} - <button class="button small remove-section" data-type="route" data-index="${index}">Remove</button> + ${this._createInputRow("Gateway", route.Gateway || "", "Gateway address")} + ${this._createInputRow("Destination", route.Destination || "", "Destination prefix")} + ${this._createInputRow("Metric", route.Metric || "", "Route metric")} + <button class="button small warning remove-section" data-type="route" data-index="${index}">Remove</button> </div> </div> - `).join(''); - } + `, + ).join(""); + } - _createInputRow(key, value, description) { - return ` + _createInputRow(key, value, description) { + return ` <div class="config-row"> <label class="config-label" title="${description}"> <abbr title="${description}">${key}</abbr>: </label> <input type="text" class="config-input" + data-section="${this._getCurrentSection()}" data-key="${key}" - value="${value || ''}" + value="${value}" placeholder="${description}"> </div> `; - } + } - _createSelectRow(key, value, options, description) { - const optionsHTML = options.map(opt => - `<option value="${opt}" ${opt === value ? 'selected' : ''}>${opt || '(not set)'}</option>` - ).join(''); + _createSelectRow(key, value, options, description) { + const optionsHTML = options + .map( + (opt) => + `<option value="${opt}" ${opt === value ? "selected" : ""}>${opt || "(not set)"}</option>`, + ) + .join(""); - return ` + return ` <div class="config-row"> <label class="config-label" title="${description}"> <abbr title="${description}">${key}</abbr>: </label> - <select class="config-select" data-key="${key}"> + <select class="config-select" data-section="${this._getCurrentSection()}" data-key="${key}"> ${optionsHTML} </select> </div> `; - } - - _attachEventListeners() { - // Input changes - this.container.querySelectorAll('.config-input').forEach(input => { - input.addEventListener('change', (e) => this._onInputChange(e)); - }); - - // Select changes - this.container.querySelectorAll('.config-select').forEach(select => { - select.addEventListener('change', (e) => this._onSelectChange(e)); - }); - - // Add sections - this.container.querySelector('#addAddressSection')?.addEventListener('click', () => { - this.config.Address.push(new (await import('./systemd-network.js')).AddressSection()); - this.render(); - }); - - this.container.querySelector('#addRouteSection')?.addEventListener('click', () => { - this.config.Route.push(new (await import('./systemd-network.js')).RouteSection()); - this.render(); - }); - - // Remove sections - this.container.querySelectorAll('.remove-section').forEach(btn => { - btn.addEventListener('click', (e) => this._onRemoveSection(e)); - }); - } - - _onInputChange(event) { - const input = event.target; - const key = input.dataset.key; - const value = input.value; - - // Update configuration based on context - this._updateConfigValue(key, value); - } - - _onSelectChange(event) { - const select = event.target; - const key = select.dataset.key; - const value = select.value; - - this._updateConfigValue(key, value); - } - - _updateConfigValue(key, value) { - // This would need to be implemented based on the current section context - console.log(`Update ${key} = ${value}`); - // Implementation would update the this.config object - } - - _onRemoveSection(event) { - const btn = event.target; - const type = btn.dataset.type; - const index = parseInt(btn.dataset.index); - - if (type === 'address') { - this.config.Address.splice(index, 1); - } else if (type === 'route') { - this.config.Route.splice(index, 1); - } - - this.render(); - } - - /** - * Get current configuration as text - * @returns {string} - */ - getConfigurationText() { - return this.config.toSystemdConfiguration(); - } + } + + /** + * Get current section name for event handling + * @private + * @returns {string} + */ + _getCurrentSection() { + // This is a simplified implementation - you might want to track the current section more precisely + return "network"; + } + + /** + * Attach event listeners to the editor + * @private + */ + _attachEventListeners() { + // Input changes + this.container.querySelectorAll(".config-input").forEach((input) => { + input.addEventListener("input", (e) => this._onInputChange(e)); + }); + + // Select changes + this.container.querySelectorAll(".config-select").forEach((select) => { + select.addEventListener("change", (e) => this._onSelectChange(e)); + }); + + // Add sections + this.container + .querySelector("#addAddressSection") + ?.addEventListener("click", () => { + this._addAddressSection(); + }); + + this.container + .querySelector("#addRouteSection") + ?.addEventListener("click", () => { + this._addRouteSection(); + }); + + // Remove sections + this.container.querySelectorAll(".remove-section").forEach((btn) => { + btn.addEventListener("click", (e) => this._onRemoveSection(e)); + }); + } + + /** + * Handle input changes + * @private + * @param {Event} event + */ + _onInputChange(event) { + const input = event.target; + const section = input.dataset.section; + const key = input.dataset.key; + const value = input.value; + + this._updateConfigValue(section, key, value); + } + + /** + * Handle select changes + * @private + * @param {Event} event + */ + _onSelectChange(event) { + const select = event.target; + const section = select.dataset.section; + const key = select.dataset.key; + const value = select.value; + + this._updateConfigValue(section, key, value); + } + + /** + * Update configuration value + * @private + * @param {string} section - Section name + * @param {string} key - Key name + * @param {string} value - Value + */ + _updateConfigValue(section, key, value) { + if (!this.config) return; + + // Simplified implementation - you'll want to expand this based on your systemd-network.js structure + console.log(`Update ${section}.${key} = ${value}`); + + // Example update logic - you'll need to implement this based on your actual data structure + switch (section) { + case "match": + if (["MACAddress", "Name", "Driver", "Type"].includes(key)) { + this.config.Match[key] = value.split(" ").filter((v) => v.trim()); + } else { + this.config.Match[key] = value; + } + break; + case "link": + this.config.Link[key] = value; + break; + case "network": + if (["DNS", "NTP", "DHCP", "Domains", "IPForward"].includes(key)) { + this.config.Network[key] = value.split(" ").filter((v) => v.trim()); + } else { + this.config.Network[key] = value; + } + break; + case "dhcp": + this.config.DHCP[key] = value; + break; + } + } + + /** + * Add a new address section + * @private + */ + async _addAddressSection() { + if (!this.systemdNetworkModule) { + this.systemdNetworkModule = await import("./systemd-network.js"); + } + + const { AddressSection } = this.systemdNetworkModule; + this.config.Address.push(new AddressSection()); + this.render(); + } + + /** + * Add a new route section + * @private + */ + async _addRouteSection() { + if (!this.systemdNetworkModule) { + this.systemdNetworkModule = await import("./systemd-network.js"); + } + + const { RouteSection } = this.systemdNetworkModule; + this.config.Route.push(new RouteSection()); + this.render(); + } + + /** + * Remove a section + * @private + * @param {Event} event + */ + _onRemoveSection(event) { + const btn = event.target; + const type = btn.dataset.type; + const index = parseInt(btn.dataset.index); + + if (type === "address" && this.config.Address) { + this.config.Address.splice(index, 1); + } else if (type === "route" && this.config.Route) { + this.config.Route.splice(index, 1); + } + + this.render(); + } + + /** + * Get current configuration as text + * @returns {string} + */ + getConfigurationText() { + return this.config ? this.config.toSystemdConfiguration() : ""; + } + + /** + * Get current configuration object + * @returns {Object|null} + */ + getConfiguration() { + return this.config; + } } export { StructuredEditor }; + diff --git a/static/styles.css b/static/styles.css index 36c0e15..ca6468f 100644 --- a/static/styles.css +++ b/static/styles.css @@ -783,6 +783,35 @@ abbr { 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); +} + +.editor-mode-toggle .button.small { + 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); +} + +.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); +} + .config-section { background: var(--color-surface); border: 1px solid var(--border-color); @@ -846,3 +875,41 @@ abbr { 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); +} + +.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; +} + +.config-section:hover { + 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); +} + +.button.warning { + background: var(--color-status-warning); + border-color: var(--color-status-warning); +} + +.button.warning:hover { + background: #c13501; + border-color: #c13501; +} |
