From 1118681fd28b0cb58ee87782d09c957d57e1128d Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 19 Jun 2026 22:19:46 +0200 Subject: [PATCH] Restructure frontend into templates/ and static/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move index.html → templates/index.html (Jinja2, uses url_for) - Extract CSS → static/css/app.css - Split JS into static/js/: utils.js, dashboard.js, shares.js, quotas.js, users.js, main.js - Switch app.py from send_file to render_template Co-Authored-By: Claude Sonnet 4.6 --- app.py | 4 +- index.html | 640 ----------------------------------------- static/css/app.css | 64 +++++ static/js/dashboard.js | 61 ++++ static/js/main.js | 4 + static/js/quotas.js | 70 +++++ static/js/shares.js | 84 ++++++ static/js/users.js | 80 ++++++ static/js/utils.js | 28 ++ templates/index.html | 214 ++++++++++++++ 10 files changed, 607 insertions(+), 642 deletions(-) delete mode 100644 index.html create mode 100644 static/css/app.css create mode 100644 static/js/dashboard.js create mode 100644 static/js/main.js create mode 100644 static/js/quotas.js create mode 100644 static/js/shares.js create mode 100644 static/js/users.js create mode 100644 static/js/utils.js create mode 100644 templates/index.html diff --git a/app.py b/app.py index 6f74a58..68b2fcd 100644 --- a/app.py +++ b/app.py @@ -5,7 +5,7 @@ import subprocess import functools from pathlib import Path -from flask import Flask, request, jsonify, send_file, Response +from flask import Flask, request, jsonify, render_template, Response app = Flask(__name__) @@ -130,7 +130,7 @@ def add_nfs_share(path, clients, ro=False): @app.route('/') @require_auth def index(): - return send_file(os.path.join(os.path.dirname(__file__), 'index.html')) + return render_template('index.html') @app.route('/api/shares', methods=['GET']) diff --git a/index.html b/index.html deleted file mode 100644 index 751084f..0000000 --- a/index.html +++ /dev/null @@ -1,640 +0,0 @@ - - - - - MicroNAS - - - - - - -
- - - - - - - - -
- MicroNAS - -
- -
- - - -
- - -
- - -
-
-
-
-
smbd
-
- … -
-
-
-
-
-
-
-
nfsd
-
- … -
-
-
-
-
-
-
-
-
/srv disk
- … -
-
-
-
-
-
-
-
- - -
- Shares - -
-
- - - - - - - - - -
TypeNamePathAccessSize
Loading…
-
- - -
Quota Usage
-
- - - - - - - -
UserUsedSoft limitUsage
Loading…
-
-
- - -
-
Add SMB Share
-
-
- -
-
- -
-
- -
-
- -
-
-
Shares
-
- - - - - - - -
NamePathValid UsersActions
Loading…
-
-
- - -
-
Add NFS Export
-
-
- -
-
- -
-
-
- - -
-
-
- -
-
-
Exports
-
- - - - - - - -
PathClientsActions
Loading…
-
-
- - -
-
Create User
-
-
- -
-
- -
-
- -
-
-
Users
-
- - - - - - - -
UsernameUIDSMB AccountActions
Loading…
-
-
- -
-
- - - - - - diff --git a/static/css/app.css b/static/css/app.css new file mode 100644 index 0000000..b1805e4 --- /dev/null +++ b/static/css/app.css @@ -0,0 +1,64 @@ +:root { + --bg: #0d1117; + --surf: #161b22; + --surf2: #21262d; + --border: #30363d; + --text: #e6edf3; + --muted: #8b949e; + --accent: #58a6ff; + --green: #3fb950; + --red: #f85149; + --yellow: #d29922; +} + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +/* Nav */ +.topbar { background: var(--surf); border-bottom: 1px solid var(--border); padding: .75rem 1.5rem; } +.nav-tabs { border-color: var(--border); } +.nav-tabs .nav-link { color: var(--muted); border-color: transparent; padding: .6rem 1rem; } +.nav-tabs .nav-link:hover { color: var(--text); background: var(--surf2); border-color: var(--border); } +.nav-tabs .nav-link.active { color: var(--text); background: var(--bg); border-color: var(--border) var(--border) var(--bg); } + +/* Cards */ +.card { background: var(--surf); border: 1px solid var(--border); border-radius: 8px; } +.card-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: .3rem; } + +/* Tables */ +.table { color: var(--text); } +.table > :not(caption) > * > * { background: transparent; border-color: var(--border); padding: .65rem .85rem; } +.table thead th { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 500; } +.table-striped > tbody > tr:nth-of-type(odd) > * { background: var(--surf2); } +.table tbody tr:hover > * { background: #1c2128; } + +/* Forms */ +.form-control, .form-select { background: var(--surf2); border-color: var(--border); color: var(--text); } +.form-control:focus, .form-select:focus { background: var(--surf2); border-color: var(--accent); color: var(--text); box-shadow: 0 0 0 3px rgba(88,166,255,.15); } +.form-control::placeholder { color: var(--muted); } +.input-group-text { background: var(--surf2); border-color: var(--border); color: var(--muted); } + +/* Status dot */ +.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 6px; flex-shrink: 0; } +.dot-green { background: var(--green); box-shadow: 0 0 6px var(--green); } +.dot-red { background: var(--red); box-shadow: 0 0 6px var(--red); } + +/* Progress */ +.progress { background: var(--surf2); border-radius: 4px; } + +/* Badges */ +.badge-smb { background: #1f3a5f; color: #58a6ff; font-size: .72rem; } +.badge-nfs { background: #2d1f5f; color: #a78bfa; font-size: .72rem; } + +/* Modal */ +.modal-content { background: var(--surf); border: 1px solid var(--border); } +.modal-header, .modal-footer { border-color: var(--border); } +.btn-close-white { filter: invert(1) grayscale(100%) brightness(200%); } + +/* Toast */ +#toastContainer { position: fixed; top: 1rem; right: 1rem; z-index: 1100; min-width: 260px; } + +code { color: var(--accent); background: var(--surf2); padding: .1em .35em; border-radius: 4px; font-size: .87em; } diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..9ec45a6 --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,61 @@ +async function loadDashboard() { + const res = await apiFetch('/api/dashboard'); + if (!res) return; + const d = await res.json(); + + function setDaemon(id, running) { + document.getElementById(id).innerHTML = + ` + ${running ? 'Running' : 'Stopped'}`; + } + setDaemon('statusSmbd', d.status.smbd); + setDaemon('statusNfsd', d.status.nfsd); + + if (d.disk && !d.disk.error) { + const p = d.disk.percent; + const bar = document.getElementById('diskBar'); + bar.style.width = p + '%'; + bar.style.background = p > 90 ? 'var(--red)' : p > 75 ? 'var(--yellow)' : 'var(--accent)'; + document.getElementById('diskLabel').textContent = + `${d.disk.used_gb} GB / ${d.disk.total_gb} GB — ${d.disk.free_gb} GB free`; + } else if (d.disk?.error) { + document.getElementById('diskLabel').textContent = 'Error: ' + d.disk.error; + } + + const sb = document.getElementById('dashSharesBody'); + if (!d.shares?.length) { + sb.innerHTML = `No shares configured.`; + } else { + sb.innerHTML = d.shares.map(s => ` + + ${s.type.toUpperCase()} + ${esc(s.name ?? '—')} + ${esc(s.path)} + ${esc(s.access || '—')} + ${s.size_gb} GB + `).join(''); + } + + const qb = document.getElementById('dashQuotasBody'); + if (!d.quotas?.length) { + qb.innerHTML = `No quotas set.`; + } else { + qb.innerHTML = d.quotas.map(q => { + const p = Math.min(q.percent, 100); + const col = p > 90 ? 'var(--red)' : p > 75 ? 'var(--yellow)' : 'var(--green)'; + return ` + ${esc(q.user)} + ${q.used_gb} GB + ${q.soft_gb} GB + +
+
+
+
${p.toFixed(1)}%
+ + `; + }).join(''); + } + + document.getElementById('lastRefresh').textContent = new Date().toLocaleTimeString(); +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..94ca235 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,4 @@ +loadDashboard(); +loadShares(); +loadUsers(); +setInterval(loadDashboard, 30_000); diff --git a/static/js/quotas.js b/static/js/quotas.js new file mode 100644 index 0000000..0d82771 --- /dev/null +++ b/static/js/quotas.js @@ -0,0 +1,70 @@ +let _qCtx = null; + +function openQuotaModal(type, name, path, validUsers) { + _qCtx = { type, name, path, users: validUsers ? validUsers.split(/[\s,]+/).filter(Boolean) : [] }; + document.getElementById('quotaModalTitle').textContent = + 'Quota — ' + (type === 'smb' ? esc(name) : esc(path)); + + let body = ''; + if (type === 'smb' && _qCtx.users.length > 0) { + body = _qCtx.users.map(u => ` +
+ +
+ + GB +
+
`).join(''); + } else { + const note = type === 'nfs' + ? `

Path: ${esc(path)}

` + : `

No valid users on this share — enter a username manually.

`; + body = note + ` +
+ + +
+
+ +
+ + GB +
+
`; + } + + document.getElementById('quotaModalBody').innerHTML = body; + new bootstrap.Modal(document.getElementById('quotaModal')).show(); +} + +async function submitQuota() { + if (!_qCtx) return; + const calls = []; + + if (_qCtx.type === 'smb' && _qCtx.users.length > 0) { + for (const inp of document.querySelectorAll('.quota-inp')) { + const gb = parseInt(inp.value); + if (!gb || gb <= 0) continue; + calls.push({ type: 'smb', user: inp.dataset.user, quota: gb }); + } + } else { + const user = document.getElementById('qManualUser')?.value.trim(); + const gb = parseInt(document.getElementById('qManualGB')?.value); + if (!user) return showToast('Username is required.', 'danger'); + if (!gb || gb <= 0) return showToast('Quota must be a positive number.', 'danger'); + calls.push({ type: _qCtx.type, user, path: _qCtx.path, quota: gb }); + } + + if (!calls.length) return showToast('No quota values entered.', 'warning'); + + bootstrap.Modal.getInstance(document.getElementById('quotaModal')).hide(); + + const results = await Promise.all(calls.map(body => + apiFetch('/api/quota', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }) + )); + const failed = results.filter(r => r && !r.ok).length; + if (failed) showToast(`${failed} quota(s) failed to apply.`, 'danger'); + else showToast(`Quota${calls.length > 1 ? 's' : ''} applied.`); + + loadDashboard(); +} diff --git a/static/js/shares.js b/static/js/shares.js new file mode 100644 index 0000000..3a7e335 --- /dev/null +++ b/static/js/shares.js @@ -0,0 +1,84 @@ +async function loadShares() { + const res = await apiFetch('/api/shares'); + if (!res) return; + const shares = await res.json(); + + const sb = document.getElementById('smbTableBody'); + if (!shares.smb?.length) { + sb.innerHTML = `No SMB shares.`; + } else { + sb.innerHTML = shares.smb.map(s => ` + + ${esc(s.name)} + ${esc(s.path)} + ${esc(s.valid_users || '—')} + + + + + `).join(''); + } + + const nb = document.getElementById('nfsTableBody'); + if (!shares.nfs?.length) { + nb.innerHTML = `No NFS exports.`; + } else { + nb.innerHTML = shares.nfs.map(s => ` + + ${esc(s.path)} + ${esc(s.clients)} + + + + + `).join(''); + } +} + +async function addSMBShare() { + const name = document.getElementById('smbName').value.trim(); + const path = document.getElementById('smbPath').value.trim(); + const validUsers = document.getElementById('smbUsers').value.trim(); + if (!name || !path) return showToast('Name and path are required.', 'danger'); + + const res = await apiFetch('/api/share/smb', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name, path, validUsers}), + }); + if (!res) return; + const data = await res.json(); + if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); + showToast('SMB share created.'); + document.getElementById('smbName').value = ''; + document.getElementById('smbPath').value = ''; + document.getElementById('smbUsers').value = ''; + loadShares(); + loadDashboard(); +} + +async function addNFSShare() { + const path = document.getElementById('nfsPath').value.trim(); + const clients = document.getElementById('nfsClients').value.trim() || '*'; + const readOnly = document.getElementById('nfsReadOnly').checked; + if (!path) return showToast('Path is required.', 'danger'); + + const res = await apiFetch('/api/share/nfs', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({path, clients, readOnly}), + }); + if (!res) return; + const data = await res.json(); + if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); + showToast('NFS export created.'); + document.getElementById('nfsPath').value = ''; + document.getElementById('nfsClients').value = ''; + document.getElementById('nfsReadOnly').checked = false; + loadShares(); + loadDashboard(); +} diff --git a/static/js/users.js b/static/js/users.js new file mode 100644 index 0000000..ed8a9f2 --- /dev/null +++ b/static/js/users.js @@ -0,0 +1,80 @@ +async function loadUsers() { + const res = await apiFetch('/api/users'); + if (!res) return; + const users = await res.json(); + const tb = document.getElementById('usersTableBody'); + if (!users.length) { + tb.innerHTML = `No users found.`; + return; + } + tb.innerHTML = users.map(u => ` + + ${esc(u.name)} + ${u.uid} + + + ${u.smb ? 'Active' : 'No'} + + + + + + + `).join(''); +} + +async function createUser() { + const name = document.getElementById('newUsername').value.trim(); + const password = document.getElementById('newPassword').value; + if (!name || !password) return showToast('Username and password are required.', 'danger'); + + const res = await apiFetch('/api/users', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name, password}), + }); + if (!res) return; + const data = await res.json(); + if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); + showToast(`User "${name}" created.`); + document.getElementById('newUsername').value = ''; + document.getElementById('newPassword').value = ''; + loadUsers(); +} + +async function deleteUser(name) { + if (!confirm(`Delete user "${name}"? This cannot be undone.`)) return; + const res = await apiFetch(`/api/users/${encodeURIComponent(name)}`, { method: 'DELETE' }); + if (!res) return; + const data = await res.json(); + if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); + showToast(`User "${name}" deleted.`); + loadUsers(); +} + +function openPwModal(name) { + document.getElementById('pwUsername').value = name; + document.getElementById('pwNew').value = ''; + document.getElementById('pwConfirm').value = ''; + new bootstrap.Modal(document.getElementById('pwModal')).show(); +} + +async function submitPassword() { + const name = document.getElementById('pwUsername').value; + const pw = document.getElementById('pwNew').value; + const confirm = document.getElementById('pwConfirm').value; + if (pw.length < 8) return showToast('Password must be at least 8 characters.', 'danger'); + if (pw !== confirm) return showToast('Passwords do not match.', 'danger'); + + const res = await apiFetch(`/api/users/${encodeURIComponent(name)}/password`, { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({password: pw}), + }); + if (!res) return; + const data = await res.json(); + bootstrap.Modal.getInstance(document.getElementById('pwModal')).hide(); + if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); + showToast(`Password updated for "${name}".`); +} diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..d88a7dd --- /dev/null +++ b/static/js/utils.js @@ -0,0 +1,28 @@ +function esc(s) { + return String(s ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function showToast(msg, type = 'success') { + const colors = { success: '#3fb950', danger: '#f85149', warning: '#d29922' }; + const id = 'toast-' + Date.now(); + document.getElementById('toastContainer').insertAdjacentHTML('beforeend', ` + `); + const el = document.getElementById(id); + const t = new bootstrap.Toast(el, { delay: 3500 }); + t.show(); + el.addEventListener('hidden.bs.toast', () => el.remove()); +} + +async function apiFetch(url, opts) { + const res = await fetch(url, opts); + if (res.status === 401) { location.reload(); return null; } + return res; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..503483b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,214 @@ + + + + + MicroNAS + + + + + +
+ + + + + + + + +
+ MicroNAS + +
+ +
+ + + +
+ + +
+
+
+
+
+
smbd
+
+ … +
+
+
+
+
+
+
+
nfsd
+
+ … +
+
+
+
+
+
+
+
+
/srv disk
+ … +
+
+
+
+
+
+
+
+ +
+ Shares + +
+
+ + + + + +
TypeNamePathAccessSize
Loading…
+
+ +
Quota Usage
+
+ + + + + +
UserUsedSoft limitUsage
Loading…
+
+
+ + +
+
Add SMB Share
+
+
+
+
+
+
+
Shares
+
+ + + + + +
NamePathValid UsersActions
Loading…
+
+
+ + +
+
Add NFS Export
+
+
+
+
+
+ + +
+
+
+
+
Exports
+
+ + + + + +
PathClientsActions
Loading…
+
+
+ + +
+
Create User
+
+
+
+
+
+
Users
+
+ + + + + +
UsernameUIDSMB AccountActions
Loading…
+
+
+ +
+
+ + + + + + + + + + +