summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-09-28 12:09:33 +0300
committerPetri Hienonen <petri.hienonen@gmail.com>2025-09-28 12:09:33 +0300
commit2bf20fe39cd70b25f89ccd3b40d06895f25a0833 (patch)
tree509173d3e3ab980214046df156429f6de8236081
parent47529804bef15ed84730ff3409f0d426fcef2112 (diff)
downloadnetwork-2bf20fe39cd70b25f89ccd3b40d06895f25a0833.tar.zst
Iteration
-rw-r--r--index.html377
-rw-r--r--main.go2
-rw-r--r--requirements.json82
-rw-r--r--static/functions.js184
-rw-r--r--static/index.html146
-rw-r--r--static/styles.css466
6 files changed, 860 insertions, 397 deletions
diff --git a/index.html b/index.html
deleted file mode 100644
index f516f7c..0000000
--- a/index.html
+++ /dev/null
@@ -1,377 +0,0 @@
-<!-- index.html -->
-<!doctype html>
-<html lang="en">
-
-<head>
- <meta charset="utf-8" />
- <title>Network UI — Control Panel</title>
- <meta name="viewport" content="width=device-width,initial-scale=1" />
- <script type="module" src="https://unpkg.com/@fluentui/web-components@2.6.1/dist/web-components.min.js"></script>
- <style>
- :root {
- --panel-gap: 18px;
- }
-
- body {
- font-family: Arial, sans-serif;
- margin: 0;
- }
-
- header {
- background: #f3f3f3;
- padding: 12px 20px;
- border-bottom: 1px solid #ddd;
- }
-
- main {
- display: flex;
- height: calc(100vh - 58px);
- }
-
- nav {
- width: 220px;
- border-right: 1px solid #eee;
- padding: 16px;
- box-sizing: border-box;
- }
-
- section {
- flex: 1;
- padding: 16px;
- overflow: auto;
- box-sizing: border-box;
- }
-
- .panel {
- background: #fff;
- border: 1px solid #eee;
- padding: 12px;
- border-radius: 8px;
- margin-bottom: 12px;
- box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
- }
-
- .iface {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
- }
-
- .small {
- font-size: 0.9em;
- color: #444;
- }
-
- pre {
- background: #fafafa;
- padding: 8px;
- border-radius: 6px;
- overflow: auto;
- }
-
- textarea {
- width: 100%;
- height: 360px;
- font-family: monospace;
- font-size: 13px;
- }
-
- label {
- display: block;
- margin-bottom: 6px;
- font-weight: 600;
- }
-
- .controls {
- display: flex;
- gap: 10px;
- align-items: center;
- }
- </style>
-</head>
-
-<body>
- <header>
- <h2 style="margin:0">Systemd-networkd — Admin Panel</h2>
- <div class="small">Single page admin + teaching panel. Auth: HTTP Basic.</div>
- </header>
-
- <main>
- <nav>
- <fluent-button appearance="secondary" id="btnStatus" style="width:100%; margin-bottom:8px"
- onclick="show('status')">Network Status</fluent-button>
- <fluent-button appearance="secondary" id="btnConfigs" style="width:100%; margin-bottom:8px"
- onclick="show('configs')">Configs</fluent-button>
- <fluent-button appearance="secondary" id="btnLogs" style="width:100%; margin-bottom:8px"
- onclick="show('logs')">Logs</fluent-button>
- <fluent-button appearance="accent" id="btnCommands" style="width:100%; margin-top:14px"
- onclick="show('commands')">Commands</fluent-button>
- <div style="margin-top:18px; font-size:0.9em; color:#555">
- <strong>Tips</strong>
- <ul>
- <li>Validate before saving (systemd-analyze verify).</li>
- <li>Backups are created automatically on save.</li>
- </ul>
- </div>
- </nav>
-
- <section id="panelStatus" style="display:block;">
- <div class="panel">
- <div style="display:flex; justify-content:space-between; align-items:center;">
- <div>
- <h3 style="margin:0">Network Status</h3>
- <div class="small">Parsed output from <code>networkctl status --json=short</code></div>
- </div>
- <div><fluent-button appearance="accent" onclick="loadStatus()">Refresh</fluent-button></div>
- </div>
- </div>
- <div id="ifaces"></div>
- </section>
-
- <section id="panelConfigs" style="display:none;">
- <div class="panel">
- <div style="display:flex; justify-content:space-between; align-items:center;">
- <div>
- <h3 style="margin:0">Configurations</h3>
- <div class="small">Edit <code>/etc/systemd/network/*.network</code> files. Validate -> Save ->
- (optional) Restart</div>
- </div>
- <div class="controls">
- <fluent-button onclick="refreshConfigs()">Refresh list</fluent-button>
- <fluent-button appearance="accent" onclick="saveConfig()">Save</fluent-button>
- </div>
- </div>
- </div>
-
- <div class="panel">
- <label for="configSelect">Config file</label>
- <select id="configSelect" style="width:100%; padding:8px; margin-bottom:8px"
- onchange="loadConfig()"></select>
- <label for="cfgEditor">Contents</label>
- <textarea id="cfgEditor" spellcheck="false"></textarea>
- <div style="margin-top:8px;">
- <input type="checkbox" id="restartAfterSave" /> <label for="restartAfterSave"
- style="display:inline; font-weight:normal">Restart systemd-networkd after save</label>
- <fluent-button style="margin-left:8px" onclick="validateConfig()">Validate</fluent-button>
- <span id="validateResult" class="small" style="margin-left:12px"></span>
- </div>
- </div>
- </section>
-
- <section id="panelLogs" style="display:none;">
- <div class="panel">
- <div style="display:flex; justify-content:space-between;">
- <div>
- <h3 style="margin:0">systemd-networkd Logs</h3>
- <div class="small">journalctl -u systemd-networkd.service</div>
- </div>
- <fluent-button appearance="accent" onclick="loadLogs()">Refresh</fluent-button>
- </div>
- </div>
- <div class="panel">
- <pre id="logsArea">[no logs]</pre>
- </div>
- </section>
-
- <section id="panelCommands" style="display:none;">
- <div class="panel">
- <h3 style="margin-top:0">Commands</h3>
- <div class="small" style="margin-bottom:8px">Restart networkd or reboot device.</div>
- <div class="controls">
- <fluent-button appearance="accent" onclick="restartNetworkd()">Restart networkd</fluent-button>
- <fluent-button appearance="warning" onclick="rebootDevice()">Reboot device</fluent-button>
- </div>
- <div id="cmdResult" style="margin-top:12px"></div>
- </div>
- </section>
- </main>
-
- <script>
- 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();
- }
-
- 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;
- }
-
- 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>';
- }
- }
-
- 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('');
- }
-
- function arrayToMac(a) {
- if (!Array.isArray(a)) return '';
- return a.map(x => ('0' + x.toString(16)).slice(-2)).join(':');
- }
- 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);
- }
- 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 */
- 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);
- }
- }
-
- 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);
- }
- }
-
- 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;
- }
- }
-
- 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 */
- 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;
- }
- }
-
- 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;
- }
- }
-
- 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;
- }
- }
-
- /* initial */
- loadStatus();
- </script>
-</body>
-
-</html>
diff --git a/main.go b/main.go
index 8b2ca32..84676e9 100644
--- a/main.go
+++ b/main.go
@@ -21,7 +21,7 @@ const (
listenAddress = ":8080"
)
-var tpl = template.Must(template.ParseFiles("index.html"))
+var tpl = template.Must(template.ParseFiles("static/index.html"))
// basicAuth wraps any http.Handler and enforces HTTP Basic Authentication.
func basicAuth(next http.Handler) http.Handler {
diff --git a/requirements.json b/requirements.json
index 6dd5f58..73cd98e 100644
--- a/requirements.json
+++ b/requirements.json
@@ -1,75 +1,119 @@
{
- "project": "systemd-networkd simple Web UI",
- "version": "0.2",
- "goal": "Make a easy to use interface to configure router to teach network basics for high schoolers purposes",
+ "project": "Network Classroom - systemd-networkd Web UI",
+ "version": "0.3",
+ "goal": "Educational web interface for teaching network fundamentals through practical router configuration with systemd-networkd",
+ "target_audience": "High school students learning basic networking concepts",
+ "core_principles": [
+ "Progressive disclosure - show simple concepts first, advanced options later",
+ "Visual learning - use diagrams and color coding",
+ "Safe experimentation - easy undo/redo and validation",
+ "Real-world relevance - connect concepts to actual network behavior"
+ ],
"requirements": {
"functional": [
{
"id": "F-001",
- "description": "The system shall provide a structured web-based control panel with application-style layout (navigation menu, panels, command buttons)."
+ "description": "The system shall provide an educational dashboard showing network topology with visual representation of interfaces (WAN, LAN, wireless) and their connections."
},
{
"id": "F-002",
- "description": "The system shall parse `networkctl status --json=short` output and present each interface as a structured panel showing: name, MAC, state, addresses, routes, DNS."
+ "description": "The system shall parse `networkctl status --json=short` and present interfaces in an educational panel showing: name, type (WAN/LAN), MAC, state (color-coded), IP addresses, routes, DNS with tooltips explaining each concept."
},
{
"id": "F-003",
- "description": "The system shall allow browsing of existing network configuration files under `/etc/systemd/network/`."
+ "description": "The system shall provide guided configuration wizards for common scenarios: Home Router, School Lab, Internet Cafe, with explanations of each setup choice."
},
{
"id": "F-004",
- "description": "The system shall display each configuration file (`*.network`, `*.netdev`, `*.link`) as editable text with syntax highlighting and validation hooks."
+ "description": "The system shall allow browsing of configuration files with educational annotations explaining what each section does in simple terms."
},
{
"id": "F-005",
- "description": "The system shall validate edited configuration against systemd-networkd rules before allowing save (e.g., reject unknown keys, missing sections)."
+ "description": "The system shall provide inline configuration editing with two modes: Simple (form-based with explanations) and Advanced (raw file editing with syntax highlighting)."
},
{
"id": "F-006",
- "description": "The system shall provide versioned backups of configuration files, allowing rollback to earlier revisions."
+ "description": "The system shall include network simulation mode showing how configuration changes would affect packet flow before applying changes."
},
{
"id": "F-007",
- "description": "The system shall provide controls to apply changes safely by restarting only `systemd-networkd` (not full reboot)."
+ "description": "The system shall maintain version history with educational notes about what each change accomplished (e.g., 'Added DHCP server for student devices')."
},
{
"id": "F-008",
- "description": "The system shall provide a full device reboot option for recovery."
+ "description": "The system shall include built-in tutorials: IP addressing, subnetting, NAT, firewall basics, DNS configuration."
+ },
+ {
+ "id": "F-009",
+ "description": "The system shall provide safe testing environment with pre-commit validation and the ability to quickly revert to known working configuration."
+ },
+ {
+ "id": "F-010",
+ "description": "The system shall include network diagnostics tools with educational explanations: ping, traceroute, port testing with visual results."
}
],
"technical": [
{
"id": "T-001",
- "description": "The backend shall be implemented in Go, exposing HTTP endpoints for structured data (JSON) and file operations."
+ "description": "The backend shall be implemented in Go with RESTful API designed for educational use cases."
},
{
"id": "T-002",
- "description": "The backend shall include a parser for systemd-networkd configuration files, mapping sections ([Match], [Link], [Network], etc.) into JSON objects."
+ "description": "The frontend shall use vanilla HTML/CSS/JS with custom educational-focused styling - avoid heavy frameworks for better learning and performance."
},
{
"id": "T-003",
- "description": "The frontend shall render interface panels dynamically from backend JSON and allow in-place editing of configuration fields."
+ "description": "The system shall include configuration templates for educational scenarios with inline documentation."
},
{
"id": "T-004",
- "description": "Configuration edits shall be validated by a backend hook that checks syntax with `systemd-analyze verify` before saving."
+ "description": "Configuration validation shall include both technical checks (`systemd-analyze verify`) and educational warnings (e.g., 'This will make the device unreachable from network - are you sure?')."
},
{
"id": "T-005",
- "description": "All changes shall be written atomically to `/etc/systemd/network/`, with `.bak` backup created automatically."
+ "description": "The system shall implement atomic operations with automatic backups and easy rollback functionality."
},
{
"id": "T-006",
- "description": "The backend shall implement privilege separation: file I/O restricted to `/etc/systemd/network/`, command execution restricted to `systemd-networkd` operations."
+ "description": "The frontend shall include interactive network diagrams that update based on configuration changes."
},
{
"id": "T-007",
- "description": "The frontend shall provide syntax highlighting (via regex) for editing `.network` files."
+ "description": "The system shall provide REST API endpoints for educational workflows: template application, simulation mode, diagnostic tests."
},
{
"id": "T-008",
- "description": "The system shall expose REST API endpoints for: (a) listing configs, (b) retrieving config, (c) validating config, (d) saving config, (e) restarting networkd, (f) rebooting device."
+ "description": "All operations shall be logged with educational context for review and learning."
+ }
+ ],
+ "educational": [
+ {
+ "id": "E-001",
+ "description": "Each configuration option shall have beginner-friendly explanation with examples of when to use it."
+ },
+ {
+ "id": "E-002",
+ "description": "The interface shall include learning progression from basic (IP addresses) to advanced (firewall rules, VLANs)."
+ },
+ {
+ "id": "E-003",
+ "description": "The system shall provide immediate feedback showing how configuration changes affect network behavior."
+ },
+ {
+ "id": "E-004",
+ "description": "Complex concepts shall be broken down with analogies (e.g., 'IP addresses are like street addresses for computers')."
}
]
+ },
+ "ui_recommendation": {
+ "styling_approach": "Use custom CSS with minimal, education-focused design",
+ "rationale": "Fluent UI is enterprise-focused and may be overwhelming for students. A simpler, custom design will be more approachable and educational.",
+ "design_principles": [
+ "Clean, uncluttered interface with ample whitespace",
+ "Color coding for different interface types and states",
+ "Progressive disclosure - show advanced options only when needed",
+ "Consistent, predictable navigation",
+ "Mobile-responsive for tablet use in classrooms"
+ ]
}
}
diff --git a/static/functions.js b/static/functions.js
new file mode 100644
index 0000000..ce909e2
--- /dev/null
+++ b/static/functions.js
@@ -0,0 +1,184 @@
+
+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
new file mode 100644
index 0000000..842775a
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,146 @@
+<!-- index.html -->
+<!doctype html>
+<html lang="en">
+
+<head>
+ <meta charset="utf-8" />
+ <title>Network UI — Control Panel</title>
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <link rel="stylesheet" href="./static/styles.css">
+</head>
+
+<body>
+ <header>
+ <h2>Network Classroom</h2>
+ <p class="subtitle">Educational interface for learning network configuration with systemd-networkd</p>
+ </header>
+
+ <main>
+ <nav>
+ <button class="nav-button active" onclick="show('status')">
+ <span>🌐</span> Network Status
+ </button>
+ <button class="nav-button" onclick="show('configs')">
+ <span>⚙️</span> Configurations
+ </button>
+ <button class="nav-button" onclick="show('logs')">
+ <span>📋</span> System Logs
+ </button>
+ <button class="nav-button accent" onclick="show('commands')">
+ <span>🔧</span> System Commands
+ </button>
+
+ <div class="tips-section">
+ <div class="tips-title">Learning Tips</div>
+ <ul class="tips-list">
+ <li>Always validate before saving changes</li>
+ <li>Automatic backups are created on save</li>
+ <li>Use the logs to troubleshoot issues</li>
+ </ul>
+ </div>
+ </nav>
+
+ <section id="panelStatus" class="active">
+ <div class="card">
+ <div class="card-header">
+ <div>
+ <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()">
+ <span>🔄</span> Refresh
+ </button>
+ </div>
+ <div id="ifaces" class="interface-grid"></div>
+ </div>
+ </section>
+
+ <section id="panelConfigs">
+ <div class="card">
+ <div class="card-header">
+ <div>
+ <h3 class="card-title">Configuration Files</h3>
+ <p class="card-description">Edit systemd-networkd configuration files in /etc/systemd/network/
+ </p>
+ </div>
+ <div style="display: flex; gap: var(--spacing-s);">
+ <button class="button secondary" onclick="refreshConfigs()">
+ Refresh List
+ </button>
+ <button class="button" onclick="saveConfig()">
+ <span>💾</span> Save
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div class="card">
+ <div class="form-group">
+ <label class="form-label">Configuration File</label>
+ <select id="configSelect" class="select" onchange="loadConfig()"></select>
+ </div>
+
+ <div class="form-group">
+ <label class="form-label">File Contents</label>
+ <textarea id="cfgEditor" class="textarea" spellcheck="false"></textarea>
+ </div>
+
+ <div class="checkbox-group">
+ <input type="checkbox" id="restartAfterSave" class="checkbox">
+ <label for="restartAfterSave" class="checkbox-label">
+ Restart systemd-networkd after saving
+ </label>
+ </div>
+
+ <div style="display: flex; gap: var(--spacing-s); align-items: center;">
+ <button class="button secondary" onclick="validateConfig()">
+ Validate Configuration
+ </button>
+ <span id="validateResult"></span>
+ </div>
+ </div>
+ </section>
+
+ <section id="panelLogs">
+ <div class="card">
+ <div class="card-header">
+ <div>
+ <h3 class="card-title">System Logs</h3>
+ <p class="card-description">Recent logs from systemd-networkd service</p>
+ </div>
+ <button class="button" onclick="loadLogs()">
+ <span>🔄</span> Refresh
+ </button>
+ </div>
+ <div class="logs-container" id="logsArea">[Loading logs...]</div>
+ </div>
+ </section>
+
+ <section id="panelCommands">
+ <div class="card">
+ <h3 class="card-title">System Commands</h3>
+ <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()">
+ <span>🔄</span> Restart Network Service
+ </button>
+ <button class="button warning" onclick="rebootDevice()">
+ <span>⚠️</span> Reboot Device
+ </button>
+ </div>
+
+ <div id="cmdResult" class="command-result"></div>
+ </div>
+ </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>
+</body>
+
+</html>
diff --git a/static/styles.css b/static/styles.css
new file mode 100644
index 0000000..96531df
--- /dev/null
+++ b/static/styles.css
@@ -0,0 +1,466 @@
+:root {
+ /* Fluent 2 Color Tokens */
+ --color-neutral-background: #f7f7f7;
+ --color-neutral-background-selected: #e6e6e6;
+ --color-neutral-foreground: #242424;
+ --color-neutral-foreground-subtle: #616161;
+ --color-brand-background: #0078d4;
+ --color-brand-background-hover: #106ebe;
+ --color-brand-foreground: #0078d4;
+ --color-brand-foreground-hover: #004578;
+ --color-status-success: #107c10;
+ --color-status-warning: #d83b01;
+ --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);
+
+ /* Fluent 2 Typography */
+ --font-weight-regular: 400;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+
+ /* Spacing */
+ --spacing-xs: 4px;
+ --spacing-s: 8px;
+ --spacing-m: 12px;
+ --spacing-l: 16px;
+ --spacing-xl: 20px;
+ --spacing-xxl: 24px;
+
+ /* Border Radius */
+ --border-radius-medium: 4px;
+ --border-radius-large: 8px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+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;
+}
+
+/* Header */
+header {
+ background: white;
+ padding: var(--spacing-l) var(--spacing-xl);
+ border-bottom: 1px solid #e1e1e1;
+ box-shadow: var(--shadow-2);
+}
+
+header h2 {
+ margin: 0 0 var(--spacing-xs) 0;
+ font-weight: var(--font-weight-semibold);
+ font-size: 24px;
+ color: var(--color-neutral-foreground);
+}
+
+.subtitle {
+ font-size: 14px;
+ color: var(--color-neutral-foreground-subtle);
+ margin: 0;
+}
+
+/* Main Layout */
+main {
+ display: flex;
+ min-height: calc(100vh - 80px);
+}
+
+/* Navigation */
+nav {
+ width: 260px;
+ background: white;
+ border-right: 1px solid #e1e1e1;
+ padding: var(--spacing-l);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+}
+
+.nav-button {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-s);
+ padding: var(--spacing-m) var(--spacing-l);
+ background: none;
+ border: 1px solid transparent;
+ 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;
+ text-align: left;
+ text-decoration: none;
+}
+
+.nav-button:hover {
+ background-color: var(--color-neutral-background);
+ border-color: #e1e1e1;
+}
+
+.nav-button.active {
+ background-color: var(--color-brand-background);
+ color: white;
+ font-weight: var(--font-weight-semibold);
+}
+
+.nav-button.accent {
+ background-color: var(--color-brand-background);
+ color: white;
+ font-weight: var(--font-weight-semibold);
+ margin-top: var(--spacing-xl);
+}
+
+.nav-button.accent:hover {
+ background-color: var(--color-brand-background-hover);
+}
+
+.nav-button.warning {
+ background-color: var(--color-status-warning);
+ color: white;
+}
+
+.nav-button.warning:hover {
+ background-color: #c13501;
+}
+
+/* Content Area */
+section {
+ flex: 1;
+ padding: var(--spacing-xl);
+ overflow: auto;
+ display: none;
+}
+
+section.active {
+ display: block;
+}
+
+/* Cards/Panels */
+.card {
+ background: white;
+ border-radius: var(--border-radius-large);
+ padding: var(--spacing-xl);
+ margin-bottom: var(--spacing-l);
+ box-shadow: var(--shadow-2);
+ border: 1px solid #e1e1e1;
+}
+
+.card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: var(--spacing-l);
+}
+
+.card-title {
+ margin: 0;
+ font-weight: var(--font-weight-semibold);
+ font-size: 20px;
+ color: var(--color-neutral-foreground);
+}
+
+.card-description {
+ margin: var(--spacing-xs) 0 0 0;
+ font-size: 14px;
+ color: var(--color-neutral-foreground-subtle);
+}
+
+/* Buttons */
+.button {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-s);
+ padding: var(--spacing-m) var(--spacing-l);
+ background: var(--color-brand-background);
+ color: white;
+ border: none;
+ border-radius: var(--border-radius-medium);
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: var(--font-weight-semibold);
+ cursor: pointer;
+ transition: background-color 0.1s ease;
+ text-decoration: none;
+}
+
+.button:hover {
+ background-color: var(--color-brand-background-hover);
+}
+
+.button.secondary {
+ background: transparent;
+ color: var(--color-brand-foreground);
+ border: 1px solid var(--color-brand-foreground);
+}
+
+.button.secondary:hover {
+ background-color: var(--color-neutral-background);
+}
+
+.button.warning {
+ background: var(--color-status-warning);
+}
+
+.button.warning:hover {
+ background-color: #c13501;
+}
+
+/* Interface Grid */
+.interface-grid {
+ display: grid;
+ gap: var(--spacing-l);
+ margin-top: var(--spacing-l);
+}
+
+.interface-card {
+ background: white;
+ border-radius: var(--border-radius-large);
+ padding: var(--spacing-xl);
+ box-shadow: var(--shadow-2);
+ border: 1px solid #e1e1e1;
+ border-left: 4px solid var(--color-brand-background);
+}
+
+.interface-card.wan {
+ border-left-color: var(--color-status-warning);
+}
+
+.interface-card.lan {
+ border-left-color: var(--color-status-success);
+}
+
+.interface-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: var(--spacing-l);
+}
+
+.interface-name {
+ font-weight: var(--font-weight-semibold);
+ font-size: 18px;
+ margin: 0;
+}
+
+.interface-type {
+ display: inline-block;
+ padding: var(--spacing-xs) var(--spacing-s);
+ background: var(--color-neutral-background);
+ border-radius: var(--border-radius-medium);
+ font-size: 12px;
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-neutral-foreground-subtle);
+ margin-left: var(--spacing-s);
+}
+
+.interface-details {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--spacing-l);
+}
+
+.detail-group h4 {
+ margin: 0 0 var(--spacing-xs) 0;
+ font-size: 14px;
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-neutral-foreground);
+}
+
+.detail-value {
+ font-size: 14px;
+ color: var(--color-neutral-foreground-subtle);
+ line-height: 1.5;
+}
+
+.state-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: var(--font-weight-semibold);
+}
+
+.state-up {
+ background: #dff6dd;
+ color: var(--color-status-success);
+}
+
+.state-down {
+ background: #f4d5d5;
+ color: var(--color-status-error);
+}
+
+/* Forms */
+.form-group {
+ margin-bottom: var(--spacing-l);
+}
+
+.form-label {
+ display: block;
+ margin-bottom: var(--spacing-s);
+ font-weight: var(--font-weight-semibold);
+ font-size: 14px;
+ color: var(--color-neutral-foreground);
+}
+
+.select {
+ width: 100%;
+ padding: var(--spacing-m);
+ border: 1px solid #e1e1e1;
+ border-radius: var(--border-radius-medium);
+ font-family: inherit;
+ font-size: 14px;
+ background: white;
+ transition: border-color 0.1s ease;
+}
+
+.select:focus {
+ outline: none;
+ border-color: var(--color-brand-background);
+}
+
+.textarea {
+ width: 100%;
+ height: 360px;
+ padding: var(--spacing-m);
+ border: 1px solid #e1e1e1;
+ border-radius: var(--border-radius-medium);
+ font-family: 'Consolas', 'Monaco', monospace;
+ font-size: 13px;
+ line-height: 1.5;
+ resize: vertical;
+ background: white;
+ transition: border-color 0.1s ease;
+}
+
+.textarea:focus {
+ outline: none;
+ border-color: var(--color-brand-background);
+}
+
+.checkbox-group {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-s);
+ margin: var(--spacing-l) 0;
+}
+
+.checkbox {
+ width: 16px;
+ height: 16px;
+ margin: 0;
+}
+
+.checkbox-label {
+ font-size: 14px;
+ color: var(--color-neutral-foreground);
+ margin: 0;
+}
+
+/* Validation Results */
+.validation-result {
+ padding: var(--spacing-m);
+ border-radius: var(--border-radius-medium);
+ font-size: 14px;
+ margin-top: var(--spacing-m);
+}
+
+.validation-success {
+ background: #dff6dd;
+ color: var(--color-status-success);
+ border: 1px solid #107c10;
+}
+
+.validation-error {
+ background: #f4d5d5;
+ color: var(--color-status-error);
+ border: 1px solid var(--color-status-error);
+}
+
+/* Logs */
+.logs-container {
+ background: #1e1e1e;
+ color: #d4d4d4;
+ padding: var(--spacing-l);
+ border-radius: var(--border-radius-medium);
+ font-family: 'Consolas', 'Monaco', monospace;
+ font-size: 13px;
+ line-height: 1.5;
+ overflow: auto;
+ max-height: 600px;
+ white-space: pre-wrap;
+}
+
+/* Command Results */
+.command-result {
+ padding: var(--spacing-m);
+ border-radius: var(--border-radius-medium);
+ background: var(--color-neutral-background);
+ font-size: 14px;
+ margin-top: var(--spacing-m);
+}
+
+/* Tips Section */
+.tips-section {
+ margin-top: auto;
+ padding-top: var(--spacing-xl);
+ border-top: 1px solid #e1e1e1;
+}
+
+.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);
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ main {
+ flex-direction: column;
+ }
+
+ nav {
+ width: 100%;
+ flex-direction: row;
+ overflow-x: auto;
+ padding: var(--spacing-m);
+ }
+
+ .nav-button {
+ white-space: nowrap;
+ }
+
+ .interface-details {
+ grid-template-columns: 1fr;
+ }
+
+ .card-header {
+ flex-direction: column;
+ gap: var(--spacing-m);
+ }
+}