From 48a39bcff2c14da3d2b0c28d64ac2716d52829b0 Mon Sep 17 00:00:00 2001 From: Petri Hienonen Date: Sun, 28 Sep 2025 14:23:58 +0300 Subject: Structured editor works 2 --- static/app.js | 30 ++- static/structured-editor.js | 462 +++++++++++++++++----------------- static/systemd-network.js | 590 +++++++++++++++++++++----------------------- 3 files changed, 529 insertions(+), 553 deletions(-) diff --git a/static/app.js b/static/app.js index ca205f3..3599cba 100644 --- a/static/app.js +++ b/static/app.js @@ -1,10 +1,10 @@ /* jshint esversion: 2024, module: true */ -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 { InterfaceRenderer } from './interface-renderer.js'; import { StructuredEditor } from './structured-editor.js'; +import { ThemeManager } from './theme-manager.js'; /** * Main Application Class @@ -160,6 +160,17 @@ class Application { document.addEventListener('touchstart', this.handleTouchStart, { passive: true }); } + // Add event handlers for structured editor + handleAddSection(detail) { + console.log('Add section:', detail); + // Implement section addition logic + } + + handleRemoveSection(detail) { + console.log('Remove section:', detail); + // Implement section removal logic + } + /** * Handle configuration file change * @method handleConfigFileChange @@ -181,6 +192,7 @@ class Application { * Load config for structured editor * @method loadConfigForStructuredEditor */ + async loadConfigForStructuredEditor() { if (!this.structuredEditor) { console.error('Structured editor not initialized'); @@ -189,7 +201,19 @@ class Application { try { const text = await this.apiClient.getText(`/api/config/${encodeURIComponent(this.state.currentConfigFile)}`); - await this.structuredEditor.loadConfiguration(text, this.state.currentConfigFile); + + // Parse configuration and get schema + const { NetworkConfiguration } = await import('./systemd-network.js'); + const config = NetworkConfiguration.fromSystemdConfiguration(text); + 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}`); } diff --git a/static/structured-editor.js b/static/structured-editor.js index 4de9217..0ce0965 100644 --- a/static/structured-editor.js +++ b/static/structured-editor.js @@ -7,44 +7,57 @@ class StructuredEditor { constructor(container) { this.container = container; - this.config = null; + this.schema = null; this.currentFile = ""; - this.systemdNetworkModule = null; } /** - * Load configuration from text - * @param {string} configText - Configuration text + * Load configuration schema + * @param {Object} schema - Configuration schema from NetworkConfiguration.getSchema() * @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"); + loadSchema(schema, filename) { + if (!this.container) { + console.error("Structured editor container not found"); + return; } - const { NetworkConfiguration } = this.systemdNetworkModule; - this.config = NetworkConfiguration.fromSystemdConfiguration(configText); - this.currentFile = filename; - this.render(); + try { + this.schema = schema; + this.currentFile = filename; + this.render(); + } catch (error) { + console.error("Error loading schema:", error); + this.showError("Failed to load configuration schema: " + error.message); + } } /** * Render the structured editor */ render() { - if (!this.config) { + if (!this.container) { + console.error("Cannot render: container is null"); + return; + } + + if (!this.schema) { this.container.innerHTML = - '
No configuration loaded
'; + '
No configuration schema loaded
'; return; } - this.container.innerHTML = this._createEditorHTML(); - this._attachEventListeners(); + try { + this.container.innerHTML = this._createEditorHTML(); + this._attachEventListeners(); + } catch (error) { + console.error("Error rendering structured editor:", error); + this.showError("Failed to render editor: " + error.message); + } } /** - * Create editor HTML structure + * Create editor HTML structure from schema * @private * @returns {string} */ @@ -52,163 +65,165 @@ class StructuredEditor { return `
- ${this._createMatchSection()} - ${this._createLinkSection()} - ${this._createNetworkSection()} - ${this._createDHCPSection()} - ${this._createAddressSections()} - ${this._createRouteSections()} -
-
- - + ${this._createSection("Match", this.schema.Match)} + ${this._createSection("Link", this.schema.Link)} + ${this._createSection("Network", this.schema.Network)} + ${this._createSection("DHCP", this.schema.DHCP)} + ${this._createArraySection("Address", this.schema.Address)} + ${this._createArraySection("Route", this.schema.Route)}
`; } - _createMatchSection() { - const match = this.config.Match; + /** + * Create a regular section + * @private + * @param {string} sectionName - Section name + * @param {Object} sectionSchema - Section schema + * @returns {string} + */ + _createSection(sectionName, sectionSchema) { + if (!this._hasSectionValues(sectionSchema)) { + return ""; + } + + const fieldsHTML = Object.entries(sectionSchema) + .map(([key, field]) => this._createFieldInput(key, field)) + .join(""); + return `
-

[Match]

+

[${sectionName}]

- ${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")} + ${fieldsHTML}
`; } - _createLinkSection() { - const link = this.config.Link; - return ` -
-

[Link]

-
- ${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")} + /** + * Create an array section (Address, Route) + * @private + * @param {string} sectionName - Section name + * @param {Object} arraySchema - Array section schema + * @returns {string} + */ + _createArraySection(sectionName, arraySchema) { + if (!arraySchema.items || arraySchema.items.length === 0) { + return ` +
+

[${sectionName}]

+

No ${sectionName.toLowerCase()} sections

+
-
- `; - } + `; + } - _createNetworkSection() { - const network = this.config.Network; - return ` -
-

[Network]

-
- ${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")} + const sectionsHTML = arraySchema.items + .map((itemSchema, index) => { + const fieldsHTML = Object.entries(itemSchema) + .map(([key, field]) => + this._createFieldInput(key, field, sectionName, index), + ) + .join(""); + + return ` +
+

[${sectionName}] ${index > 0 ? `#${index + 1}` : ""}

+
+ ${fieldsHTML} + +
-
- `; - } + `; + }) + .join(""); - _createDHCPSection() { - const dhcp = this.config.DHCP; return ` -
-

[DHCP]

-
- ${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")} +
+ ${sectionsHTML} +
+
`; } - _createAddressSections() { - if (!this.config.Address || this.config.Address.length === 0) { - return '

[Address]

No address sections

'; + /** + * Create field input based on field schema + * @private + * @param {string} key - Field key + * @param {Object} field - Field schema + * @param {string} sectionName - Section name (for array sections) + * @param {number} index - Item index (for array sections) + * @returns {string} + */ + _createFieldInput(key, field, sectionName = null, index = null) { + const dataAttributes = []; + if (sectionName) { + dataAttributes.push(`data-section="${sectionName}"`); + dataAttributes.push(`data-index="${index}"`); } - - return this.config.Address.map( - (addr, index) => ` -
-

[Address] ${index > 0 ? `#${index + 1}` : ""}

-
- ${this._createInputRow("Address", addr.Address || "", "IP address with prefix")} - ${this._createInputRow("Peer", addr.Peer || "", "Peer address")} - + dataAttributes.push(`data-key="${key}"`); + + const inputId = sectionName ? `${sectionName}-${index}-${key}` : key; + + if (field.options?.enum) { + // Create select for enum fields + const optionsHTML = field.options.enum + .map( + (opt) => + ``, + ) + .join(""); + + return ` +
+ +
-
- `, - ).join(""); - } - - _createRouteSections() { - if (!this.config.Route || this.config.Route.length === 0) { - return '

[Route]

No route sections

'; - } - - return this.config.Route.map( - (route, index) => ` -
-

[Route] ${index > 0 ? `#${index + 1}` : ""}

-
- ${this._createInputRow("Gateway", route.Gateway || "", "Gateway address")} - ${this._createInputRow("Destination", route.Destination || "", "Destination prefix")} - ${this._createInputRow("Metric", route.Metric || "", "Route metric")} - + `; + } else { + // Create text input for other fields + return ` +
+ +
-
- `, - ).join(""); - } - - _createInputRow(key, value, description) { - return ` -
- - -
- `; - } - - _createSelectRow(key, value, options, description) { - const optionsHTML = options - .map( - (opt) => - ``, - ) - .join(""); - - return ` -
- - -
- `; + `; + } } /** - * Get current section name for event handling + * Check if section has any values * @private - * @returns {string} + * @param {Object} sectionSchema - Section schema + * @returns {boolean} */ - _getCurrentSection() { - // This is a simplified implementation - you might want to track the current section more precisely - return "network"; + _hasSectionValues(sectionSchema) { + return Object.values(sectionSchema).some( + (field) => + field.value !== null && field.value !== undefined && field.value !== "", + ); } /** @@ -226,20 +241,12 @@ class StructuredEditor { 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(); - }); + // Add section buttons + this.container.querySelectorAll("[data-add-section]").forEach((btn) => { + btn.addEventListener("click", (e) => this._onAddSection(e)); + }); - // Remove sections + // Remove section buttons this.container.querySelectorAll(".remove-section").forEach((btn) => { btn.addEventListener("click", (e) => this._onRemoveSection(e)); }); @@ -252,11 +259,7 @@ class StructuredEditor { */ _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); + this._updateFieldValue(input); } /** @@ -266,114 +269,99 @@ class StructuredEditor { */ _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); + this._updateFieldValue(select); } /** - * Update configuration value + * Update field value in schema * @private - * @param {string} section - Section name - * @param {string} key - Key name - * @param {string} value - Value + * @param {HTMLElement} element - Input or select element */ - _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; + _updateFieldValue(element) { + const section = element.dataset.section; + const index = element.dataset.index + ? parseInt(element.dataset.index) + : null; + const key = element.dataset.key; + const value = element.value; + + if (!section) { + // Regular section (Match, Link, Network, DHCP) + if (this.schema[section] && this.schema[section][key]) { + this.schema[section][key].value = value || null; + } + } else if ( + index !== null && + this.schema[section] && + this.schema[section].items + ) { + // Array section item + if ( + this.schema[section].items[index] && + this.schema[section].items[index][key] + ) { + this.schema[section].items[index][key].value = value || null; + } } } /** - * Add a new address section + * Handle add section * @private + * @param {Event} event */ - async _addAddressSection() { - if (!this.systemdNetworkModule) { - this.systemdNetworkModule = await import("./systemd-network.js"); - } - - const { AddressSection } = this.systemdNetworkModule; - this.config.Address.push(new AddressSection()); - this.render(); + _onAddSection(event) { + const sectionName = event.target.dataset.addSection; + this.emit("addSection", { section: sectionName }); } /** - * Add a new route section + * Handle remove section * @private + * @param {Event} event */ - async _addRouteSection() { - if (!this.systemdNetworkModule) { - this.systemdNetworkModule = await import("./systemd-network.js"); - } - - const { RouteSection } = this.systemdNetworkModule; - this.config.Route.push(new RouteSection()); - this.render(); + _onRemoveSection(event) { + const section = event.target.dataset.section; + const index = parseInt(event.target.dataset.index); + this.emit("removeSection", { section, index }); } /** - * Remove a section - * @private - * @param {Event} event + * Show error message + * @param {string} message - Error message */ - _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); + showError(message) { + if (this.container) { + this.container.innerHTML = `
${message}
`; } + } - this.render(); + /** + * Emit custom event + * @param {string} eventName - Event name + * @param {Object} detail - Event detail + */ + emit(eventName, detail) { + const event = new CustomEvent(eventName, { detail }); + this.container.dispatchEvent(event); } /** - * Get current configuration as text - * @returns {string} + * Add event listener + * @param {string} eventName - Event name + * @param {Function} callback - Event callback */ - getConfigurationText() { - return this.config ? this.config.toSystemdConfiguration() : ""; + on(eventName, callback) { + this.container.addEventListener(eventName, callback); } /** - * Get current configuration object + * Get current schema * @returns {Object|null} */ - getConfiguration() { - return this.config; + getSchema() { + return this.schema; } } export { StructuredEditor }; - diff --git a/static/systemd-network.js b/static/systemd-network.js index 8c66a6d..1a33f0e 100644 --- a/static/systemd-network.js +++ b/static/systemd-network.js @@ -7,56 +7,71 @@ */ /** - * MAC Address type - * @class MACAddress + * Base field type with standardized interface + * @class BaseField */ -class MACAddress { +class BaseField { /** - * @param {string} value - MAC address value - * @param {string} description - Description + * @param {*} value - Field value + * @param {string} type - Field type + * @param {string} description - Field description + * @param {Object} options - Additional options (pattern, enum, etc.) */ - constructor(value = '', description = 'Hardware address') { + constructor(value = null, type = 'string', description = '', options = {}) { this.value = value; - this.type = 'mac-address'; + this.type = type; this.description = description; - this.pattern = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/; + this.options = options; } /** - * Validate MAC address format + * Validate field value * @returns {boolean} */ validate() { - return this.pattern.test(this.value); + 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; + return this.value !== null ? String(this.value) : ''; } } /** - * IPv4 Address type - * @class IPv4Address + * MAC Address type + * @class MACAddress */ -class IPv4Address { - /** - * @param {string} value - IPv4 address - * @param {string} description - Description - */ - constructor(value = '', description = 'IPv4 address') { - this.value = value; - this.type = 'ipv4-address'; - this.description = description; - this.pattern = /^(\d{1,3}\.){3}\d{1,3}$/; - } - - validate() { - return this.pattern.test(this.value); +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})$/ + }); } +} - toString() { - return this.value; +/** + * IPv4 Address type + * @class IPv4Address + */ +class IPv4Address extends BaseField { + constructor(value = null) { + super(value, 'ipv4-address', 'IPv4 address', { + pattern: /^(\d{1,3}\.){3}\d{1,3}$/ + }); } } @@ -64,24 +79,47 @@ class IPv4Address { * IPv6 Address type * @class IPv6Address */ -class IPv6Address { - /** - * @param {string} value - IPv6 address - * @param {string} description - Description - */ - constructor(value = '', description = 'IPv6 address') { - this.value = value; - this.type = 'ipv6-address'; - this.description = description; - this.pattern = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/; +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}$/ + }); } +} - validate() { - return this.pattern.test(this.value); +/** + * Boolean type with yes/no values + * @class BooleanYesNo + */ +class BooleanYesNo extends BaseField { + constructor(value = null) { + super(value, 'boolean', 'Boolean (yes/no)', { + enum: ['yes', 'no'] + }); } +} - toString() { - return this.value; +/** + * Port type + * @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])$/ + }); + } +} + +/** + * MTU type + * @class MTU + */ +class MTU extends BaseField { + constructor(value = null) { + super(value, 'mtu', 'Maximum Transmission Unit', { + pattern: /^[1-9][0-9]*$/ + }); } } @@ -91,28 +129,17 @@ class IPv6Address { */ class MatchSection { constructor() { - /** @type {MACAddress[]} */ - this.MACAddress = []; - /** @type {string[]} */ - this.OriginalName = []; - /** @type {string[]} */ - this.Path = []; - /** @type {string[]} */ - this.Driver = []; - /** @type {string[]} */ - this.Type = []; - /** @type {string[]} */ - this.Name = []; - /** @type {string} */ - this.Property = ''; - /** @type {string} */ - this.Host = ''; - /** @type {string} */ - this.Virtualization = ''; - /** @type {string} */ - this.KernelCommandLine = ''; - /** @type {string} */ - this.Architecture = ''; + 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'); } } @@ -122,26 +149,22 @@ class MatchSection { */ class LinkSection { constructor() { - /** @type {string} */ - this.MACAddress = ''; - /** @type {string} */ - this.MTUBytes = ''; - /** @type {number} */ - this.BitsPerSecond = 0; - /** @type {string} */ - this.Duplex = ''; - /** @type {string} */ - this.AutoNegotiation = ''; - /** @type {string} */ - this.WakeOnLan = ''; - /** @type {string} */ - this.Port = ''; - /** @type {string} */ - this.Advertise = ''; - /** @type {string} */ - this.RxFlowControl = ''; - /** @type {string} */ - this.TxFlowControl = ''; + 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(); } } @@ -151,36 +174,33 @@ class LinkSection { */ class NetworkSection { constructor() { - /** @type {string} */ - this.Description = ''; - /** @type {string[]} */ - this.DHCP = []; // 'yes', 'no', 'ipv4', 'ipv6' - /** @type {boolean} */ - this.DHCPServer = false; - /** @type {string[]} */ - this.DNS = []; - /** @type {string[]} */ - this.NTP = []; - /** @type {string[]} */ - this.IPForward = []; // 'yes', 'no', 'ipv4', 'ipv6' - /** @type {string} */ - this.IPv6PrivacyExtensions = ''; // 'yes', 'no', 'prefer-public' - /** @type {string} */ - this.IPv6AcceptRA = ''; // 'yes', 'no' - /** @type {string} */ - this.LLMNR = ''; // 'yes', 'no', 'resolve' - /** @type {string} */ - this.MulticastDNS = ''; // 'yes', 'no', 'resolve' - /** @type {string} */ - this.DNSSEC = ''; // 'yes', 'no', 'allow-downgrade' - /** @type {string[]} */ - this.Domains = []; - /** @type {string} */ - this.ConfigureWithoutCarrier = ''; // 'yes', 'no' - /** @type {string} */ - this.IgnoreCarrierLoss = ''; // 'yes', 'no' - /** @type {number} */ - this.KeepConfiguration = 0; // seconds + 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'); } } @@ -190,20 +210,17 @@ class NetworkSection { */ class DHCPSection { constructor() { - /** @type {string} */ - this.UseDNS = ''; // 'yes', 'no' - /** @type {string} */ - this.UseNTP = ''; // 'yes', 'no' - /** @type {string} */ - this.UseMTU = ''; // 'yes', 'no' - /** @type {string} */ - this.UseHostname = ''; // 'yes', 'no' - /** @type {string} */ - this.UseDomains = ''; // 'yes', 'no', 'route' - /** @type {string} */ - this.ClientIdentifier = ''; // 'mac', 'duid' - /** @type {string} */ - this.RouteMetric = ''; + 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'); } } @@ -213,18 +230,12 @@ class DHCPSection { */ class AddressSection { constructor() { - /** @type {string} */ - this.Address = ''; // IP address with prefix - /** @type {string} */ - this.Peer = ''; // Peer address - /** @type {string} */ - this.Broadcast = ''; // Broadcast address - /** @type {string} */ - this.Label = ''; // Address label - /** @type {number} */ - this.Scope = 0; // Address scope - /** @type {string} */ - this.Flags = ''; // Address flags + 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'); } } @@ -234,22 +245,18 @@ class AddressSection { */ class RouteSection { constructor() { - /** @type {string} */ - this.Gateway = ''; // Gateway address - /** @type {string} */ - this.GatewayOnLink = ''; // 'yes', 'no' - /** @type {string} */ - this.Destination = ''; // Destination prefix - /** @type {string} */ - this.Source = ''; // Source address - /** @type {string} */ - this.PreferredSource = ''; // Preferred source address - /** @type {number} */ - this.Metric = 1024; // Route metric - /** @type {string} */ - this.Scope = ''; // 'global', 'link', 'host' - /** @type {string} */ - this.Type = ''; // 'unicast', 'local', 'broadcast', etc. + 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'] + }); } } @@ -259,20 +266,63 @@ class RouteSection { */ class NetworkConfiguration { constructor() { - /** @type {MatchSection} */ this.Match = new MatchSection(); - /** @type {LinkSection} */ this.Link = new LinkSection(); - /** @type {NetworkSection} */ this.Network = new NetworkSection(); - /** @type {DHCPSection} */ this.DHCP = new DHCPSection(); - /** @type {AddressSection[]} */ this.Address = []; - /** @type {RouteSection[]} */ 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 @@ -282,6 +332,8 @@ class NetworkConfiguration { 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(); @@ -293,6 +345,15 @@ class NetworkConfiguration { 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; } @@ -302,7 +363,7 @@ class NetworkConfiguration { const key = kvMatch[1]; const value = kvMatch[2]; - config._setValue(currentSection, key, value); + config._setValue(currentSection, key, value, currentAddress, currentRoute); } } @@ -315,8 +376,10 @@ class NetworkConfiguration { * @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) { + _setValue(section, key, value, currentAddress, currentRoute) { switch (section) { case 'match': this._setMatchValue(key, value); @@ -331,70 +394,52 @@ class NetworkConfiguration { this._setDHCPValue(key, value); break; case 'address': - // Handle multiple address sections - if (!this.Address.length) this.Address.push(new AddressSection()); - this._setAddressValue(this.Address[this.Address.length - 1], key, value); + if (currentAddress) { + this._setAddressValue(currentAddress, key, value); + } break; case 'route': - // Handle multiple route sections - if (!this.Route.length) this.Route.push(new RouteSection()); - this._setRouteValue(this.Route[this.Route.length - 1], key, value); + if (currentRoute) { + this._setRouteValue(currentRoute, key, value); + } break; } } _setMatchValue(key, value) { - const match = this.Match; - switch (key) { - case 'MACAddress': - match.MACAddress = value.split(' '); - break; - case 'Name': - match.Name = value.split(' '); - break; - case 'Driver': - match.Driver = value.split(' '); - break; - case 'Path': - match.Path = value.split(' '); - break; - case 'Type': - match.Type = value.split(' '); - break; - default: - match[key] = value; + if (this.Match[key] !== undefined) { + this.Match[key].value = value; } } _setLinkValue(key, value) { - this.Link[key] = value; + if (this.Link[key] !== undefined) { + this.Link[key].value = value; + } } _setNetworkValue(key, value) { - const network = this.Network; - switch (key) { - case 'DNS': - case 'NTP': - case 'Domains': - case 'DHCP': - case 'IPForward': - network[key] = value.split(' '); - break; - default: - network[key] = value; + if (this.Network[key] !== undefined) { + this.Network[key].value = value; } } _setDHCPValue(key, value) { - this.DHCP[key] = value; + if (this.DHCP[key] !== undefined) { + this.DHCP[key].value = value; + } } _setAddressValue(address, key, value) { - address[key] = value; + if (address[key] !== undefined) { + address[key].value = value; + } } _setRouteValue(route, key, value) { - route[key] = value; + if (route[key] !== undefined) { + route[key].value = value; + } } /** @@ -405,155 +450,74 @@ class NetworkConfiguration { const sections = []; // [Match] section - if (this._hasMatchValues()) { + if (this._hasSectionValues(this.Match)) { sections.push('[Match]'); - sections.push(...this._formatMatchSection()); + sections.push(...this._formatSection(this.Match)); } // [Link] section - if (this._hasLinkValues()) { + if (this._hasSectionValues(this.Link)) { sections.push('[Link]'); - sections.push(...this._formatLinkSection()); + sections.push(...this._formatSection(this.Link)); } // [Network] section - if (this._hasNetworkValues()) { + if (this._hasSectionValues(this.Network)) { sections.push('[Network]'); - sections.push(...this._formatNetworkSection()); + sections.push(...this._formatSection(this.Network)); } // [DHCP] section - if (this._hasDHCPValues()) { + if (this._hasSectionValues(this.DHCP)) { sections.push('[DHCP]'); - sections.push(...this._formatDHCPSection()); + sections.push(...this._formatSection(this.DHCP)); } // [Address] sections - this.Address.forEach((addr, index) => { - if (this._hasAddressValues(addr)) { + this.Address.forEach(addr => { + if (this._hasSectionValues(addr)) { sections.push('[Address]'); - sections.push(...this._formatAddressSection(addr)); + sections.push(...this._formatSection(addr)); } }); // [Route] sections - this.Route.forEach((route, index) => { - if (this._hasRouteValues(route)) { + this.Route.forEach(route => { + if (this._hasSectionValues(route)) { sections.push('[Route]'); - sections.push(...this._formatRouteSection(route)); + sections.push(...this._formatSection(route)); } }); return sections.join('\n') + '\n'; } - // Helper methods to check if sections have values - _hasMatchValues() { - return Object.values(this.Match).some(val => - val && (Array.isArray(val) ? val.length > 0 : val !== '') + _hasSectionValues(section) { + return Object.values(section).some(field => + field.value !== null && field.value !== undefined && field.value !== '' ); } - _hasLinkValues() { - return Object.values(this.Link).some(val => val && val !== ''); - } - - _hasNetworkValues() { - return Object.values(this.Network).some(val => - val && (Array.isArray(val) ? val.length > 0 : val !== '') - ); - } - - _hasDHCPValues() { - return Object.values(this.DHCP).some(val => val && val !== ''); - } - - _hasAddressValues(addr) { - return Object.values(addr).some(val => val && val !== ''); - } - - _hasRouteValues(route) { - return Object.values(route).some(val => val && val !== ''); - } - - // Formatting methods - _formatMatchSection() { + _formatSection(section) { const lines = []; - const match = this.Match; - - if (match.MACAddress.length) lines.push(`MACAddress=${match.MACAddress.join(' ')}`); - if (match.Name.length) lines.push(`Name=${match.Name.join(' ')}`); - if (match.Driver.length) lines.push(`Driver=${match.Driver.join(' ')}`); - if (match.Path.length) lines.push(`Path=${match.Path.join(' ')}`); - if (match.Type.length) lines.push(`Type=${match.Type.join(' ')}`); - if (match.Property) lines.push(`Property=${match.Property}`); - - return lines; - } - - _formatLinkSection() { - const lines = []; - const link = this.Link; - - Object.entries(link).forEach(([key, value]) => { - if (value && value !== '') lines.push(`${key}=${value}`); - }); - - return lines; - } - - _formatNetworkSection() { - const lines = []; - const network = this.Network; - - if (network.Description) lines.push(`Description=${network.Description}`); - if (network.DHCP.length) lines.push(`DHCP=${network.DHCP.join(' ')}`); - if (network.DNS.length) lines.push(`DNS=${network.DNS.join(' ')}`); - if (network.NTP.length) lines.push(`NTP=${network.NTP.join(' ')}`); - if (network.IPForward.length) lines.push(`IPForward=${network.IPForward.join(' ')}`); - if (network.IPv6PrivacyExtensions) lines.push(`IPv6PrivacyExtensions=${network.IPv6PrivacyExtensions}`); - if (network.LLMNR) lines.push(`LLMNR=${network.LLMNR}`); - - return lines; - } - - _formatDHCPSection() { - const lines = []; - const dhcp = this.DHCP; - - Object.entries(dhcp).forEach(([key, value]) => { - if (value && value !== '') lines.push(`${key}=${value}`); - }); - - return lines; - } - - _formatAddressSection(addr) { - const lines = []; - - Object.entries(addr).forEach(([key, value]) => { - if (value && value !== '') lines.push(`${key}=${value}`); - }); - - return lines; - } - - _formatRouteSection(route) { - const lines = []; - - Object.entries(route).forEach(([key, value]) => { - if (value && value !== '') lines.push(`${key}=${value}`); - }); - + for (const [key, field] of Object.entries(section)) { + if (field.value !== null && field.value !== undefined && field.value !== '') { + lines.push(`${key}=${field.toString()}`); + } + } return lines; } } // Export classes export { + BaseField, MACAddress, IPv4Address, IPv6Address, + BooleanYesNo, + Port, + MTU, MatchSection, LinkSection, NetworkSection, -- cgit v1.2.3-70-g09d2