diff options
Diffstat (limited to 'index.html')
| -rw-r--r-- | index.html | 391 |
1 files changed, 344 insertions, 47 deletions
@@ -1,80 +1,377 @@ -<!DOCTYPE html> +<!-- index.html --> +<!doctype html> <html lang="en"> <head> - <meta charset="UTF-8"> - <title>Systemd-networkd Control Panel</title> + <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: 2em; + 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 { - margin-bottom: 2em; + 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: #f5f5f5; - padding: 1em; + background: #fafafa; + padding: 8px; border-radius: 6px; - overflow-x: auto; + 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> - <h1>Systemd-networkd Control Panel</h1> - <p>This page shows network status, logs, and allows you to restart network services or reboot the device. - For more details see <a href="https://www.freedesktop.org/software/systemd/man/systemd-networkd.service.html" - target="_blank">systemd-networkd documentation</a>.</p> - - <section> - <h2>1. Network Status</h2> - <p>The command <code>networkctl status --json=short</code> shows current interfaces, addresses, and routes.</p> - <fluent-button appearance="accent" onclick="loadStatus()">Refresh Status</fluent-button> - <pre id="status">[Waiting for data]</pre> - </section> - - <section> - <h2>2. Network Logs</h2> - <p>Logs come from <code>journalctl -u systemd-networkd.service</code>. This helps diagnose DHCP and link issues. - </p> - <fluent-button onclick="loadLogs()">Show Logs</fluent-button> - <pre id="logs">[Waiting for logs]</pre> - </section> - - <section> - <h2>3. Manage Services</h2> - <p> - - Reload networkd after configuration changes.<br> - - Reboot device if required. - </p> - <fluent-button appearance="accent" onclick="reloadNetworkd()">Restart networkd</fluent-button> - <fluent-button appearance="accent" onclick="rebootDevice()">Reboot device</fluent-button> - </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() { - let res = await fetch('/api/status'); - document.getElementById('status').textContent = await res.text(); + 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() { - let res = await fetch('/api/logs'); - document.getElementById('logs').textContent = await res.text(); + 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 reloadNetworkd() { - let res = await fetch('/api/reload', {method: 'POST'}); - alert(await res.text()); + + 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() { - let res = await fetch('/api/reboot', {method: 'POST'}); - alert(await res.text()); + 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; + } } - </script> + /* initial */ + loadStatus(); + </script> </body> </html> |
