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
-
-
-
-
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Shares
-
-
-
-
-
-
- | Type | Name | Path | Access | Size |
-
-
-
- | Loading… |
-
-
-
-
-
-
Quota Usage
-
-
-
- | User | Used | Soft limit | Usage |
-
-
- | Loading… |
-
-
-
-
-
-
-
-
Add SMB Share
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Shares
-
-
-
- | Name | Path | Valid Users | Actions |
-
-
- | Loading… |
-
-
-
-
-
-
-
-
Add NFS Export
-
-
-
-
-
-
-
-
-
-
-
-
-
Exports
-
-
-
- | Path | Clients | Actions |
-
-
- | Loading… |
-
-
-
-
-
-
-
-
Create User
-
-
-
-
-
-
-
-
-
-
-
-
Users
-
-
-
- | Username | UID | SMB Account | Actions |
-
-
- | 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 => `
+ `).join('');
+ } else {
+ const note = type === 'nfs'
+ ? `Path: ${esc(path)}
`
+ : `No valid users on this share — enter a username manually.
`;
+ body = note + `
+
+
+
+
+ `;
+ }
+
+ 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
+
+
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+ Shares
+
+
+
+
+ | Type | Name | Path | Access | Size |
+
+ | Loading… |
+
+
+
+
+
Quota Usage
+
+
+ | User | Used | Soft limit | Usage |
+
+ | Loading… |
+
+
+
+
+
+
+
+
Add SMB Share
+
+
Shares
+
+
+ | Name | Path | Valid Users | Actions |
+
+ | Loading… |
+
+
+
+
+
+
+
+
Add NFS Export
+
+
Exports
+
+
+ | Path | Clients | Actions |
+
+ | Loading… |
+
+
+
+
+
+
+
+
Create User
+
+
Users
+
+
+ | Username | UID | SMB Account | Actions |
+
+ | Loading… |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+