summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-09-28 14:15:46 +0300
committerPetri Hienonen <petri.hienonen@gmail.com>2025-09-28 14:15:46 +0300
commit645dd2b87927b7efadedb6dd5232d36a7800ce28 (patch)
treea510862ef65f5fe0e44624751dd47e66b582b9ed
parentb0c76dcc159ead3d67314da3a71d60bad9385991 (diff)
downloadnetwork-645dd2b87927b7efadedb6dd5232d36a7800ce28.tar.zst
Structured editor works
-rw-r--r--static/app.js605
-rw-r--r--static/config-manager.js50
-rw-r--r--static/index.html5
-rw-r--r--static/structured-editor.js468
-rw-r--r--static/styles.css67
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;
+}