Restructure frontend into templates/ and static/
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
822e6b3b87
commit
1118681fd2
10 files changed
+607
-642
No files matched your search
@@ -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; }
|
||||
@@ -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 =
|
||||
`<span class="dot ${running ? 'dot-green' : 'dot-red'}"></span>
|
||||
<span class="small">${running ? 'Running' : 'Stopped'}</span>`;
|
||||
}
|
||||
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 = `<tr><td colspan="5" style="color:var(--muted)">No shares configured.</td></tr>`;
|
||||
} else {
|
||||
sb.innerHTML = d.shares.map(s => `
|
||||
<tr>
|
||||
<td><span class="badge rounded-pill ${s.type === 'smb' ? 'badge-smb' : 'badge-nfs'}">${s.type.toUpperCase()}</span></td>
|
||||
<td>${esc(s.name ?? '—')}</td>
|
||||
<td><code>${esc(s.path)}</code></td>
|
||||
<td style="color:var(--muted); font-size:.87em">${esc(s.access || '—')}</td>
|
||||
<td class="text-end" style="color:var(--muted)">${s.size_gb} GB</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
const qb = document.getElementById('dashQuotasBody');
|
||||
if (!d.quotas?.length) {
|
||||
qb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No quotas set.</td></tr>`;
|
||||
} 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 `<tr>
|
||||
<td>${esc(q.user)}</td>
|
||||
<td>${q.used_gb} GB</td>
|
||||
<td>${q.soft_gb} GB</td>
|
||||
<td>
|
||||
<div class="progress" style="height:8px">
|
||||
<div class="progress-bar" style="width:${p}%; background:${col}; transition:width .6s ease"></div>
|
||||
</div>
|
||||
<div class="mt-1" style="font-size:.75rem; color:var(--muted)">${p.toFixed(1)}%</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('lastRefresh').textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
loadDashboard();
|
||||
loadShares();
|
||||
loadUsers();
|
||||
setInterval(loadDashboard, 30_000);
|
||||
@@ -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 => `
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">${esc(u)}</label>
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control quota-inp" data-user="${esc(u)}" placeholder="GB" min="1">
|
||||
<span class="input-group-text">GB</span>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} else {
|
||||
const note = type === 'nfs'
|
||||
? `<p class="mb-3" style="color:var(--muted); font-size:.87rem">Path: <code>${esc(path)}</code></p>`
|
||||
: `<p class="mb-3" style="color:var(--muted); font-size:.87rem">No valid users on this share — enter a username manually.</p>`;
|
||||
body = note + `
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">Username</label>
|
||||
<input type="text" id="qManualUser" class="form-control" placeholder="username">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">Quota</label>
|
||||
<div class="input-group">
|
||||
<input type="number" id="qManualGB" class="form-control" placeholder="GB" min="1">
|
||||
<span class="input-group-text">GB</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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 = `<tr><td colspan="4" style="color:var(--muted)">No SMB shares.</td></tr>`;
|
||||
} else {
|
||||
sb.innerHTML = shares.smb.map(s => `
|
||||
<tr>
|
||||
<td>${esc(s.name)}</td>
|
||||
<td><code>${esc(s.path)}</code></td>
|
||||
<td style="color:var(--muted); font-size:.87em">${esc(s.valid_users || '—')}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
||||
onclick="openQuotaModal('smb',${JSON.stringify(s.name)},${JSON.stringify(s.path)},${JSON.stringify(s.valid_users)})">
|
||||
Quota
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
const nb = document.getElementById('nfsTableBody');
|
||||
if (!shares.nfs?.length) {
|
||||
nb.innerHTML = `<tr><td colspan="3" style="color:var(--muted)">No NFS exports.</td></tr>`;
|
||||
} else {
|
||||
nb.innerHTML = shares.nfs.map(s => `
|
||||
<tr>
|
||||
<td><code>${esc(s.path)}</code></td>
|
||||
<td style="color:var(--muted); font-size:.87em">${esc(s.clients)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
||||
onclick="openQuotaModal('nfs',null,${JSON.stringify(s.path)},null)">
|
||||
Quota
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button>
|
||||
</td>
|
||||
</tr>`).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();
|
||||
}
|
||||
@@ -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 = `<tr><td colspan="4" style="color:var(--muted)">No users found.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = users.map(u => `
|
||||
<tr>
|
||||
<td>${esc(u.name)}</td>
|
||||
<td style="color:var(--muted)">${u.uid}</td>
|
||||
<td>
|
||||
<span class="badge rounded-pill"
|
||||
style="${u.smb ? 'background:#1a3a1f; color:var(--green)' : 'background:var(--surf2); color:var(--muted)'}">
|
||||
${u.smb ? 'Active' : 'No'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
||||
onclick="openPwModal(${JSON.stringify(u.name)})">Password</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="deleteUser(${JSON.stringify(u.name)})">Delete</button>
|
||||
</td>
|
||||
</tr>`).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}".`);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&').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', `
|
||||
<div id="${id}" class="toast align-items-center border-0 mb-2" role="alert"
|
||||
style="background:#161b22; border-left:3px solid ${colors[type] ?? colors.success} !important; color:#e6edf3;">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">${esc(msg)}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>`);
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user