diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-09-28 12:09:33 +0300 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-09-28 12:09:33 +0300 |
| commit | 2bf20fe39cd70b25f89ccd3b40d06895f25a0833 (patch) | |
| tree | 509173d3e3ab980214046df156429f6de8236081 | |
| parent | 47529804bef15ed84730ff3409f0d426fcef2112 (diff) | |
| download | network-2bf20fe39cd70b25f89ccd3b40d06895f25a0833.tar.zst | |
Iteration
| -rw-r--r-- | index.html | 377 | ||||
| -rw-r--r-- | main.go | 2 | ||||
| -rw-r--r-- | requirements.json | 82 | ||||
| -rw-r--r-- | static/functions.js | 184 | ||||
| -rw-r--r-- | static/index.html | 146 | ||||
| -rw-r--r-- | static/styles.css | 466 |
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> @@ -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); + } +} |
