summaryrefslogtreecommitdiffstats
path: root/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'index.html')
-rw-r--r--index.html391
1 files changed, 344 insertions, 47 deletions
diff --git a/index.html b/index.html
index 618f523..f516f7c 100644
--- a/index.html
+++ b/index.html
@@ -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>