summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--static/api-client.js104
-rw-r--r--static/app.js244
-rw-r--r--static/config-manager.js130
-rw-r--r--static/enums.js67
-rw-r--r--static/index.html4
-rw-r--r--static/interface-renderer.js232
-rw-r--r--static/utils.js155
7 files changed, 658 insertions, 278 deletions
diff --git a/static/api-client.js b/static/api-client.js
index 4a1b81e..7a6ffbd 100644
--- a/static/api-client.js
+++ b/static/api-client.js
@@ -1,18 +1,20 @@
/* jshint esversion: 2024, module: true */
+import { ApiEndpoints } from './enums.js';
+
/**
- * API Client for network operations
+ * Static API Client for network operations
* @class ApiClient
*/
class ApiClient {
/**
* API utility function
- * @method request
+ * @static
* @param {string} path - API endpoint
* @param {Object} [options] - Fetch options
* @returns {Promise<Response>}
*/
- async request(path, options = {}) {
+ static async request(path, options = {}) {
const response = await fetch(path, options);
if (!response.ok) {
@@ -25,34 +27,34 @@ class ApiClient {
/**
* GET request returning JSON
- * @method get
+ * @static
* @param {string} path - API endpoint
* @returns {Promise<Object>}
*/
- async get(path) {
- const response = await this.request(path);
+ static async get(path) {
+ const response = await ApiClient.request(path);
return response.json();
}
/**
* GET request returning text
- * @method getText
+ * @static
* @param {string} path - API endpoint
* @returns {Promise<string>}
*/
- async getText(path) {
- const response = await this.request(path);
+ static async getText(path) {
+ const response = await ApiClient.request(path);
return response.text();
}
/**
* POST request with JSON body
- * @method post
+ * @static
* @param {string} path - API endpoint
* @param {Object} [data] - Request body
* @returns {Promise<Object>}
*/
- async post(path, data = null) {
+ static async post(path, data = null) {
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -62,9 +64,87 @@ class ApiClient {
options.body = JSON.stringify(data);
}
- const response = await this.request(path, options);
+ const response = await ApiClient.request(path, options);
return response.json();
}
+
+ /**
+ * Get network status
+ * @static
+ * @returns {Promise<Object>}
+ */
+ static async getNetworkStatus() {
+ return ApiClient.get(ApiEndpoints.STATUS);
+ }
+
+ /**
+ * Get configuration files list
+ * @static
+ * @returns {Promise<Object>}
+ */
+ static async getConfigFiles() {
+ return ApiClient.get(ApiEndpoints.CONFIGS);
+ }
+
+ /**
+ * Get configuration file content
+ * @static
+ * @param {string} name - File name
+ * @returns {Promise<string>}
+ */
+ static async getConfigFile(name) {
+ return ApiClient.getText(`${ApiEndpoints.CONFIG}/${encodeURIComponent(name)}`);
+ }
+
+ /**
+ * Validate configuration
+ * @static
+ * @param {string} name - File name
+ * @param {string} content - File content
+ * @returns {Promise<Object>}
+ */
+ static async validateConfig(name, content) {
+ return ApiClient.post(ApiEndpoints.VALIDATE, { name, content });
+ }
+
+ /**
+ * Save configuration file
+ * @static
+ * @param {string} name - File name
+ * @param {string} content - File content
+ * @param {boolean} restart - Restart service
+ * @returns {Promise<Object>}
+ */
+ static async saveConfig(name, content, restart) {
+ return ApiClient.post(ApiEndpoints.SAVE, { name, content, restart });
+ }
+
+ /**
+ * Get system logs
+ * @static
+ * @returns {Promise<string>}
+ */
+ static async getSystemLogs() {
+ return ApiClient.getText(ApiEndpoints.LOGS);
+ }
+
+ /**
+ * Restart networkd service
+ * @static
+ * @returns {Promise<Object>}
+ */
+ static async restartNetworkd() {
+ return ApiClient.post(ApiEndpoints.RELOAD);
+ }
+
+ /**
+ * Reboot device
+ * @static
+ * @returns {Promise<Object>}
+ */
+ static async rebootDevice() {
+ return ApiClient.post(ApiEndpoints.REBOOT);
+ }
}
export { ApiClient };
diff --git a/static/app.js b/static/app.js
index 3599cba..22e52b5 100644
--- a/static/app.js
+++ b/static/app.js
@@ -2,6 +2,7 @@
import { ApiClient } from './api-client.js';
import { ConfigManager } from './config-manager.js';
+import { EditorMode, ThemeMode, ValidationState } from './enums.js';
import { InterfaceRenderer } from './interface-renderer.js';
import { StructuredEditor } from './structured-editor.js';
import { ThemeManager } from './theme-manager.js';
@@ -19,17 +20,13 @@ class Application {
this.state = {
currentInterface: null,
interfaces: [],
- editorMode: 'raw', // 'raw' or 'structured'
- currentConfigFile: null
+ editorMode: EditorMode.STRUCTURED,
+ currentConfigFile: null,
+ theme: ThemeMode.DARK
};
// Initialize modules
this.themeManager = new ThemeManager(elements);
- this.apiClient = new ApiClient();
- this.interfaceRenderer = new InterfaceRenderer(elements, this.state);
- this.configManager = new ConfigManager(elements, this.apiClient, this.state);
-
- // Structured editor will be initialized after DOM is ready
this.structuredEditor = null;
// Create editor mode toggle UI
@@ -44,8 +41,6 @@ class Application {
this.themeManager.init();
this.setupEventListeners();
this.loadStatus();
-
- // Initialize structured editor now that DOM is ready
this.initializeStructuredEditor();
}
@@ -56,11 +51,11 @@ class Application {
createEditorModeToggle() {
const editorToggleHTML = `
<div class="editor-mode-toggle" style="margin-bottom: var(--spacing-l);">
- <button class="button small ${this.state.editorMode === 'raw' ? 'active' : ''}"
+ <button class="button small ${this.state.editorMode === EditorMode.RAW ? 'active' : ''}"
data-mode="raw" id="rawEditorBtn">
📝 Raw Editor
</button>
- <button class="button small ${this.state.editorMode === 'structured' ? 'active' : ''}"
+ <button class="button small ${this.state.editorMode === EditorMode.STRUCTURED ? 'active' : ''}"
data-mode="structured" id="structuredEditorBtn">
🏗️ Structured Editor
</button>
@@ -135,19 +130,19 @@ class Application {
// Configs panel - raw editor
this.elements.buttons.refreshConfigs?.addEventListener('click',
- () => this.configManager.refreshConfigs());
+ () => this.handleRefreshConfigs());
this.elements.buttons.saveConfig?.addEventListener('click',
() => this.handleSaveConfig());
this.elements.buttons.validateConfig?.addEventListener('click',
- () => this.configManager.validateConfig());
+ () => this.handleValidateConfig());
this.elements.inputs.configSelect?.addEventListener('change',
() => this.handleConfigFileChange());
// Editor mode toggle
this.elements.editorButtons?.raw?.addEventListener('click',
- () => this.setEditorMode('raw'));
+ () => this.setEditorMode(EditorMode.RAW));
this.elements.editorButtons?.structured?.addEventListener('click',
- () => this.setEditorMode('structured'));
+ () => this.setEditorMode(EditorMode.STRUCTURED));
// Logs panel
this.elements.buttons.refreshLogs?.addEventListener('click', () => this.loadLogs());
@@ -160,15 +155,53 @@ class Application {
document.addEventListener('touchstart', this.handleTouchStart, { passive: true });
}
- // Add event handlers for structured editor
- handleAddSection(detail) {
- console.log('Add section:', detail);
- // Implement section addition logic
+ /**
+ * Handle touch events for better mobile support
+ * @method handleTouchStart
+ * @param {TouchEvent} event
+ */
+ handleTouchStart = (event) => {
+ // Add visual feedback for touch
+ if (event.target.classList.contains('button') || event.target.classList.contains('nav-button')) {
+ event.target.style.opacity = '0.7';
+ setTimeout(() => {
+ event.target.style.opacity = '';
+ }, 150);
+ }
+ };
+
+ /**
+ * Handle refresh configuration files
+ * @method handleRefreshConfigs
+ */
+ async handleRefreshConfigs() {
+ try {
+ const files = await ConfigManager.refreshConfigs();
+ this.updateConfigSelect(files);
+
+ if (files.length > 0) {
+ this.state.currentConfigFile = files[0];
+ await this.handleConfigFileChange();
+ } else {
+ this.elements.inputs.cfgEditor.value = '';
+ this.state.currentConfigFile = null;
+ }
+ } catch (error) {
+ alert(error.message);
+ }
}
- handleRemoveSection(detail) {
- console.log('Remove section:', detail);
- // Implement section removal logic
+ /**
+ * Update configuration select element
+ * @method updateConfigSelect
+ * @param {Array} files - File names
+ */
+ updateConfigSelect(files) {
+ this.elements.inputs.configSelect.innerHTML = '';
+ files.forEach(file => {
+ const option = new Option(file, file);
+ this.elements.inputs.configSelect.add(option);
+ });
}
/**
@@ -181,18 +214,31 @@ class Application {
this.state.currentConfigFile = name;
- if (this.state.editorMode === 'raw') {
- await this.configManager.loadConfig();
+ if (this.state.editorMode === EditorMode.RAW) {
+ await this.loadConfigForRawEditor();
} else {
await this.loadConfigForStructuredEditor();
}
}
/**
+ * Load config for raw editor
+ * @method loadConfigForRawEditor
+ */
+ async loadConfigForRawEditor() {
+ try {
+ const content = await ConfigManager.loadConfig(this.state.currentConfigFile);
+ this.elements.inputs.cfgEditor.value = content;
+ this.clearValidationResult();
+ } catch (error) {
+ alert(error.message);
+ }
+ }
+
+ /**
* Load config for structured editor
* @method loadConfigForStructuredEditor
*/
-
async loadConfigForStructuredEditor() {
if (!this.structuredEditor) {
console.error('Structured editor not initialized');
@@ -200,11 +246,11 @@ class Application {
}
try {
- const text = await this.apiClient.getText(`/api/config/${encodeURIComponent(this.state.currentConfigFile)}`);
+ const content = await ConfigManager.loadConfig(this.state.currentConfigFile);
// Parse configuration and get schema
const { NetworkConfiguration } = await import('./systemd-network.js');
- const config = NetworkConfiguration.fromSystemdConfiguration(text);
+ const config = NetworkConfiguration.fromSystemdConfiguration(content);
const schema = config.getSchema();
// Load schema into structured editor
@@ -220,26 +266,104 @@ class Application {
}
/**
+ * Handle add section from structured editor
+ * @method handleAddSection
+ * @param {Object} detail - Event detail
+ */
+ handleAddSection(detail) {
+ console.log('Add section:', detail);
+ // TODO: Implement section addition logic
+ // This would involve updating the schema and re-rendering
+ }
+
+ /**
+ * Handle remove section from structured editor
+ * @method handleRemoveSection
+ * @param {Object} detail - Event detail
+ */
+ handleRemoveSection(detail) {
+ console.log('Remove section:', detail);
+ // TODO: Implement section removal logic
+ // This would involve updating the schema and re-rendering
+ }
+
+ /**
+ * Handle validate configuration
+ * @method handleValidateConfig
+ */
+ async handleValidateConfig() {
+ const name = this.state.currentConfigFile;
+ if (!name) {
+ alert('Please select a configuration file first.');
+ return;
+ }
+
+ let content;
+ if (this.state.editorMode === EditorMode.RAW) {
+ content = this.elements.inputs.cfgEditor.value;
+ } else if (this.structuredEditor) {
+ content = this.structuredEditor.getConfigurationText();
+ } else {
+ alert('Structured editor not available');
+ return;
+ }
+
+ this.setValidationResult(ValidationState.PENDING);
+
+ try {
+ const result = await ConfigManager.validateConfig(name, content);
+
+ if (result.ok) {
+ this.setValidationResult(ValidationState.SUCCESS);
+ } else {
+ this.setValidationResult(ValidationState.ERROR, result.output);
+ }
+ } catch (error) {
+ this.setValidationResult(ValidationState.ERROR, error.message);
+ }
+ }
+
+ /**
+ * Set validation result
+ * @method setValidationResult
+ * @param {Symbol} state - Validation state
+ * @param {string} [message] - Additional message
+ */
+ setValidationResult(state, message = '') {
+ this.elements.outputs.validateResult.textContent = ConfigManager.getValidationMessage(state, message);
+ this.elements.outputs.validateResult.className = ConfigManager.getValidationClass(state);
+ }
+
+ /**
+ * Clear validation result
+ * @method clearValidationResult
+ */
+ clearValidationResult() {
+ this.elements.outputs.validateResult.textContent = '';
+ this.elements.outputs.validateResult.className = '';
+ }
+
+ /**
* Set editor mode (raw or structured)
* @method setEditorMode
- * @param {string} mode - Editor mode
+ * @param {Symbol} mode - Editor mode
*/
setEditorMode(mode) {
this.state.editorMode = mode;
// Update UI
if (this.elements.editorButtons?.raw && this.elements.editorButtons?.structured) {
- this.elements.editorButtons.raw.classList.toggle('active', mode === 'raw');
- this.elements.editorButtons.structured.classList.toggle('active', mode === 'structured');
+ this.elements.editorButtons.raw.classList.toggle('active', mode === EditorMode.RAW);
+ this.elements.editorButtons.structured.classList.toggle('active', mode === EditorMode.STRUCTURED);
}
if (this.elements.editorContainers?.raw && this.elements.editorContainers?.structured) {
- this.elements.editorContainers.raw.style.display = mode === 'raw' ? 'block' : 'none';
- this.elements.editorContainers.structured.style.display = mode === 'structured' ? 'block' : 'none';
+ this.elements.editorContainers.raw.style.display = mode === EditorMode.RAW ? 'block' : 'none';
+ this.elements.editorContainers.structured.style.display = mode === EditorMode.STRUCTURED ? 'block' : 'none';
}
// If switching to structured mode and we have a config file loaded, load it
- if (mode === 'structured' && this.state.currentConfigFile && this.structuredEditor) {
+ if (mode === EditorMode.STRUCTURED && this.state.currentConfigFile && this.structuredEditor) {
this.loadConfigForStructuredEditor();
}
}
@@ -263,7 +387,7 @@ class Application {
try {
let content;
- if (this.state.editorMode === 'raw') {
+ if (this.state.editorMode === EditorMode.RAW) {
content = this.elements.inputs.cfgEditor.value;
} else if (this.structuredEditor) {
content = this.structuredEditor.getConfigurationText();
@@ -271,12 +395,12 @@ class Application {
throw new Error('Structured editor not available');
}
- const result = await this.apiClient.post('/api/save', { name, content, restart });
+ const result = await ConfigManager.saveConfig(name, content, restart);
alert(`Saved: ${result.status ?? 'ok'}`);
// Refresh the config in structured editor if needed
- if (this.state.editorMode === 'structured' && this.structuredEditor) {
- await this.structuredEditor.loadConfiguration(content, name);
+ if (this.state.editorMode === EditorMode.STRUCTURED && this.structuredEditor) {
+ await this.loadConfigForStructuredEditor();
}
} catch (error) {
alert(`Save failed: ${error.message}`);
@@ -307,7 +431,7 @@ class Application {
// Load panel-specific data
const panelActions = {
status: () => this.loadStatus(),
- configs: () => this.configManager.refreshConfigs(),
+ configs: () => this.handleRefreshConfigs(),
logs: () => this.loadLogs(),
};
@@ -320,14 +444,29 @@ class Application {
*/
async loadStatus() {
try {
- const data = await this.apiClient.get('/api/status');
+ const data = await ApiClient.getNetworkStatus();
this.state.interfaces = data.Interfaces ?? [];
- this.interfaceRenderer.renderInterfaceTabs(this.state.interfaces);
+
+ // Render interface tabs
+ const tabsHTML = InterfaceRenderer.renderInterfaceTabs(this.state.interfaces, this.state.currentInterface);
+ this.elements.outputs.ifaceTabs.innerHTML = tabsHTML;
// Show first interface by default
if (this.state.interfaces.length > 0 && !this.state.currentInterface) {
- this.interfaceRenderer.showInterfaceDetails(this.state.interfaces[0]);
+ this.showInterfaceDetails(this.state.interfaces[0]);
}
+
+ // Add event listeners to tabs
+ this.elements.outputs.ifaceTabs.querySelectorAll('.interface-tab').forEach(tab => {
+ tab.addEventListener('click', (event) => {
+ const ifaceName = event.currentTarget.dataset.interface;
+ const iface = this.state.interfaces.find(i => i.Name === ifaceName);
+ if (iface) {
+ this.showInterfaceDetails(iface);
+ }
+ });
+ });
+
} catch (error) {
this.elements.outputs.ifaceDetails.innerHTML =
`<div class="error-message">Error loading status: ${error.message}</div>`;
@@ -335,12 +474,29 @@ class Application {
}
/**
+ * Show interface details
+ * @method showInterfaceDetails
+ * @param {Object} iface - Interface object
+ */
+ showInterfaceDetails(iface) {
+ this.state.currentInterface = iface;
+
+ // Update active tab
+ this.elements.outputs.ifaceTabs.querySelectorAll('.interface-tab').forEach(tab => {
+ tab.classList.toggle('active', tab.dataset.interface === iface.Name);
+ });
+
+ const detailsHTML = InterfaceRenderer.showInterfaceDetails(iface);
+ this.elements.outputs.ifaceDetails.innerHTML = detailsHTML;
+ }
+
+ /**
* Load system logs
* @method loadLogs
*/
async loadLogs() {
try {
- const text = await this.apiClient.getText('/api/logs');
+ const text = await ApiClient.getSystemLogs();
this.elements.outputs.logsArea.textContent = text;
} catch (error) {
this.elements.outputs.logsArea.textContent = `Error: ${error.message}`;
@@ -355,7 +511,7 @@ class Application {
if (!confirm('Restart systemd-networkd? Active connections may be reset.')) return;
try {
- const result = await this.apiClient.post('/api/reload');
+ const result = await ApiClient.restartNetworkd();
this.elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`;
} catch (error) {
this.elements.outputs.cmdResult.textContent = `Error: ${error.message}`;
@@ -370,7 +526,7 @@ class Application {
if (!confirm('Reboot device now?')) return;
try {
- const result = await this.apiClient.post('/api/reboot');
+ const result = await ApiClient.rebootDevice();
this.elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`;
} catch (error) {
this.elements.outputs.cmdResult.textContent = `Error: ${error.message}`;
diff --git a/static/config-manager.js b/static/config-manager.js
index ebaf9f2..edb46b2 100644
--- a/static/config-manager.js
+++ b/static/config-manager.js
@@ -1,92 +1,102 @@
/* jshint esversion: 2024, module: true */
+import { ApiClient } from './api-client.js';
+import { ValidationState } from './enums.js';
+
/**
- * Configuration Manager for handling config files
+ * Static Configuration Manager for handling config files
* @class ConfigManager
*/
class ConfigManager {
/**
- * @param {Object} elements - DOM elements
- * @param {ApiClient} apiClient - API client instance
- * @param {Object} state - Application state
+ * Refresh configuration file list
+ * @static
+ * @returns {Promise<Array>} Array of file names
*/
- constructor(elements, apiClient, state) {
- this.elements = elements;
- this.apiClient = apiClient;
- this.state = state;
+ static async refreshConfigs() {
+ try {
+ const data = await ApiClient.getConfigFiles();
+ return data.files || [];
+ } catch (error) {
+ throw new Error(`Failed to list configs: ${error.message}`);
+ }
}
/**
- * Refresh configuration file list
- * @method refreshConfigs
+ * Load selected configuration file
+ * @static
+ * @param {string} name - File name
+ * @returns {Promise<string>} File content
*/
- async refreshConfigs() {
+ static async loadConfig(name) {
try {
- const data = await this.apiClient.get('/api/configs');
-
- this.elements.inputs.configSelect.innerHTML = '';
- data.files?.forEach(file => {
- const option = new Option(file, file);
- this.elements.inputs.configSelect.add(option);
- });
-
- if (data.files?.length > 0) {
- this.state.currentConfigFile = data.files[0];
- await this.loadConfig();
- } else {
- this.elements.inputs.cfgEditor.value = '';
- this.state.currentConfigFile = null;
- }
+ return await ApiClient.getConfigFile(name);
} catch (error) {
- alert(`Failed to list configs: ${error.message}`);
+ throw new Error(`Failed to load: ${error.message}`);
}
}
/**
- * Load selected configuration file
- * @method loadConfig
+ * Validate current configuration
+ * @static
+ * @param {string} name - File name
+ * @param {string} content - File content
+ * @returns {Promise<Object>} Validation result
*/
- async loadConfig() {
- const name = this.elements.inputs.configSelect.value;
- if (!name) return;
-
- this.state.currentConfigFile = name;
-
+ static async validateConfig(name, content) {
try {
- const text = await this.apiClient.getText(`/api/config/${encodeURIComponent(name)}`);
- this.elements.inputs.cfgEditor.value = text;
- this.elements.outputs.validateResult.textContent = '';
+ return await ApiClient.validateConfig(name, content);
} catch (error) {
- alert(`Failed to load: ${error.message}`);
+ throw new Error(`Validation failed: ${error.message}`);
}
}
/**
- * Validate current configuration
- * @method validateConfig
+ * Save current configuration
+ * @static
+ * @param {string} name - File name
+ * @param {string} content - File content
+ * @param {boolean} restart - Restart service
+ * @returns {Promise<Object>} Save result
*/
- async validateConfig() {
- const name = this.elements.inputs.configSelect.value;
- const content = this.elements.inputs.cfgEditor.value;
-
- this.elements.outputs.validateResult.textContent = 'Validating...';
- this.elements.outputs.validateResult.className = 'validation-pending';
-
+ static async saveConfig(name, content, restart) {
try {
- const result = await this.apiClient.post('/api/validate', { name, content });
-
- if (result.ok) {
- this.elements.outputs.validateResult.textContent = '✓ Configuration is valid';
- this.elements.outputs.validateResult.className = 'validation-success';
- } else {
- this.elements.outputs.validateResult.textContent = `✗ ${result.output || 'Validation failed'}`;
- this.elements.outputs.validateResult.className = 'validation-error';
- }
+ return await ApiClient.saveConfig(name, content, restart);
} catch (error) {
- this.elements.outputs.validateResult.textContent = `✗ Error: ${error.message}`;
- this.elements.outputs.validateResult.className = 'validation-error';
+ throw new Error(`Save failed: ${error.message}`);
+ }
+ }
+
+ /**
+ * Get validation state class
+ * @static
+ * @param {Symbol} state - Validation state
+ * @returns {string} CSS class
+ */
+ static getValidationClass(state) {
+ switch (state) {
+ case ValidationState.SUCCESS: return 'validation-success';
+ case ValidationState.ERROR: return 'validation-error';
+ case ValidationState.PENDING: return 'validation-pending';
+ default: return '';
+ }
+ }
+
+ /**
+ * Get validation message
+ * @static
+ * @param {Symbol} state - Validation state
+ * @param {string} [message] - Additional message
+ * @returns {string} Validation message
+ */
+ static getValidationMessage(state, message = '') {
+ switch (state) {
+ case ValidationState.SUCCESS: return '✓ Configuration is valid';
+ case ValidationState.ERROR: return `✗ ${message || 'Validation failed'}`;
+ case ValidationState.PENDING: return 'Validating...';
+ default: return '';
}
}
}
-export { ConfigManager }; \ No newline at end of file
+export { ConfigManager };
diff --git a/static/enums.js b/static/enums.js
new file mode 100644
index 0000000..e494e87
--- /dev/null
+++ b/static/enums.js
@@ -0,0 +1,67 @@
+/* jshint esversion: 2024, module: true */
+
+/**
+ * Application state enumerations
+ * @module Enums
+ */
+
+// Editor modes
+const EditorMode = {
+ RAW: Symbol("raw"),
+ STRUCTURED: Symbol("structured")
+};
+Object.freeze(EditorMode);
+
+// Panel types
+const PanelType = {
+ STATUS: Symbol("status"),
+ CONFIGS: Symbol("configs"),
+ LOGS: Symbol("logs"),
+ COMMANDS: Symbol("commands")
+};
+Object.freeze(PanelType);
+
+// Interface states
+const InterfaceState = {
+ UP: Symbol("up"),
+ DOWN: Symbol("down"),
+ UNKNOWN: Symbol("unknown")
+};
+Object.freeze(InterfaceState);
+
+// Theme modes
+const ThemeMode = {
+ LIGHT: Symbol("light"),
+ DARK: Symbol("dark")
+};
+Object.freeze(ThemeMode);
+
+// Validation states
+const ValidationState = {
+ PENDING: Symbol("pending"),
+ SUCCESS: Symbol("success"),
+ ERROR: Symbol("error")
+};
+Object.freeze(ValidationState);
+
+// API endpoints
+const ApiEndpoints = {
+ STATUS: '/api/status',
+ CONFIGS: '/api/configs',
+ CONFIG: '/api/config',
+ VALIDATE: '/api/validate',
+ SAVE: '/api/save',
+ LOGS: '/api/logs',
+ RELOAD: '/api/reload',
+ REBOOT: '/api/reboot'
+};
+Object.freeze(ApiEndpoints);
+
+export {
+ EditorMode,
+ PanelType,
+ InterfaceState,
+ ThemeMode,
+ ValidationState,
+ ApiEndpoints
+};
diff --git a/static/index.html b/static/index.html
index 5bea6f8..752509c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -11,7 +11,7 @@
<body>
<header>
- <h2>Network Classroom</h2>
+ <h2>Network Configuration</h2>
<p class="subtitle">Educational interface for learning network configuration with systemd-networkd</p>
<button class="theme-toggle" id="themeToggle" title="Toggle theme">
<span id="themeIcon">🌙</span>
@@ -27,7 +27,7 @@
<span>⚙️</span> Configurations
</button>
<button class="nav-button" data-panel="logs">
- <span>📋</span> System Logs
+ <span>📋</span> Network Logs
</button>
<button class="nav-button accent" data-panel="commands">
<span>🔧</span> System Commands
diff --git a/static/interface-renderer.js b/static/interface-renderer.js
index 93364c8..8c01696 100644
--- a/static/interface-renderer.js
+++ b/static/interface-renderer.js
@@ -1,111 +1,92 @@
/* jshint esversion: 2024, module: true */
+import { Utils } from './utils.js';
+import { InterfaceState } from './enums.js';
+
/**
- * Interface Renderer for displaying network interfaces
+ * Static Interface Renderer for displaying network interfaces
* @class InterfaceRenderer
*/
class InterfaceRenderer {
/**
- * @param {Object} elements - DOM elements
- * @param {Object} state - Application state
- */
- constructor(elements, state) {
- this.elements = elements;
- this.state = state;
- }
-
- /**
* Render interface tabs
- * @method renderInterfaceTabs
+ * @static
* @param {Array} interfaces - Array of interface objects
+ * @param {Object} currentInterface - Current interface object
+ * @returns {string} HTML string
*/
- renderInterfaceTabs(interfaces) {
+ static renderInterfaceTabs(interfaces, currentInterface) {
if (!interfaces.length) {
- this.elements.outputs.ifaceTabs.innerHTML = '<div class="no-interfaces">No network interfaces found</div>';
- this.elements.outputs.ifaceDetails.innerHTML = '';
- return;
+ return '<div class="no-interfaces">No network interfaces found</div>';
}
- const tabsHTML = interfaces.map(iface => `
- <button class="interface-tab ${iface === this.state.currentInterface ? 'active' : ''}"
- data-interface="${iface.Name}">
- ${iface.Name}
- <span class="interface-state ${this.getStateClass(iface)}">${this.getStateText(iface)}</span>
- </button>
- `).join('');
-
- this.elements.outputs.ifaceTabs.innerHTML = `<div class="interface-tabs-container">${tabsHTML}</div>`;
-
- // Add event listeners to tabs
- this.elements.outputs.ifaceTabs.querySelectorAll('.interface-tab').forEach(tab => {
- tab.addEventListener('click', (event) => {
- const ifaceName = event.currentTarget.dataset.interface;
- const iface = interfaces.find(i => i.Name === ifaceName);
- if (iface) {
- this.showInterfaceDetails(iface);
- }
- });
- });
+ const tabsHTML = interfaces.map(iface => {
+ const state = Utils.getInterfaceState(iface);
+ const stateClass = Utils.getStateClass(state);
+ const stateText = Utils.getStateText(iface);
+ const isActive = iface === currentInterface;
+
+ return `
+ <button class="interface-tab ${isActive ? 'active' : ''}"
+ data-interface="${Utils.sanitizeHTML(iface.Name)}">
+ ${Utils.sanitizeHTML(iface.Name)}
+ <span class="interface-state ${stateClass}">${Utils.sanitizeHTML(stateText)}</span>
+ </button>
+ `;
+ }).join('');
+
+ return `<div class="interface-tabs-container">${tabsHTML}</div>`;
}
/**
- * Show detailed interface information with abbreviations
- * @method showInterfaceDetails
+ * Show detailed interface information
+ * @static
* @param {Object} iface - Interface object
+ * @returns {string} HTML string
*/
- showInterfaceDetails(iface) {
- this.state.currentInterface = iface;
-
- // Update active tab
- this.elements.outputs.ifaceTabs.querySelectorAll('.interface-tab').forEach(tab => {
- tab.classList.toggle('active', tab.dataset.interface === iface.Name);
- });
-
- const detailsHTML = `
- <div class="interface-detail-grid">
- ${this.renderDetailRow('Link File', iface.LinkFile)}
- ${this.renderDetailRow('Network File', iface.NetworkFile)}
- ${this.renderDetailRow('State', iface.State, this.getStateClass(iface))}
- ${this.renderDetailRow('Online State', iface.OnlineState)}
- ${this.renderDetailRow('Type', iface.Type)}
- ${this.renderDetailRow('Path', iface.Path)}
- ${this.renderDetailRow('Driver', iface.Driver)}
- ${this.renderDetailRow('Vendor', iface.Vendor)}
- ${this.renderDetailRow('Model', iface.Model)}
- ${this.renderDetailRow('Hardware Address', this.arrayToMac(iface.HardwareAddress))}
- ${this.renderDetailRow('MTU', iface.MTU ? `${iface.MTU} (min: ${iface.MTUMin ?? '?'}, max: ${iface.MTUMax ?? '?'})` : '')}
- ${this.renderDetailRow('QDisc', iface.QDisc)}
- ${this.renderDetailRow('IPv6 Address Generation Mode', iface.IPv6AddressGenerationMode)}
- ${this.renderDetailRow('Number of Queues (Tx/Rx)', iface.Queues ? `${iface.Queues.Tx ?? '?'}/${iface.Queues.Rx ?? '?'}` : '')}
- ${this.renderDetailRow('Auto negotiation', iface.AutoNegotiation ? 'yes' : 'no')}
- ${this.renderDetailRow('Speed', iface.Speed)}
- ${this.renderDetailRow('Duplex', iface.Duplex)}
- ${this.renderDetailRow('Port', iface.Port)}
- ${this.renderDetailRow('Address', this.renderAddressList(iface.Addresses))}
- ${this.renderDetailRow('DNS', this.renderDNSServerList(iface.DNS))}
- ${this.renderDetailRow('NTP', iface.NTP)}
- ${this.renderDetailRow('Activation Policy', iface.ActivationPolicy)}
- ${this.renderDetailRow('Required For Online', iface.RequiredForOnline ? 'yes' : 'no')}
- ${this.renderDetailRow('Connected To', iface.ConnectedTo)}
- ${this.renderDetailRow('Offered DHCP leases', this.renderDHCPLeases(iface.DHCPLeases))}
- </div>
- `;
-
- this.elements.outputs.ifaceDetails.innerHTML = detailsHTML;
+ static showInterfaceDetails(iface) {
+ const details = [
+ InterfaceRenderer.renderDetailRow('Link File', iface.LinkFile),
+ InterfaceRenderer.renderDetailRow('Network File', iface.NetworkFile),
+ InterfaceRenderer.renderDetailRow('State', iface.State, Utils.getStateClass(Utils.getInterfaceState(iface))),
+ InterfaceRenderer.renderDetailRow('Online State', iface.OnlineState),
+ InterfaceRenderer.renderDetailRow('Type', iface.Type),
+ InterfaceRenderer.renderDetailRow('Path', iface.Path),
+ InterfaceRenderer.renderDetailRow('Driver', iface.Driver),
+ InterfaceRenderer.renderDetailRow('Vendor', iface.Vendor),
+ InterfaceRenderer.renderDetailRow('Model', iface.Model),
+ InterfaceRenderer.renderDetailRow('Hardware Address', Utils.arrayToMac(iface.HardwareAddress)),
+ InterfaceRenderer.renderDetailRow('MTU', iface.MTU ? `${iface.MTU} (min: ${iface.MTUMin ?? '?'}, max: ${iface.MTUMax ?? '?'})` : ''),
+ InterfaceRenderer.renderDetailRow('QDisc', iface.QDisc),
+ InterfaceRenderer.renderDetailRow('IPv6 Address Generation Mode', iface.IPv6AddressGenerationMode),
+ InterfaceRenderer.renderDetailRow('Number of Queues (Tx/Rx)', iface.Queues ? `${iface.Queues.Tx ?? '?'}/${iface.Queues.Rx ?? '?'}` : ''),
+ InterfaceRenderer.renderDetailRow('Auto negotiation', iface.AutoNegotiation ? 'yes' : 'no'),
+ InterfaceRenderer.renderDetailRow('Speed', iface.Speed),
+ InterfaceRenderer.renderDetailRow('Duplex', iface.Duplex),
+ InterfaceRenderer.renderDetailRow('Port', iface.Port),
+ InterfaceRenderer.renderDetailRow('Address', InterfaceRenderer.renderAddressList(iface.Addresses)),
+ InterfaceRenderer.renderDetailRow('DNS', InterfaceRenderer.renderDNSServerList(iface.DNS)),
+ InterfaceRenderer.renderDetailRow('NTP', iface.NTP),
+ InterfaceRenderer.renderDetailRow('Activation Policy', iface.ActivationPolicy),
+ InterfaceRenderer.renderDetailRow('Required For Online', iface.RequiredForOnline ? 'yes' : 'no'),
+ InterfaceRenderer.renderDetailRow('Connected To', iface.ConnectedTo),
+ InterfaceRenderer.renderDetailRow('Offered DHCP leases', InterfaceRenderer.renderDHCPLeases(iface.DHCPLeases))
+ ].filter(Boolean).join('');
+
+ return `<div class="interface-detail-grid">${details}</div>`;
}
/**
* Render a detail row with abbreviations
- * @method renderDetailRow
+ * @static
* @param {string} label - Row label
* @param {string} value - Row value
* @param {string} [valueClass] - CSS class for value
* @returns {string} HTML string
*/
- renderDetailRow(label, value, valueClass = '') {
+ static renderDetailRow(label, value, valueClass = '') {
if (!value) return '';
- // Add abbreviations for common networking terms
const abbreviations = {
'MTU': 'Maximum Transmission Unit',
'QDisc': 'Queueing Discipline',
@@ -126,125 +107,56 @@ class InterfaceRenderer {
return `
<div class="detail-row">
<span class="detail-label">${abbrLabel}:</span>
- <span class="detail-value ${valueClass}">${value}</span>
+ <span class="detail-value ${valueClass}">${Utils.sanitizeHTML(value)}</span>
</div>
`;
}
/**
* Render address list
- * @method renderAddressList
+ * @static
* @param {Array} addresses - Array of addresses
* @returns {string} Formatted addresses
*/
- renderAddressList(addresses) {
+ static renderAddressList(addresses) {
if (!addresses?.length) return '';
return addresses.map(addr => {
- const ip = this.ipFromArray(addr);
- return ip ? `<div class="address-item">${ip}</div>` : '';
+ const ip = Utils.ipFromArray(addr);
+ return ip ? `<div class="address-item">${Utils.sanitizeHTML(ip)}</div>` : '';
}).join('');
}
/**
* Render DNS server list
- * @method renderDNSServerList
+ * @static
* @param {Array} dnsServers - Array of DNS servers
* @returns {string} Formatted DNS servers
*/
- renderDNSServerList(dnsServers) {
+ static renderDNSServerList(dnsServers) {
if (!dnsServers?.length) return '';
return dnsServers.map(dns => {
- const server = this.ipFromArray(dns.Address ?? dns);
- return server ? `<div class="dns-item">${server}</div>` : '';
+ const server = Utils.ipFromArray(dns.Address ?? dns);
+ return server ? `<div class="dns-item">${Utils.sanitizeHTML(server)}</div>` : '';
}).join('');
}
/**
* Render DHCP leases
- * @method renderDHCPLeases
+ * @static
* @param {Array} leases - Array of DHCP leases
* @returns {string} Formatted leases
*/
- renderDHCPLeases(leases) {
+ static renderDHCPLeases(leases) {
if (!leases?.length) return '';
return leases.map(lease => {
const ip = lease.IP ?? lease;
const to = lease.To ?? lease.MAC ?? '';
- return `<div class="lease-item">${ip} (to ${to})</div>`;
+ return `<div class="lease-item">${Utils.sanitizeHTML(ip)} (to ${Utils.sanitizeHTML(to)})</div>`;
}).join('');
}
-
- /**
- * Get CSS class for interface state
- * @method getStateClass
- * @param {Object} iface - Interface object
- * @returns {string} CSS class
- */
- getStateClass(iface) {
- const state = iface.OperationalState ?? iface.AdministrativeState ?? iface.State ?? '';
- return state.toLowerCase().includes('up') ||
- state.toLowerCase().includes('routable') ||
- state.toLowerCase().includes('configured') ? 'state-up' : 'state-down';
- }
-
- /**
- * Get display text for interface state
- * @method getStateText
- * @param {Object} iface - Interface object
- * @returns {string} State text
- */
- getStateText(iface) {
- return iface.OperationalState ?? iface.AdministrativeState ?? iface.State ?? 'unknown';
- }
-
- /**
- * Convert byte array to MAC address
- * @method arrayToMac
- * @param {Array} bytes - Byte array
- * @returns {string} MAC address
- */
- arrayToMac(bytes) {
- if (!Array.isArray(bytes)) return '';
-
- return bytes.map(byte => byte.toString(16).padStart(2, '0')).join(':');
- }
-
- /**
- * Convert byte array to IP address
- * @method ipFromArray
- * @param {Array|Object} obj - IP data
- * @returns {string} IP address
- */
- ipFromArray(obj) {
- let bytes = null;
-
- if (Array.isArray(obj)) {
- bytes = obj;
- } else if (obj?.Address && Array.isArray(obj.Address)) {
- bytes = obj.Address;
- } else {
- return '';
- }
-
- // IPv4
- if (bytes.length === 4) {
- return bytes.join('.');
- }
-
- // IPv6
- if (bytes.length === 16) {
- const parts = [];
- for (let i = 0; i < 16; i += 2) {
- parts.push(((bytes[i] << 8) | bytes[i + 1]).toString(16));
- }
- return parts.join(':').replace(/(^|:)0+/g, '$1').replace(/:{3,}/, '::');
- }
-
- return '';
- }
}
export { InterfaceRenderer };
diff --git a/static/utils.js b/static/utils.js
new file mode 100644
index 0000000..2b0b816
--- /dev/null
+++ b/static/utils.js
@@ -0,0 +1,155 @@
+/* jshint esversion: 2024, module: true */
+
+import { InterfaceState } from './enums.js';
+
+/**
+ * Static utility functions
+ * @module Utils
+ */
+class Utils {
+ /**
+ * Convert byte array to MAC address
+ * @static
+ * @param {Array} bytes - Byte array
+ * @returns {string} MAC address
+ */
+ static arrayToMac(bytes) {
+ if (!Array.isArray(bytes)) return '';
+ return bytes.map(byte => byte.toString(16).padStart(2, '0')).join(':');
+ }
+
+ /**
+ * Convert byte array to IP address
+ * @static
+ * @param {Array|Object} obj - IP data
+ * @returns {string} IP address
+ */
+ static ipFromArray(obj) {
+ let bytes = null;
+
+ if (Array.isArray(obj)) {
+ bytes = obj;
+ } else if (obj?.Address && Array.isArray(obj.Address)) {
+ bytes = obj.Address;
+ } else {
+ return '';
+ }
+
+ // IPv4
+ if (bytes.length === 4) {
+ return bytes.join('.');
+ }
+
+ // IPv6
+ if (bytes.length === 16) {
+ const parts = [];
+ for (let i = 0; i < 16; i += 2) {
+ parts.push(((bytes[i] << 8) | bytes[i + 1]).toString(16));
+ }
+ return parts.join(':').replace(/(^|:)0+/g, '$1').replace(/:{3,}/, '::');
+ }
+
+ return '';
+ }
+
+ /**
+ * Convert route object to string
+ * @static
+ * @param {Object} route - Route object
+ * @returns {string} Route string
+ */
+ static routeToString(route) {
+ if (!route) return '';
+ const destination = route.Destination ? Utils.ipFromArray(route.Destination) : 'default';
+ const gateway = route.Gateway ? Utils.ipFromArray(route.Gateway) : '';
+ return gateway ? `${destination} → ${gateway}` : destination;
+ }
+
+ /**
+ * Get interface state from interface object
+ * @static
+ * @param {Object} iface - Interface object
+ * @returns {Symbol} Interface state
+ */
+ static getInterfaceState(iface) {
+ const state = iface.OperationalState ?? iface.AdministrativeState ?? iface.State ?? '';
+ const stateLower = state.toLowerCase();
+
+ if (stateLower.includes('up') || stateLower.includes('routable') || stateLower.includes('configured')) {
+ return InterfaceState.UP;
+ } else if (stateLower.includes('down') || stateLower.includes('off')) {
+ return InterfaceState.DOWN;
+ } else {
+ return InterfaceState.UNKNOWN;
+ }
+ }
+
+ /**
+ * Get CSS class for interface state
+ * @static
+ * @param {Symbol} state - Interface state
+ * @returns {string} CSS class
+ */
+ static getStateClass(state) {
+ switch (state) {
+ case InterfaceState.UP: return 'state-up';
+ case InterfaceState.DOWN: return 'state-down';
+ default: return 'state-unknown';
+ }
+ }
+
+ /**
+ * Get display text for interface state
+ * @static
+ * @param {Object} iface - Interface object
+ * @returns {string} State text
+ */
+ static getStateText(iface) {
+ return iface.OperationalState ?? iface.AdministrativeState ?? iface.State ?? 'unknown';
+ }
+
+ /**
+ * Sanitize HTML string
+ * @static
+ * @param {string} str - String to sanitize
+ * @returns {string} Sanitized string
+ */
+ static sanitizeHTML(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+ }
+
+ /**
+ * Create DOM element from HTML string
+ * @static
+ * @param {string} html - HTML string
+ * @returns {HTMLElement} DOM element
+ */
+ static createElementFromHTML(html) {
+ const template = document.createElement('template');
+ template.innerHTML = html.trim();
+ return template.content.firstElementChild;
+ }
+
+ /**
+ * Debounce function
+ * @static
+ * @param {Function} func - Function to debounce
+ * @param {number} wait - Wait time in ms
+ * @returns {Function} Debounced function
+ */
+ static debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+ }
+}
+
+export { Utils }; \ No newline at end of file