summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-09-28 13:30:59 +0300
committerPetri Hienonen <petri.hienonen@gmail.com>2025-09-28 13:30:59 +0300
commita1862888a7818ae9663b02dda48d25ef5f2ab6a6 (patch)
tree8f6ff541135d369cc6f58839a4534baf723e4abe
parent2bf20fe39cd70b25f89ccd3b40d06895f25a0833 (diff)
downloadnetwork-a1862888a7818ae9663b02dda48d25ef5f2ab6a6.tar.zst
Iteration 2
-rw-r--r--static/app.js660
-rw-r--r--static/functions.js184
-rw-r--r--static/index.html41
-rw-r--r--static/structured-editor.js253
-rw-r--r--static/styles.css430
-rw-r--r--static/systemd-network.js564
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
+};