Files
MicroNAS/index.html
T
marcandClaude Sonnet 4.6 458092a3c6 Add Phase 2 dashboard + fix Phase 1 issues
- Implement parse_smb(), remove shell=True, add input validation, HTTP Basic Auth (Phase 1)
- Add /api/dashboard with daemon status, disk usage, per-share du, quota usage (Phase 2)
- Rewrite index.html with Dashboard as landing tab, progress bars, XSS-safe rendering
- Update README with full LXC install guide including git clone and update steps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 21:58:47 +02:00

375 lines
15 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MicroNAS</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css">
</head>
<body class="bg-dark text-white p-4">
<div class="container-lg">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="mb-0">MicroNAS</h1>
<span id="lastRefresh" class="text-muted small"></span>
</div>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#dashboard" type="button">Dashboard</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#smb" type="button">SMB Shares</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#nfs" type="button">NFS Exports</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#quotas" type="button">Quotas</button>
</li>
</ul>
<div class="tab-content">
<!-- DASHBOARD TAB -->
<div class="tab-pane fade show active mt-4" id="dashboard">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-0">Dashboard</h3>
<button class="btn btn-sm btn-outline-light" onclick="loadDashboard()">&#x21bb; Refresh</button>
</div>
<!-- Daemon status + disk -->
<div class="row g-3 mb-4">
<div class="col-md-2">
<div class="card border-secondary bg-secondary text-center">
<div class="card-body py-3">
<div class="small text-muted mb-1">smbd</div>
<span id="statusSmbd" class="badge fs-6">…</span>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card border-secondary bg-secondary text-center">
<div class="card-body py-3">
<div class="small text-muted mb-1">nfsd</div>
<span id="statusNfsd" class="badge fs-6">…</span>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card border-secondary bg-secondary">
<div class="card-body py-3">
<div class="d-flex justify-content-between mb-2">
<span class="small fw-semibold">/srv</span>
<span id="diskLabel" class="small text-muted">…</span>
</div>
<div class="progress" style="height: 18px;">
<div id="diskBar" class="progress-bar" role="progressbar" style="width:0%">0%</div>
</div>
</div>
</div>
</div>
</div>
<!-- Shares overview -->
<h5>Shares</h5>
<div class="table-responsive mb-4">
<table class="table table-dark table-striped table-sm align-middle">
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Path</th>
<th>Access</th>
<th class="text-end">Size on disk</th>
</tr>
</thead>
<tbody id="dashSharesBody">
<tr><td colspan="5" class="text-muted">Loading…</td></tr>
</tbody>
</table>
</div>
<!-- Quota usage -->
<h5>Quota Usage</h5>
<div class="table-responsive">
<table class="table table-dark table-striped table-sm align-middle">
<thead>
<tr>
<th>User</th>
<th>Used</th>
<th>Soft limit</th>
<th style="min-width:180px">Usage</th>
</tr>
</thead>
<tbody id="dashQuotasBody">
<tr><td colspan="4" class="text-muted">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- SMB TAB -->
<div class="tab-pane fade mt-4" id="smb">
<h3>SMB Shares</h3>
<form class="row g-3 mb-4">
<div class="col-md-3">
<input type="text" id="smbName" class="form-control" placeholder="Share name" required>
</div>
<div class="col-md-4">
<input type="text" id="smbPath" class="form-control" placeholder="/srv/samba/…" required>
</div>
<div class="col-md-3">
<input type="text" id="smbUsers" class="form-control" placeholder="user1,user2">
</div>
<div class="col-md-2">
<button type="button" class="btn btn-success w-100" onclick="addSMBShare()">Add SMB</button>
</div>
</form>
<div class="table-responsive">
<table class="table table-dark table-striped">
<thead>
<tr><th>Name</th><th>Path</th><th>Valid Users</th><th>Actions</th></tr>
</thead>
<tbody id="smbTableBody"></tbody>
</table>
</div>
</div>
<!-- NFS TAB -->
<div class="tab-pane fade mt-4" id="nfs">
<h3>NFS Exports</h3>
<form class="row g-3 mb-4">
<div class="col-md-4">
<input type="text" id="nfsPath" class="form-control" placeholder="/srv/data" required>
</div>
<div class="col-md-3">
<input type="text" id="nfsClients" class="form-control" placeholder="192.168.1.0/24 or *">
</div>
<div class="col-md-2 d-flex align-items-center">
<div class="form-check">
<input type="checkbox" id="nfsReadOnly" class="form-check-input">
<label class="form-check-label" for="nfsReadOnly">Read Only</label>
</div>
</div>
<div class="col-md-3">
<button type="button" class="btn btn-success w-100" onclick="addNFSShare()">Add NFS</button>
</div>
</form>
<div class="table-responsive">
<table class="table table-dark table-striped">
<thead>
<tr><th>Path</th><th>Clients</th><th>Actions</th></tr>
</thead>
<tbody id="nfsTableBody"></tbody>
</table>
</div>
</div>
<!-- QUOTAS TAB -->
<div class="tab-pane fade mt-4" id="quotas">
<h3>Set Quota</h3>
<form class="row g-3 mb-4">
<div class="col-md-2">
<select id="quotaType" class="form-select">
<option value="smb">SMB</option>
<option value="nfs">NFS</option>
</select>
</div>
<div class="col-md-2">
<input type="text" id="quotaUser" class="form-control" placeholder="Username">
</div>
<div class="col-md-3">
<input type="text" id="quotaPath" class="form-control" placeholder="/srv/data (NFS only)">
</div>
<div class="col-md-2">
<input type="number" id="quotaGB" class="form-control" placeholder="Quota (GB)" min="1">
</div>
<div class="col-md-3">
<button type="button" class="btn btn-warning w-100" onclick="setQuota()">Set Quota</button>
</div>
</form>
</div>
</div><!-- /.tab-content -->
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.bundle.min.js"></script>
<script>
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function apiFetch(url, opts) {
const res = await fetch(url, opts);
if (res.status === 401) { location.reload(); return null; }
return res;
}
// ===== DASHBOARD =====
async function loadDashboard() {
const res = await apiFetch('/api/dashboard');
if (!res) return;
const d = await res.json();
function setStatus(id, running) {
const el = document.getElementById(id);
el.textContent = running ? 'Running' : 'Stopped';
el.className = 'badge fs-6 ' + (running ? 'bg-success' : 'bg-danger');
}
setStatus('statusSmbd', d.status.smbd);
setStatus('statusNfsd', d.status.nfsd);
if (d.disk && !d.disk.error) {
const pct = d.disk.percent;
const bar = document.getElementById('diskBar');
bar.style.width = pct + '%';
bar.textContent = pct + '%';
bar.className = 'progress-bar ' + (pct > 90 ? 'bg-danger' : pct > 75 ? 'bg-warning' : 'bg-info');
document.getElementById('diskLabel').textContent =
`${d.disk.used_gb} GB used / ${d.disk.total_gb} GB total (${d.disk.free_gb} GB free)`;
} else if (d.disk?.error) {
document.getElementById('diskLabel').textContent = 'Error: ' + d.disk.error;
}
const sharesBody = document.getElementById('dashSharesBody');
if (!d.shares?.length) {
sharesBody.innerHTML = '<tr><td colspan="5" class="text-muted">No shares configured.</td></tr>';
} else {
sharesBody.innerHTML = d.shares.map(s => `
<tr>
<td><span class="badge bg-secondary">${esc(s.type.toUpperCase())}</span></td>
<td>${esc(s.name ?? '—')}</td>
<td><code>${esc(s.path)}</code></td>
<td class="text-muted small">${esc(s.access || '—')}</td>
<td class="text-end">${s.size_gb} GB</td>
</tr>`).join('');
}
const quotasBody = document.getElementById('dashQuotasBody');
if (!d.quotas?.length) {
quotasBody.innerHTML = '<tr><td colspan="4" class="text-muted">No quotas set.</td></tr>';
} else {
quotasBody.innerHTML = d.quotas.map(q => {
const pct = Math.min(q.percent, 100);
const cls = pct > 90 ? 'bg-danger' : pct > 75 ? 'bg-warning' : 'bg-success';
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:14px">
<div class="progress-bar ${cls}" style="width:${pct}%">${pct.toFixed(1)}%</div>
</div>
</td>
</tr>`;
}).join('');
}
document.getElementById('lastRefresh').textContent =
'Last refresh: ' + new Date().toLocaleTimeString();
}
// ===== SMB / NFS TABS =====
async function loadShares() {
const res = await apiFetch('/api/shares');
if (!res) return;
const shares = await res.json();
const smbBody = document.getElementById('smbTableBody');
if (!shares.smb?.length) {
smbBody.innerHTML = '<tr><td colspan="4" class="text-muted">No SMB shares.</td></tr>';
} else {
smbBody.innerHTML = shares.smb.map(s => `
<tr>
<td>${esc(s.name)}</td>
<td><code>${esc(s.path)}</code></td>
<td>${esc(s.valid_users)}</td>
<td><button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button></td>
</tr>`).join('');
}
const nfsBody = document.getElementById('nfsTableBody');
if (!shares.nfs?.length) {
nfsBody.innerHTML = '<tr><td colspan="3" class="text-muted">No NFS exports.</td></tr>';
} else {
nfsBody.innerHTML = shares.nfs.map(s => `
<tr>
<td><code>${esc(s.path)}</code></td>
<td>${esc(s.clients)}</td>
<td><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 alert('Name and path are required.');
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 alert('Error: ' + (data.error || res.status));
alert('SMB share created.');
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 alert('Path is required.');
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 alert('Error: ' + (data.error || res.status));
alert('NFS export created.');
loadShares();
loadDashboard();
}
async function setQuota() {
const user = document.getElementById('quotaUser').value.trim();
const quota = document.getElementById('quotaGB').value;
if (!user || !quota) return alert('Username and quota are required.');
const res = await apiFetch('/api/quota', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
type: document.getElementById('quotaType').value,
user,
path: document.getElementById('quotaPath').value.trim(),
quota: Number(quota),
}),
});
if (!res) return;
const data = await res.json();
if (!res.ok) return alert('Error: ' + (data.error || res.status));
alert('Quota set.');
loadDashboard();
}
loadDashboard();
loadShares();
setInterval(loadDashboard, 60_000);
</script>
</body>
</html>