From 2bf20fe39cd70b25f89ccd3b40d06895f25a0833 Mon Sep 17 00:00:00 2001 From: Petri Hienonen Date: Sun, 28 Sep 2025 12:09:33 +0300 Subject: Iteration --- static/functions.js | 184 +++++++++++++++++++++ static/index.html | 146 ++++++++++++++++ static/styles.css | 466 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 796 insertions(+) create mode 100644 static/functions.js create mode 100644 static/index.html create mode 100644 static/styles.css (limited to 'static') 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 = '
Error loading status: ' + e.message + '
'; + } +} + +export function renderIfaces(ifaces) { + const out = []; + if (ifaces.length === 0) { + out.push('
No interfaces
'); + } + for (const ifc of ifaces) { + const name = ifc.Name || 'n/a'; + const typ = ifc.Type || ''; + const state = ifc.OperationalState || ifc.AdministrativeState || ''; + const mac = ifc.HardwareAddress ? arrayToMac(ifc.HardwareAddress) : ''; + const addrs = (ifc.Addresses || []).map(a => ipFromArray(a)).join('
'); + const routes = (ifc.Routes || []).map(r => routeToString(r)).join('
'); + const dns = (ifc.DNS || []).map(d => ipFromArray(d.Address || d)).join('
'); + out.push(`

${name} ${typ}

+
+
State
${state}
+
MAC
${mac}
+
Addresses
${addrs}
+
DNS
${dns}
+
Routes
${routes}
+
`); + } + 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 @@ + + + + + + + Network UI — Control Panel + + + + + +
+

Network Classroom

+

Educational interface for learning network configuration with systemd-networkd

+
+ +
+ + +
+
+
+
+

Network Status

+

Live view of network interfaces and their configuration

+
+ +
+
+
+
+ +
+
+
+
+

Configuration Files

+

Edit systemd-networkd configuration files in /etc/systemd/network/ +

+
+
+ + +
+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+
+
+

System Logs

+

Recent logs from systemd-networkd service

+
+ +
+
[Loading logs...]
+
+
+ +
+
+

System Commands

+

Manage network services and system state

+ +
+ + +
+ +
+
+
+
+ + + + + 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); + } +} -- cgit v1.2.3-70-g09d2