diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-09-28 13:30:59 +0300 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-09-28 13:30:59 +0300 |
| commit | a1862888a7818ae9663b02dda48d25ef5f2ab6a6 (patch) | |
| tree | 8f6ff541135d369cc6f58839a4534baf723e4abe | |
| parent | 2bf20fe39cd70b25f89ccd3b40d06895f25a0833 (diff) | |
| download | network-a1862888a7818ae9663b02dda48d25ef5f2ab6a6.tar.zst | |
Iteration 2
| -rw-r--r-- | static/app.js | 660 | ||||
| -rw-r--r-- | static/functions.js | 184 | ||||
| -rw-r--r-- | static/index.html | 41 | ||||
| -rw-r--r-- | static/structured-editor.js | 253 | ||||
| -rw-r--r-- | static/styles.css | 430 | ||||
| -rw-r--r-- | static/systemd-network.js | 564 |
6 files changed, 1903 insertions, 229 deletions
diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..54c1681 --- /dev/null +++ b/static/app.js @@ -0,0 +1,660 @@ +/* jshint esversion: 2024, module: true */ + +// import { StructuredEditor } from "./structured-editor.js"; + +/** + * Network Classroom Web UI - Modern ES2024 Implementation + * @module NetworkUI + */ + +// Global state +const state = { + currentInterface: null, + interfaces: [], + theme: localStorage.getItem("network-ui-theme") || "dark", + structuredEditor: null, + editorMode: "raw", +}; + +/** + * DOM Elements cache + * @type {Object} + */ +const elements = Object.freeze({ + themeToggle: document.getElementById("themeToggle"), + themeIcon: document.getElementById("themeIcon"), + panels: Object.freeze({ + status: document.getElementById("panelStatus"), + configs: document.getElementById("panelConfigs"), + logs: document.getElementById("panelLogs"), + commands: document.getElementById("panelCommands"), + }), + buttons: Object.freeze({ + 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: Object.freeze({ + configSelect: document.getElementById("configSelect"), + cfgEditor: document.getElementById("cfgEditor"), + restartAfterSave: document.getElementById("restartAfterSave"), + }), + outputs: Object.freeze({ + ifaceTabs: document.getElementById("interfaceTabs"), + ifaceDetails: document.getElementById("interfaceDetails"), + validateResult: document.getElementById("validateResult"), + logsArea: document.getElementById("logsArea"), + cmdResult: document.getElementById("cmdResult"), + }), +}); + +/** + * Initialize the application + * @function init + */ +const init = () => { + applyTheme(state.theme); + setupEventListeners(); + loadStatus(); +}; + +/** + * Toggle between light and dark themes + * @function toggleTheme + */ +const toggleTheme = () => { + const newTheme = state.theme === "dark" ? "light" : "dark"; + applyTheme(newTheme); +}; + +/** + * Apply theme to document + * @function applyTheme + * @param {string} theme - Theme name ('light' or 'dark') + */ +const applyTheme = (theme) => { + document.documentElement.setAttribute("data-theme", theme); + state.theme = theme; + localStorage.setItem("network-ui-theme", theme); + + // Update theme icon + if (elements.themeIcon) { + elements.themeIcon.textContent = theme === "dark" ? "☀️" : "🌙"; + } +}; + +/** + * Set up all event listeners + * @function setupEventListeners + */ +const setupEventListeners = () => { + // Navigation + elements.themeToggle?.addEventListener("click", toggleTheme); + elements.buttons.nav.forEach((button) => { + button.addEventListener("click", (event) => { + show(event.currentTarget.dataset.panel); + }); + }); + + // Status panel + elements.buttons.refreshStatus?.addEventListener("click", loadStatus); + + // Configs panel + elements.buttons.refreshConfigs?.addEventListener("click", refreshConfigs); + elements.buttons.saveConfig?.addEventListener("click", saveConfig); + elements.buttons.validateConfig?.addEventListener("click", validateConfig); + elements.inputs.configSelect?.addEventListener("change", loadConfig); + + // Logs panel + elements.buttons.refreshLogs?.addEventListener("click", loadLogs); + + // Commands panel + elements.buttons.restartNetworkd?.addEventListener("click", restartNetworkd); + elements.buttons.rebootDevice?.addEventListener("click", rebootDevice); + + // Touch support + document.addEventListener("touchstart", handleTouchStart, { passive: true }); +}; + +/** + * Handle touch events for better mobile support + * @function handleTouchStart + * @param {TouchEvent} event + */ +const 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 + * @function show + * @param {string} panel - Panel to show + */ +const show = (panel) => { + // Hide all panels and remove active class from buttons + Object.values(elements.panels).forEach((p) => p?.classList.remove("active")); + elements.buttons.nav.forEach((btn) => btn?.classList.remove("active")); + + // Show selected panel and activate button + elements.panels[panel]?.classList.add("active"); + document.querySelector(`[data-panel="${panel}"]`)?.classList.add("active"); + + // Load panel-specific data + const panelActions = { + status: loadStatus, + configs: refreshConfigs, + logs: loadLogs, + }; + + panelActions[panel]?.(); +}; + +/** + * API utility function + * @function api + * @param {string} path - API endpoint + * @param {Object} [options] - Fetch options + * @returns {Promise<Response>} + */ +const api = async (path, options = {}) => { + const response = await fetch(path, options); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`${response.status} ${text}`); + } + + return response; +}; + +/** + * Load and display network status + * @function loadStatus + */ +const loadStatus = async () => { + try { + const response = await api("/api/status"); + const data = await response.json(); + state.interfaces = data.Interfaces ?? []; + renderInterfaceTabs(state.interfaces); + + // Show first interface by default + if (state.interfaces.length > 0 && !state.currentInterface) { + showInterfaceDetails(state.interfaces[0]); + } + } catch (error) { + elements.outputs.ifaceDetails.innerHTML = `<div class="error-message">Error loading status: ${error.message}</div>`; + } +}; + +/** + * Render interface tabs + * @function renderInterfaceTabs + * @param {Array} interfaces - Array of interface objects + */ +const renderInterfaceTabs = (interfaces) => { + if (!interfaces.length) { + elements.outputs.ifaceTabs.innerHTML = + '<div class="no-interfaces">No network interfaces found</div>'; + elements.outputs.ifaceDetails.innerHTML = ""; + return; + } + + const tabsHTML = interfaces + .map( + (iface) => ` + <button class="interface-tab ${iface === state.currentInterface ? "active" : ""}" + data-interface="${iface.Name}"> + ${iface.Name} + <span class="interface-state ${getStateClass(iface)}">${getStateText(iface)}</span> + </button> + `, + ) + .join(""); + + elements.outputs.ifaceTabs.innerHTML = `<div class="interface-tabs-container">${tabsHTML}</div>`; + + // Add event listeners to tabs + 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) { + showInterfaceDetails(iface); + } + }); + }); +}; + +/** + * Show detailed interface information with abbreviations + * @function showInterfaceDetails + * @param {Object} iface - Interface object + */ +const showInterfaceDetails = (iface) => { + state.currentInterface = iface; + + // Update active tab + elements.outputs.ifaceTabs + .querySelectorAll(".interface-tab") + .forEach((tab) => { + tab.classList.toggle("active", tab.dataset.interface === iface.Name); + }); + + const detailsHTML = ` + <div class="interface-detail-grid"> + ${renderDetailRow("Link File", iface.LinkFile)} + ${renderDetailRow("Network File", iface.NetworkFile)} + ${renderDetailRow("State", iface.State, getStateClass(iface))} + ${renderDetailRow("Online State", iface.OnlineState)} + ${renderDetailRow("Type", iface.Type)} + ${renderDetailRow("Path", iface.Path)} + ${renderDetailRow("Driver", iface.Driver)} + ${renderDetailRow("Vendor", iface.Vendor)} + ${renderDetailRow("Model", iface.Model)} + ${renderDetailRow("Hardware Address", arrayToMac(iface.HardwareAddress))} + ${renderDetailRow("MTU", iface.MTU ? `${iface.MTU} (min: ${iface.MTUMin ?? "?"}, max: ${iface.MTUMax ?? "?"})` : "")} + ${renderDetailRow("QDisc", iface.QDisc)} + ${renderDetailRow("IPv6 Address Generation Mode", iface.IPv6AddressGenerationMode)} + ${renderDetailRow("Number of Queues (Tx/Rx)", iface.Queues ? `${iface.Queues.Tx ?? "?"}/${iface.Queues.Rx ?? "?"}` : "")} + ${renderDetailRow("Auto negotiation", iface.AutoNegotiation ? "yes" : "no")} + ${renderDetailRow("Speed", iface.Speed)} + ${renderDetailRow("Duplex", iface.Duplex)} + ${renderDetailRow("Port", iface.Port)} + ${renderDetailRow("Address", renderAddressList(iface.Addresses))} + ${renderDetailRow("DNS", renderDNSServerList(iface.DNS))} + ${renderDetailRow("NTP", iface.NTP)} + ${renderDetailRow("Activation Policy", iface.ActivationPolicy)} + ${renderDetailRow("Required For Online", iface.RequiredForOnline ? "yes" : "no")} + ${renderDetailRow("Connected To", iface.ConnectedTo)} + ${renderDetailRow("Offered DHCP leases", renderDHCPLeases(iface.DHCPLeases))} + </div> + `; + + elements.outputs.ifaceDetails.innerHTML = detailsHTML; +}; + +/** + * Render a detail row with abbreviations + * @function renderDetailRow + * @param {string} label - Row label + * @param {string} value - Row value + * @param {string} [valueClass] - CSS class for value + * @returns {string} HTML string + */ +const renderDetailRow = (label, value, valueClass = "") => { + if (!value) return ""; + + // Add abbreviations for common networking terms + const abbreviations = { + MTU: "Maximum Transmission Unit", + QDisc: "Queueing Discipline", + Tx: "Transmit", + Rx: "Receive", + DNS: "Domain Name System", + NTP: "Network Time Protocol", + DHCP: "Dynamic Host Configuration Protocol", + MAC: "Media Access Control", + IP: "Internet Protocol", + IPv6: "Internet Protocol version 6", + }; + + const abbrLabel = Object.keys(abbreviations).includes(label) + ? `<abbr title="${abbreviations[label]}">${label}</abbr>` + : label; + + return ` + <div class="detail-row"> + <span class="detail-label">${abbrLabel}:</span> + <span class="detail-value ${valueClass}">${value}</span> + </div> + `; +}; + +/** + * Render address list + * @function renderAddressList + * @param {Array} addresses - Array of addresses + * @returns {string} Formatted addresses + */ +const renderAddressList = (addresses) => { + if (!addresses?.length) return ""; + + return addresses + .map((addr) => { + const ip = ipFromArray(addr); + return ip ? `<div class="address-item">${ip}</div>` : ""; + }) + .join(""); +}; + +/** + * Render DNS server list + * @function renderDNSServerList + * @param {Array} dnsServers - Array of DNS servers + * @returns {string} Formatted DNS servers + */ +const renderDNSServerList = (dnsServers) => { + if (!dnsServers?.length) return ""; + + return dnsServers + .map((dns) => { + const server = ipFromArray(dns.Address ?? dns); + return server ? `<div class="dns-item">${server}</div>` : ""; + }) + .join(""); +}; + +/** + * Render DHCP leases + * @function renderDHCPLeases + * @param {Array} leases - Array of DHCP leases + * @returns {string} Formatted leases + */ +const 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>`; + }) + .join(""); +}; + +/** + * Get CSS class for interface state + * @function getStateClass + * @param {Object} iface - Interface object + * @returns {string} CSS class + */ +const 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 + * @function getStateText + * @param {Object} iface - Interface object + * @returns {string} State text + */ +const getStateText = (iface) => { + return ( + iface.OperationalState ?? + iface.AdministrativeState ?? + iface.State ?? + "unknown" + ); +}; + +/** + * Convert byte array to MAC address + * @function arrayToMac + * @param {Array} bytes - Byte array + * @returns {string} MAC address + */ +const arrayToMac = (bytes) => { + if (!Array.isArray(bytes)) return ""; + + return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(":"); +}; + +/** + * Convert byte array to IP address + * @function ipFromArray + * @param {Array|Object} obj - IP data + * @returns {string} IP address + */ +const 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 + * @function routeToString + * @param {Object} route - Route object + * @returns {string} Route string + */ +const routeToString = (route) => { + if (!route) return ""; + + const destination = route.Destination + ? ipFromArray(route.Destination) + : "default"; + const gateway = route.Gateway ? ipFromArray(route.Gateway) : ""; + + return gateway ? `${destination} → ${gateway}` : destination; +}; + +/** + * Refresh configuration file list + * @function refreshConfigs + */ +const refreshConfigs = async () => { + try { + const response = await api("/api/configs"); + const data = await response.json(); + + elements.inputs.configSelect.innerHTML = ""; + data.files?.forEach((file) => { + const option = new Option(file, file); + elements.inputs.configSelect.add(option); + }); + + if (data.files?.length > 0) { + await loadConfig(); + } else { + elements.inputs.cfgEditor.value = ""; + } + } catch (error) { + alert(`Failed to list configs: ${error.message}`); + } +}; + +/** + * Load selected configuration file + * @function loadConfig + */ +const loadConfig = async () => { + const name = elements.inputs.configSelect.value; + if (!name) return; + + try { + const response = await api(`/api/config/${encodeURIComponent(name)}`); + const text = await response.text(); + elements.inputs.cfgEditor.value = text; + elements.outputs.validateResult.textContent = ""; + } catch (error) { + alert(`Failed to load: ${error.message}`); + } +}; + +/** + * Validate current configuration + * @function validateConfig + */ +const validateConfig = async () => { + const name = elements.inputs.configSelect.value; + const content = elements.inputs.cfgEditor.value; + + elements.outputs.validateResult.textContent = "Validating..."; + elements.outputs.validateResult.className = "validation-pending"; + + try { + const response = await api("/api/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, content }), + }); + + const result = await response.json(); + + if (result.ok) { + elements.outputs.validateResult.textContent = "✓ Configuration is valid"; + elements.outputs.validateResult.className = "validation-success"; + } else { + elements.outputs.validateResult.textContent = `✗ ${result.output || "Validation failed"}`; + elements.outputs.validateResult.className = "validation-error"; + } + } catch (error) { + elements.outputs.validateResult.textContent = `✗ Error: ${error.message}`; + elements.outputs.validateResult.className = "validation-error"; + } +}; + +/** + * Save current configuration + * @function saveConfig + */ +const saveConfig = async () => { + const name = elements.inputs.configSelect.value; + const content = elements.inputs.cfgEditor.value; + const restart = elements.inputs.restartAfterSave.checked; + + if ( + !confirm( + `Save file ${name}? This will create a backup and ${restart ? "restart" : "not restart"} networkd.`, + ) + ) { + return; + } + + try { + const response = await api("/api/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, content, restart }), + }); + + const result = await response.json(); + alert(`Saved: ${result.status ?? "ok"}`); + } catch (error) { + alert(`Save failed: ${error.message}`); + } +}; + +/** + * Load system logs + * @function loadLogs + */ +const loadLogs = async () => { + try { + const response = await api("/api/logs"); + const text = await response.text(); + elements.outputs.logsArea.textContent = text; + } catch (error) { + elements.outputs.logsArea.textContent = `Error: ${error.message}`; + } +}; + +/** + * Restart networkd service + * @function restartNetworkd + */ +const restartNetworkd = async () => { + if (!confirm("Restart systemd-networkd? Active connections may be reset.")) + return; + + try { + const response = await api("/api/reload", { method: "POST" }); + const result = await response.json(); + elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; + } catch (error) { + elements.outputs.cmdResult.textContent = `Error: ${error.message}`; + } +}; + +/** + * Reboot the device + * @function rebootDevice + */ +const rebootDevice = async () => { + if (!confirm("Reboot device now?")) return; + + try { + const response = await api("/api/reboot", { method: "POST" }); + const result = await response.json(); + elements.outputs.cmdResult.textContent = `Success: ${JSON.stringify(result)}`; + } catch (error) { + elements.outputs.cmdResult.textContent = `Error: ${error.message}`; + } +}; + +// Initialize structured editor when needed +const initStructuredEditor = () => { + if (!state.structuredEditor) { + state.structuredEditor = new StructuredEditor( + document.getElementById("structuredEditorContainer"), + ); + } +}; + +// Initialize when DOM is loaded +document.addEventListener("DOMContentLoaded", init); + +// Export for module usage +export { + show, + api, + loadStatus, + refreshConfigs, + loadConfig, + validateConfig, + saveConfig, + loadLogs, + restartNetworkd, + rebootDevice, + arrayToMac, + ipFromArray, + routeToString, +}; diff --git a/static/functions.js b/static/functions.js deleted file mode 100644 index ce909e2..0000000 --- a/static/functions.js +++ /dev/null @@ -1,184 +0,0 @@ - -export function show(panel) { - document.getElementById('panelStatus').style.display = panel === 'status' ? 'block' : 'none'; - document.getElementById('panelConfigs').style.display = panel === 'configs' ? 'block' : 'none'; - document.getElementById('panelLogs').style.display = panel === 'logs' ? 'block' : 'none'; - document.getElementById('panelCommands').style.display = panel === 'commands' ? 'block' : 'none'; - if (panel === 'status') loadStatus(); - if (panel === 'configs') refreshConfigs(); - if (panel === 'logs') loadLogs(); -} - -export async function api(path, opts) { - const res = await fetch(path, opts); - if (!res.ok) { - const text = await res.text(); - throw new Error(res.status + ' ' + text); - } - return res; -} - -export async function loadStatus() { - try { - const res = await api('/api/status'); - const data = await res.json(); - renderIfaces(data.Interfaces || []); - } catch (e) { - document.getElementById('ifaces').innerHTML = '<div class="panel">Error loading status: ' + e.message + '</div>'; - } -} - -export function renderIfaces(ifaces) { - const out = []; - if (ifaces.length === 0) { - out.push('<div class="panel">No interfaces</div>'); - } - for (const ifc of ifaces) { - const name = ifc.Name || 'n/a'; - const typ = ifc.Type || ''; - const state = ifc.OperationalState || ifc.AdministrativeState || ''; - const mac = ifc.HardwareAddress ? arrayToMac(ifc.HardwareAddress) : ''; - const addrs = (ifc.Addresses || []).map(a => ipFromArray(a)).join('<br>'); - const routes = (ifc.Routes || []).map(r => routeToString(r)).join('<br>'); - const dns = (ifc.DNS || []).map(d => ipFromArray(d.Address || d)).join('<br>'); - out.push(`<div class="panel"><h4 style="margin:0">${name} <small class="small">${typ}</small></h4> - <div class="iface"> - <div><strong>State</strong><div class="small">${state}</div></div> - <div><strong>MAC</strong><div class="small">${mac}</div></div> - <div><strong>Addresses</strong><div class="small">${addrs}</div></div> - <div><strong>DNS</strong><div class="small">${dns}</div></div> - <div style="grid-column:1/-1"><strong>Routes</strong><div class="small">${routes}</div></div> - </div></div>`); - } - document.getElementById('ifaces').innerHTML = out.join(''); -} - -export function arrayToMac(a) { - if (!Array.isArray(a)) return ''; - return a.map(x => ('0' + x.toString(16)).slice(-2)).join(':'); -} - -export function ipFromArray(obj) { - // obj can be { Family: number, Address: [..] } or {Address: [...]} - let arr = null; - if (Array.isArray(obj)) arr = obj; - else if (obj && Array.isArray(obj.Address)) arr = obj.Address; - else return ''; - // detect IPv4 vs IPv6 - if (arr.length === 4) return arr.join('.'); - if (arr.length === 16) { - // IPv6 - format groups of 2 bytes - const parts = []; - for (let i = 0; i < 16; i += 2) { - parts.push(((arr[i] << 8) | arr[i + 1]).toString(16)); - } - return parts.join(':').replace(/(:0+)+/, '::'); - } - return JSON.stringify(arr); -} - -export function routeToString(r) { - if (!r) return ''; - const dest = r.Destination ? ipFromArray(r.Destination) : ''; - const pref = r.Gateway ? ipFromArray(r.Gateway) : ''; - return dest + (pref ? ' → ' + pref : ''); -} - -/* Configs editor */ -export async function refreshConfigs() { - try { - const res = await api('/api/configs'); - const data = await res.json(); - const sel = document.getElementById('configSelect'); - sel.innerHTML = ''; - data.files.forEach(f => { - const opt = document.createElement('option'); - opt.value = f; opt.textContent = f; - sel.appendChild(opt); - }); - if (data.files.length > 0) { - loadConfig(); - } else { - document.getElementById('cfgEditor').value = ''; - } - } catch (e) { - alert('Failed to list configs: ' + e.message); - } -} - -export async function loadConfig() { - const name = document.getElementById('configSelect').value; - if (!name) return; - try { - const res = await api('/api/config/' + encodeURIComponent(name)); - const txt = await res.text(); - document.getElementById('cfgEditor').value = txt; - document.getElementById('validateResult').textContent = ''; - } catch (e) { - alert('Failed to load: ' + e.message); - } -} - -export async function validateConfig() { - const name = document.getElementById('configSelect').value; - const content = document.getElementById('cfgEditor').value; - document.getElementById('validateResult').textContent = 'Validating...'; - try { - const res = await api('/api/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, content }) }); - const r = await res.json(); - if (r.ok) { - document.getElementById('validateResult').textContent = 'OK'; - } else { - document.getElementById('validateResult').textContent = 'Error: ' + (r.output || 'validation failed'); - } - } catch (e) { - document.getElementById('validateResult').textContent = 'Error: ' + e.message; - } -} - -export async function saveConfig() { - const name = document.getElementById('configSelect').value; - const content = document.getElementById('cfgEditor').value; - const restart = document.getElementById('restartAfterSave').checked; - if (!confirm('Save file ' + name + '? This will create a backup and (optionally) restart networkd.')) return; - try { - const res = await api('/api/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, content, restart }) }); - const r = await res.json(); - alert('Saved: ' + (r.status || 'ok')); - } catch (e) { - alert('Save failed: ' + e.message); - } -} - -/* Logs & commands */ -export async function loadLogs() { - try { - const res = await api('/api/logs'); - const txt = await res.text(); - document.getElementById('logsArea').textContent = txt; - } catch (e) { - document.getElementById('logsArea').textContent = 'Error: ' + e.message; - } -} - -export async function restartNetworkd() { - if (!confirm('Restart systemd-networkd? Active connections may be reset.')) return; - try { - const res = await api('/api/reload', { method: 'POST' }); - const j = await res.json(); - document.getElementById('cmdResult').textContent = JSON.stringify(j); - } catch (e) { - document.getElementById('cmdResult').textContent = 'Error: ' + e.message; - } -} - -export async function rebootDevice() { - if (!confirm('Reboot device now?')) return; - try { - const res = await api('/api/reboot', { method: 'POST' }); - const j = await res.json(); - document.getElementById('cmdResult').textContent = JSON.stringify(j); - } catch (e) { - document.getElementById('cmdResult').textContent = 'Error: ' + e.message; - } -} diff --git a/static/index.html b/static/index.html index 842775a..0e87967 100644 --- a/static/index.html +++ b/static/index.html @@ -4,7 +4,7 @@ <head> <meta charset="utf-8" /> - <title>Network UI — Control Panel</title> + <title>Network Classroom — Control Panel</title> <meta name="viewport" content="width=device-width,initial-scale=1" /> <link rel="stylesheet" href="./static/styles.css"> </head> @@ -13,20 +13,23 @@ <header> <h2>Network Classroom</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> + </button> </header> <main> - <nav> - <button class="nav-button active" onclick="show('status')"> + <nav id="mainNav"> + <button class="nav-button active" data-panel="status"> <span>🌐</span> Network Status </button> - <button class="nav-button" onclick="show('configs')"> + <button class="nav-button" data-panel="configs"> <span>⚙️</span> Configurations </button> - <button class="nav-button" onclick="show('logs')"> + <button class="nav-button" data-panel="logs"> <span>📋</span> System Logs </button> - <button class="nav-button accent" onclick="show('commands')"> + <button class="nav-button accent" data-panel="commands"> <span>🔧</span> System Commands </button> @@ -47,11 +50,12 @@ <h3 class="card-title">Network Status</h3> <p class="card-description">Live view of network interfaces and their configuration</p> </div> - <button class="button" onclick="loadStatus()"> + <button class="button" id="refreshStatus"> <span>🔄</span> Refresh </button> </div> - <div id="ifaces" class="interface-grid"></div> + <div id="interfaceTabs" class="interface-tabs"></div> + <div id="interfaceDetails" class="interface-details-container"></div> </div> </section> @@ -64,10 +68,10 @@ </p> </div> <div style="display: flex; gap: var(--spacing-s);"> - <button class="button secondary" onclick="refreshConfigs()"> + <button class="button secondary" id="refreshConfigs"> Refresh List </button> - <button class="button" onclick="saveConfig()"> + <button class="button" id="saveConfig"> <span>💾</span> Save </button> </div> @@ -77,7 +81,7 @@ <div class="card"> <div class="form-group"> <label class="form-label">Configuration File</label> - <select id="configSelect" class="select" onchange="loadConfig()"></select> + <select id="configSelect" class="select"></select> </div> <div class="form-group"> @@ -93,7 +97,7 @@ </div> <div style="display: flex; gap: var(--spacing-s); align-items: center;"> - <button class="button secondary" onclick="validateConfig()"> + <button class="button secondary" id="validateConfig"> Validate Configuration </button> <span id="validateResult"></span> @@ -108,7 +112,7 @@ <h3 class="card-title">System Logs</h3> <p class="card-description">Recent logs from systemd-networkd service</p> </div> - <button class="button" onclick="loadLogs()"> + <button class="button" id="refreshLogs"> <span>🔄</span> Refresh </button> </div> @@ -122,10 +126,10 @@ <p class="card-description">Manage network services and system state</p> <div style="display: flex; gap: var(--spacing-s); margin-top: var(--spacing-xl);"> - <button class="button" onclick="restartNetworkd()"> + <button class="button" id="restartNetworkd"> <span>🔄</span> Restart Network Service </button> - <button class="button warning" onclick="rebootDevice()"> + <button class="button warning" id="rebootDevice"> <span>⚠️</span> Reboot Device </button> </div> @@ -135,12 +139,7 @@ </section> </main> - <script type="module"> - import {show, api, loadStatus, renderIfaces, arrayToMac, ipFromArray, routeToString, refreshConfigs, loadConfig, validateConfig, saveConfig, loadLogs, restartNetworkd, rebootDevice} from "./static/functions.js"; - window.onload = function () { - loadStatus(); - } - </script> + <script type="module" src="./static/app.js"></script> </body> </html> diff --git a/static/structured-editor.js b/static/structured-editor.js new file mode 100644 index 0000000..dc46bef --- /dev/null +++ b/static/structured-editor.js @@ -0,0 +1,253 @@ +/* jshint esversion: 2024, module: true */ + +/** + * Structured Editor for systemd-networkd configuration + * @module StructuredEditor + */ + +import { NetworkConfiguration } from './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 ` + <div class="structured-editor"> + <div class="editor-sections"> + ${this._createMatchSection()} + ${this._createLinkSection()} + ${this._createNetworkSection()} + ${this._createDHCPSection()} + ${this._createAddressSections()} + ${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> + </div> + </div> + `; + } + + _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')} + </div> + </div> + `; + } + + _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')} + </div> + </div> + `; + } + + _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')} + </div> + </div> + `; + } + + _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')} + </div> + </div> + `; + } + + _createAddressSections() { + return this.config.Address.map((addr, index) => ` + <div class="config-section"> + <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> + </div> + </div> + `).join(''); + } + + _createRouteSections() { + return this.config.Route.map((route, index) => ` + <div class="config-section"> + <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> + </div> + </div> + `).join(''); + } + + _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-key="${key}" + 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(''); + + return ` + <div class="config-row"> + <label class="config-label" title="${description}"> + <abbr title="${description}">${key}</abbr>: + </label> + <select class="config-select" 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(); + } +} + +export { StructuredEditor }; diff --git a/static/styles.css b/static/styles.css index 96531df..36c0e15 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1,9 +1,14 @@ :root { - /* Fluent 2 Color Tokens */ + /* Light theme (default) */ --color-neutral-background: #f7f7f7; --color-neutral-background-selected: #e6e6e6; --color-neutral-foreground: #242424; --color-neutral-foreground-subtle: #616161; + --color-surface: #ffffff; + --border-color: #e1e1e1; + --shadow-color: rgba(0,0,0,0.12); + + /* Brand colors remain the same */ --color-brand-background: #0078d4; --color-brand-background-hover: #106ebe; --color-brand-foreground: #0078d4; @@ -13,12 +18,12 @@ --color-status-error: #d13438; --color-status-info: #0078d4; - /* Fluent 2 Shadows */ - --shadow-2: 0 0 2px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.14); - --shadow-4: 0 0 2px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.14); - --shadow-8: 0 0 2px rgba(0,0,0,0.12), 0 8px 16px rgba(0,0,0,0.14); + /* Shadows */ + --shadow-2: 0 0 2px var(--shadow-color), 0 2px 4px var(--shadow-color); + --shadow-4: 0 0 2px var(--shadow-color), 0 4px 8px var(--shadow-color); + --shadow-8: 0 0 2px var(--shadow-color), 0 8px 16px var(--shadow-color); - /* Fluent 2 Typography */ + /* Typography */ --font-weight-regular: 400; --font-weight-semibold: 600; --font-weight-bold: 700; @@ -36,26 +41,55 @@ --border-radius-large: 8px; } -* { - box-sizing: border-box; +[data-theme="dark"] { + --color-neutral-background: #1f1f1f; + --color-neutral-background-selected: #2d2d2d; + --color-neutral-foreground: #ffffff; + --color-neutral-foreground-subtle: #a0a0a0; + --color-surface: #2d2d2d; + --border-color: #404040; + --shadow-color: rgba(0,0,0,0.3); } +/* Update all color references to use theme variables */ body { font-family: "Segoe UI", system-ui, -apple-system, sans-serif; margin: 0; background-color: var(--color-neutral-background); color: var(--color-neutral-foreground); line-height: 1.4; + transition: background-color 0.3s ease, color 0.3s ease; } -/* Header */ header { - background: white; + background: var(--color-surface); padding: var(--spacing-l) var(--spacing-xl); - border-bottom: 1px solid #e1e1e1; + border-bottom: 1px solid var(--border-color); box-shadow: var(--shadow-2); } +/* Add theme toggle button */ +.theme-toggle { + position: absolute; + top: var(--spacing-l); + right: var(--spacing-xl); + background: none; + border: none; + font-size: 20px; + cursor: pointer; + padding: var(--spacing-s); + border-radius: var(--border-radius-medium); + transition: background-color 0.2s ease; +} + +.theme-toggle:hover { + background-color: var(--color-neutral-background-selected); +} + +* { + box-sizing: border-box; +} + header h2 { margin: 0 0 var(--spacing-xs) 0; font-weight: var(--font-weight-semibold); @@ -78,8 +112,8 @@ main { /* Navigation */ nav { width: 260px; - background: white; - border-right: 1px solid #e1e1e1; + background: var(--color-surface); + border-right: 1px solid var(--border-color); padding: var(--spacing-l); display: flex; flex-direction: column; @@ -105,14 +139,15 @@ nav { } .nav-button:hover { - background-color: var(--color-neutral-background); - border-color: #e1e1e1; + background-color: var(--color-neutral-background-selected); + border-color: var(--border-color); } .nav-button.active { background-color: var(--color-brand-background); color: white; font-weight: var(--font-weight-semibold); + border-color: var(--color-brand-background); } .nav-button.accent { @@ -120,19 +155,49 @@ nav { color: white; font-weight: var(--font-weight-semibold); margin-top: var(--spacing-xl); + border-color: var(--color-brand-background); } .nav-button.accent:hover { background-color: var(--color-brand-background-hover); + border-color: var(--color-brand-background-hover); } .nav-button.warning { background-color: var(--color-status-warning); color: white; + border-color: var(--color-status-warning); } .nav-button.warning:hover { background-color: #c13501; + border-color: #c13501; +} + +/* Tips Section */ +.tips-section { + margin-top: auto; + padding-top: var(--spacing-xl); + border-top: 1px solid var(--border-color); +} + +.tips-title { + font-weight: var(--font-weight-semibold); + font-size: 14px; + margin-bottom: var(--spacing-s); + color: var(--color-neutral-foreground); +} + +.tips-list { + margin: 0; + padding-left: var(--spacing-l); + font-size: 13px; + color: var(--color-neutral-foreground-subtle); + line-height: 1.5; +} + +.tips-list li { + margin-bottom: var(--spacing-xs); } /* Content Area */ @@ -147,14 +212,13 @@ section.active { display: block; } -/* Cards/Panels */ .card { - background: white; + background: var(--color-surface); border-radius: var(--border-radius-large); padding: var(--spacing-xl); margin-bottom: var(--spacing-l); box-shadow: var(--shadow-2); - border: 1px solid #e1e1e1; + border: 1px solid var(--border-color); } .card-header { @@ -225,11 +289,11 @@ section.active { } .interface-card { - background: white; + background: var(--color-surface); border-radius: var(--border-radius-large); padding: var(--spacing-xl); box-shadow: var(--shadow-2); - border: 1px solid #e1e1e1; + border: 1px solid var(--border-color); border-left: 4px solid var(--color-brand-background); } @@ -252,6 +316,7 @@ section.active { font-weight: var(--font-weight-semibold); font-size: 18px; margin: 0; + color: var(--color-neutral-foreground); } .interface-type { @@ -303,6 +368,7 @@ section.active { } /* Forms */ +/* Forms */ .form-group { margin-bottom: var(--spacing-l); } @@ -318,11 +384,12 @@ section.active { .select { width: 100%; padding: var(--spacing-m); - border: 1px solid #e1e1e1; + border: 1px solid var(--border-color); border-radius: var(--border-radius-medium); font-family: inherit; font-size: 14px; - background: white; + background: var(--color-surface); + color: var(--color-neutral-foreground); transition: border-color 0.1s ease; } @@ -335,13 +402,14 @@ section.active { width: 100%; height: 360px; padding: var(--spacing-m); - border: 1px solid #e1e1e1; + border: 1px solid var(--border-color); border-radius: var(--border-radius-medium); font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; line-height: 1.5; resize: vertical; - background: white; + background: var(--color-surface); + color: var(--color-neutral-foreground); transition: border-color 0.1s ease; } @@ -464,3 +532,317 @@ section.active { gap: var(--spacing-m); } } + +/* Interface Tabs */ +/* Interface Tabs */ +.interface-tabs-container { + display: flex; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-l); + flex-wrap: wrap; +} + +.interface-tab { + display: flex; + align-items: center; + gap: var(--spacing-s); + padding: var(--spacing-m) var(--spacing-l); + background: var(--color-neutral-background); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-medium); + font-family: inherit; + font-size: 14px; + font-weight: var(--font-weight-regular); + color: var(--color-neutral-foreground); + cursor: pointer; + transition: all 0.1s ease; +} + +.interface-tab:hover { + background: var(--color-neutral-background-selected); + border-color: var(--color-brand-background); +} + +.interface-tab.active { + background: var(--color-brand-background); + color: white; + border-color: var(--color-brand-background); +} + +.interface-state { + font-size: 12px; + padding: 2px 6px; + border-radius: 10px; + background: var(--color-neutral-foreground-subtle); + color: white; +} + +.interface-tab.active .interface-state { + background: rgba(255, 255, 255, 0.3); +} + +/* Interface Details */ +.interface-detail-grid { + display: flex; + flex-direction: column; + gap: var(--spacing-s); + font-family: 'Monaco', 'Consolas', monospace; + font-size: 13px; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-l); + padding: var(--spacing-xs) 0; + border-bottom: 1px solid #f0f0f0; +} + +.detail-row:last-child { + border-bottom: none; +} + +.detail-label { + font-weight: var(--font-weight-semibold); + color: var(--color-neutral-foreground); + min-width: 200px; + flex-shrink: 0; +} + +.detail-value { + color: var(--color-neutral-foreground-subtle); + text-align: right; + flex: 1; + word-break: break-word; +} + +/* Address and list items */ +.address-item, +.dns-item, +.lease-item { + margin-bottom: 2px; + padding: 1px 0; +} + +.address-item:last-child, +.dns-item:last-child, +.lease-item:last-child { + margin-bottom: 0; +} + +/* State badges in details */ +.detail-value.state-up { + color: var(--color-status-success); + font-weight: var(--font-weight-semibold); +} + +.detail-value.state-down { + color: var(--color-status-error); + font-weight: var(--font-weight-semibold); +} + +/* Error and empty states */ +.error-message { + padding: var(--spacing-l); + background: var(--color-status-error); + color: white; + border-radius: var(--border-radius-medium); + text-align: center; +} + +.no-interfaces { + padding: var(--spacing-xl); + text-align: center; + color: var(--color-neutral-foreground-subtle); + font-style: italic; +} + +/* Validation states */ +.validation-pending { + color: var(--color-neutral-foreground-subtle); +} + +.validation-success { + color: var(--color-status-success); + font-weight: var(--font-weight-semibold); +} + +.validation-error { + color: var(--color-status-error); + font-weight: var(--font-weight-semibold); +} + +.validation-result { + padding: var(--spacing-m); + border-radius: var(--border-radius-medium); + font-size: 14px; + margin-top: var(--spacing-m); +} + +.validation-success { + background: var(--color-status-success); + color: white; + border: 1px solid var(--color-status-success); +} + +.validation-error { + background: var(--color-status-error); + color: white; + border: 1px solid var(--color-status-error); +} + +/* Responsive improvements */ +@media (max-width: 768px) { + .detail-row { + flex-direction: column; + gap: var(--spacing-xs); + align-items: stretch; + } + + .detail-label { + min-width: auto; + font-size: 12px; + } + + .detail-value { + text-align: left; + } + + .interface-tabs-container { + flex-direction: column; + } + + .interface-tab { + justify-content: space-between; + } +} + +.interface-detail-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-s) var(--spacing-l); + font-family: 'Monaco', 'Consolas', monospace; + font-size: 13px; + align-items: start; +} + +.detail-row { + display: contents; +} + +.detail-label { + font-weight: var(--font-weight-semibold); + color: var(--color-neutral-foreground); + text-align: right; + padding: var(--spacing-xs) 0; + border-bottom: 1px solid var(--border-color); +} + +.detail-value { + color: var(--color-neutral-foreground-subtle); + text-align: left; + padding: var(--spacing-xs) 0; + border-bottom: 1px solid var(--border-color); + word-break: break-word; +} + +.detail-row:last-child .detail-label, +.detail-row:last-child .detail-value { + border-bottom: none; +} + +/* Abbreviation styling */ +abbr { + text-decoration: underline dotted; + cursor: help; +} + +/* Address and list items */ +.address-item, +.dns-item, +.lease-item { + margin-bottom: 2px; + padding: 1px 0; +} + +.address-item:last-child, +.dns-item:last-child, +.lease-item:last-child { + margin-bottom: 0; +} + +/* Structured Editor Styles */ +.structured-editor { + margin-top: var(--spacing-l); +} + +.editor-sections { + display: flex; + flex-direction: column; + gap: var(--spacing-l); +} + +.config-section { + background: var(--color-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-medium); + padding: var(--spacing-l); +} + +.config-section h4 { + margin: 0 0 var(--spacing-m) 0; + color: var(--color-brand-foreground); + font-family: monospace; +} + +.config-table { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-s) var(--spacing-m); + align-items: center; +} + +.config-row { + display: contents; +} + +.config-label { + text-align: right; + font-weight: var(--font-weight-semibold); + font-size: 13px; + color: var(--color-neutral-foreground); +} + +.config-input, +.config-select { + padding: var(--spacing-s); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-medium); + background: var(--color-surface); + color: var(--color-neutral-foreground); + font-family: inherit; + font-size: 13px; +} + +.config-input:focus, +.config-select:focus { + outline: none; + border-color: var(--color-brand-background); +} + +.button.small { + padding: var(--spacing-xs) var(--spacing-s); + font-size: 12px; + grid-column: 1 / -1; + justify-self: end; + margin-top: var(--spacing-s); +} + +.editor-actions { + display: flex; + gap: var(--spacing-s); + margin-top: var(--spacing-l); + padding-top: var(--spacing-l); + border-top: 1px solid var(--border-color); +} diff --git a/static/systemd-network.js b/static/systemd-network.js new file mode 100644 index 0000000..8c66a6d --- /dev/null +++ b/static/systemd-network.js @@ -0,0 +1,564 @@ +/* jshint esversion: 2024, module: true */ + +/** + * Systemd Network Configuration Parser + * Based on systemd.network(5) documentation + * @module SystemdNetwork + */ + +/** + * MAC Address type + * @class MACAddress + */ +class MACAddress { + /** + * @param {string} value - MAC address value + * @param {string} description - Description + */ + constructor(value = '', description = 'Hardware address') { + this.value = value; + this.type = 'mac-address'; + this.description = description; + this.pattern = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/; + } + + /** + * Validate MAC address format + * @returns {boolean} + */ + validate() { + return this.pattern.test(this.value); + } + + toString() { + return this.value; + } +} + +/** + * IPv4 Address type + * @class IPv4Address + */ +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); + } + + toString() { + return this.value; + } +} + +/** + * 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}$/; + } + + validate() { + return this.pattern.test(this.value); + } + + toString() { + return this.value; + } +} + +/** + * [Match] section configuration + * @class MatchSection + */ +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 = ''; + } +} + +/** + * [Link] section configuration + * @class LinkSection + */ +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 = ''; + } +} + +/** + * [Network] section configuration + * @class NetworkSection + */ +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 + } +} + +/** + * [DHCP] section configuration + * @class DHCPSection + */ +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 = ''; + } +} + +/** + * [Address] section configuration + * @class AddressSection + */ +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 + } +} + +/** + * [Route] section configuration + * @class RouteSection + */ +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. + } +} + +/** + * Complete network configuration + * @class NetworkConfiguration + */ +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 = []; + } + + /** + * Parse systemd network configuration from text + * @param {string} configText - Configuration file content + * @returns {NetworkConfiguration} + */ + static fromSystemdConfiguration(configText) { + const config = new NetworkConfiguration(); + const lines = configText.split('\n'); + let currentSection = null; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith('#')) continue; + + // Section header + const sectionMatch = trimmed.match(/^\[(\w+)\]$/); + if (sectionMatch) { + currentSection = sectionMatch[1].toLowerCase(); + continue; + } + + // Key-value pair + const kvMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/); + if (kvMatch && currentSection) { + const key = kvMatch[1]; + const value = kvMatch[2]; + + config._setValue(currentSection, key, value); + } + } + + return config; + } + + /** + * Set configuration value + * @private + * @param {string} section - Section name + * @param {string} key - Key name + * @param {string} value - Value + */ + _setValue(section, key, value) { + switch (section) { + case 'match': + this._setMatchValue(key, value); + break; + case 'link': + this._setLinkValue(key, value); + break; + case 'network': + this._setNetworkValue(key, value); + break; + case 'dhcp': + this._setDHCPValue(key, value); + break; + case 'address': + // Handle multiple address sections + if (!this.Address.length) this.Address.push(new AddressSection()); + this._setAddressValue(this.Address[this.Address.length - 1], 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); + 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; + } + } + + _setLinkValue(key, value) { + this.Link[key] = 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; + } + } + + _setDHCPValue(key, value) { + this.DHCP[key] = value; + } + + _setAddressValue(address, key, value) { + address[key] = value; + } + + _setRouteValue(route, key, value) { + route[key] = value; + } + + /** + * Convert to systemd network configuration format + * @returns {string} + */ + toSystemdConfiguration() { + const sections = []; + + // [Match] section + if (this._hasMatchValues()) { + sections.push('[Match]'); + sections.push(...this._formatMatchSection()); + } + + // [Link] section + if (this._hasLinkValues()) { + sections.push('[Link]'); + sections.push(...this._formatLinkSection()); + } + + // [Network] section + if (this._hasNetworkValues()) { + sections.push('[Network]'); + sections.push(...this._formatNetworkSection()); + } + + // [DHCP] section + if (this._hasDHCPValues()) { + sections.push('[DHCP]'); + sections.push(...this._formatDHCPSection()); + } + + // [Address] sections + this.Address.forEach((addr, index) => { + if (this._hasAddressValues(addr)) { + sections.push('[Address]'); + sections.push(...this._formatAddressSection(addr)); + } + }); + + // [Route] sections + this.Route.forEach((route, index) => { + if (this._hasRouteValues(route)) { + sections.push('[Route]'); + sections.push(...this._formatRouteSection(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 !== '') + ); + } + + _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() { + 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}`); + }); + + return lines; + } +} + +// Export classes +export { + MACAddress, + IPv4Address, + IPv6Address, + MatchSection, + LinkSection, + NetworkSection, + DHCPSection, + AddressSection, + RouteSection, + NetworkConfiguration +}; |
