From a1862888a7818ae9663b02dda48d25ef5f2ab6a6 Mon Sep 17 00:00:00 2001
From: Petri Hienonen
Date: Sun, 28 Sep 2025 13:30:59 +0300
Subject: Iteration 2
---
static/app.js | 660 ++++++++++++++++++++++++++++++++++++++++++++
static/functions.js | 184 ------------
static/index.html | 41 ++-
static/structured-editor.js | 253 +++++++++++++++++
static/styles.css | 430 +++++++++++++++++++++++++++--
static/systemd-network.js | 564 +++++++++++++++++++++++++++++++++++++
6 files changed, 1903 insertions(+), 229 deletions(-)
create mode 100644 static/app.js
delete mode 100644 static/functions.js
create mode 100644 static/structured-editor.js
create mode 100644 static/systemd-network.js
(limited to 'static')
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}
+ */
+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 = `Error loading status: ${error.message}
`;
+ }
+};
+
+/**
+ * Render interface tabs
+ * @function renderInterfaceTabs
+ * @param {Array} interfaces - Array of interface objects
+ */
+const renderInterfaceTabs = (interfaces) => {
+ if (!interfaces.length) {
+ elements.outputs.ifaceTabs.innerHTML =
+ 'No network interfaces found
';
+ elements.outputs.ifaceDetails.innerHTML = "";
+ return;
+ }
+
+ const tabsHTML = interfaces
+ .map(
+ (iface) => `
+
+ `,
+ )
+ .join("");
+
+ elements.outputs.ifaceTabs.innerHTML = `${tabsHTML}
`;
+
+ // 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 = `
+
+ ${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))}
+
+ `;
+
+ 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)
+ ? `${label}`
+ : label;
+
+ return `
+
+ ${abbrLabel}:
+ ${value}
+
+ `;
+};
+
+/**
+ * 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 ? `${ip}
` : "";
+ })
+ .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 ? `${server}
` : "";
+ })
+ .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 `${ip} (to ${to})
`;
+ })
+ .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 = 'Error loading status: ' + e.message + '
';
- }
-}
-
-export function renderIfaces(ifaces) {
- const out = [];
- if (ifaces.length === 0) {
- out.push('No interfaces
');
- }
- 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('
');
- const routes = (ifc.Routes || []).map(r => routeToString(r)).join('
');
- const dns = (ifc.DNS || []).map(d => ipFromArray(d.Address || d)).join('
');
- out.push(``);
- }
- 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 @@
- Network UI — Control Panel
+ Network Classroom — Control Panel
@@ -13,20 +13,23 @@
-
-
+
Refresh List
-
+
💾 Save
@@ -77,7 +81,7 @@
-
+
@@ -93,7 +97,7 @@
-
+
Validate Configuration
@@ -108,7 +112,7 @@
System Logs
Recent logs from systemd-networkd service
-
+
🔄 Refresh
@@ -122,10 +126,10 @@
Manage network services and system state
-
+
🔄 Restart Network Service
-
+
⚠️ Reboot Device
@@ -135,12 +139,7 @@
-
+