Remove quota feature (incompatible with ZFS-backed LXC)
Linux setquota/repquota require quota support on the underlying filesystem. The target deployment is a Proxmox LXC on ZFS storage where this is not available inside the container. Removed: /api/quota endpoint, _quota_supported, _quota_usage, _find_quota_fs, quotas.js, quota modal, dashboard quota table, Quota button from share rows. PLANNING.md updated: notes quota as out of scope with reason. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
af315b37b2
commit
bf7daf1c2c
6 files changed
+4
-239
No files matched your search
+4
-3
@@ -70,14 +70,11 @@ MicroNAS/
|
||||
- [x] **Disk usage** — free / used / total for `/srv` with progress bar.
|
||||
- [x] **All shares in one view** — SMB + NFS, paths, access, size on disk.
|
||||
- [x] **Per-share data used** — `du -sb` per share path.
|
||||
- [x] **Quota usage** — per-user progress bars from `repquota`.
|
||||
- [x] Auto-refresh every 30 s + manual refresh button.
|
||||
|
||||
### Phase 2.5 — UX improvements ✅
|
||||
- [x] UI redesign — GitHub-dark palette, custom CSS vars, better contrast
|
||||
and typography.
|
||||
- [x] Inline quota management — "Quota" button per share row opens a modal
|
||||
pre-filled with the share's valid users.
|
||||
- [x] Toast notifications — replaced all `alert()`.
|
||||
- [x] Frontend split into `templates/` + `static/css/` + `static/js/`.
|
||||
- [x] User management tab — list users (uid ≥ 1000), create, delete,
|
||||
@@ -108,3 +105,7 @@ MicroNAS/
|
||||
- Database (config files are the source of truth).
|
||||
- Internet exposure / TLS termination (LAN + Tailscale only).
|
||||
- Clustering / multi-host management.
|
||||
- **Disk quotas** — removed. Linux `setquota`/`repquota` require quota
|
||||
support on the filesystem. The target deployment uses a ZFS-backed
|
||||
Proxmox LXC where this is unavailable inside the container. Per-dataset
|
||||
limits can be set from the Proxmox host with `zfs set quota=`.
|
||||
@@ -415,58 +415,6 @@ def toggle_nfs():
|
||||
return jsonify({'status': 'enabled' if enabled else 'disabled'})
|
||||
|
||||
|
||||
# ===== QUOTA =====
|
||||
|
||||
def _find_quota_fs(path):
|
||||
"""Return the best filesystem argument for setquota.
|
||||
|
||||
setquota needs a path whose filesystem has quota enabled. We try the
|
||||
exact path first, then walk up to the mountpoint reported by df. If df
|
||||
says '/' we still try the path itself — the user may have quota on root.
|
||||
"""
|
||||
r = subprocess.run(['df', '--output=target', path], capture_output=True, text=True)
|
||||
lines = [l.strip() for l in r.stdout.strip().splitlines()
|
||||
if l.strip() and l.strip() != 'Mounted on']
|
||||
return lines[0] if lines else path
|
||||
|
||||
|
||||
@app.route('/api/quota', methods=['POST'])
|
||||
@require_auth
|
||||
def set_quota():
|
||||
data = request.json or {}
|
||||
user = data.get('user', '')
|
||||
raw_quota = data.get('quota', 0)
|
||||
path = data.get('path', '/srv')
|
||||
|
||||
if not VALID_NAME.match(user):
|
||||
return jsonify({'error': 'invalid username'}), 400
|
||||
try:
|
||||
quota_gb = int(raw_quota)
|
||||
if quota_gb <= 0:
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'quota must be a positive integer (GB)'}), 400
|
||||
|
||||
try:
|
||||
safe_path = validate_path(path)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
mount = _find_quota_fs(safe_path)
|
||||
soft_kb = str(quota_gb * 1024 * 1024)
|
||||
hard_kb = str((quota_gb + 1) * 1024 * 1024)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['setquota', '-u', user, soft_kb, hard_kb, '0', '0', mount],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return jsonify({'error': r.stderr.strip() or 'setquota failed'}), 500
|
||||
except FileNotFoundError:
|
||||
return jsonify({'error': 'setquota not found — install quota package'}), 500
|
||||
return jsonify({'status': 'ok'})
|
||||
|
||||
|
||||
# ===== USERS =====
|
||||
|
||||
def _smb_users():
|
||||
@@ -613,64 +561,15 @@ def _shares_with_usage():
|
||||
return result
|
||||
|
||||
|
||||
def _quota_supported():
|
||||
"""Return (supported: bool, reason: str). Checks filesystem type under /srv."""
|
||||
r = subprocess.run(['df', '-T', '/srv'], capture_output=True, text=True)
|
||||
for line in r.stdout.splitlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
fstype = parts[1].lower()
|
||||
if fstype == 'zfs':
|
||||
return False, 'ZFS storage detected — Linux disk quotas are not available inside a ZFS-backed container. Manage per-dataset quotas from the Proxmox host with: zfs set quota=10G <dataset>'
|
||||
if fstype in ('tmpfs', 'overlay', 'aufs'):
|
||||
return False, f'{fstype} filesystem does not support disk quotas'
|
||||
return True, ''
|
||||
|
||||
|
||||
def _quota_usage():
|
||||
try:
|
||||
r = subprocess.run(['repquota', '-u', '/srv'], capture_output=True, text=True, timeout=10)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
quotas = []
|
||||
past_sep = False
|
||||
for line in r.stdout.splitlines():
|
||||
if line.startswith('---'):
|
||||
past_sep = True
|
||||
continue
|
||||
if not past_sep:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 5 or not re.match(r'^[+-]{2}$', parts[1]):
|
||||
continue
|
||||
try:
|
||||
used_kb, soft_kb, hard_kb = int(parts[2]), int(parts[3]), int(parts[4])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if soft_kb == 0 and hard_kb == 0 and used_kb == 0:
|
||||
continue
|
||||
quotas.append({
|
||||
'user': parts[0],
|
||||
'used_gb': round(used_kb / 1024**2, 2),
|
||||
'soft_gb': round(soft_kb / 1024**2, 2),
|
||||
'hard_gb': round(hard_kb / 1024**2, 2),
|
||||
'percent': round(used_kb / soft_kb * 100, 1) if soft_kb else 0,
|
||||
})
|
||||
return quotas
|
||||
|
||||
|
||||
@app.route('/api/dashboard', methods=['GET'])
|
||||
@require_auth
|
||||
def get_dashboard():
|
||||
quota_ok, quota_reason = _quota_supported()
|
||||
return jsonify({
|
||||
'status': _daemon_status(),
|
||||
'disk': _disk_usage(),
|
||||
'shares': _shares_with_usage(),
|
||||
'quotas': _quota_usage() if quota_ok else [],
|
||||
'quota_supported': quota_ok,
|
||||
'quota_reason': quota_reason,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -36,32 +36,5 @@ async function loadDashboard() {
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// Persist quota support flag for shares.js to read
|
||||
window._quotaSupported = d.quota_supported !== false;
|
||||
window._quotaReason = d.quota_reason || '';
|
||||
|
||||
const qb = document.getElementById('dashQuotasBody');
|
||||
if (!d.quota_supported) {
|
||||
qb.innerHTML = `<tr><td colspan="4" style="color:var(--muted); font-size:.87rem">${esc(d.quota_reason)}</td></tr>`;
|
||||
} else 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('');
|
||||
}
|
||||
|
||||
_startRefreshCountdown();
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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({ user: inp.dataset.user, quota: gb, path: _qCtx.path });
|
||||
}
|
||||
} 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({ user, path: _qCtx.path || '/srv', quota: gb });
|
||||
}
|
||||
|
||||
if (!calls.length) return showToast('No quota values entered.', 'warning');
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('quotaModal')).hide();
|
||||
|
||||
const responses = await Promise.all(calls.map(body =>
|
||||
apiFetch('/api/quota', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) })
|
||||
));
|
||||
const errors = [];
|
||||
for (const r of responses) {
|
||||
if (r && !r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
errors.push(d.error || `HTTP ${r.status}`);
|
||||
}
|
||||
}
|
||||
if (errors.length) showToast('Quota error: ' + errors.join('; '), 'danger');
|
||||
else showToast(`Quota${calls.length > 1 ? 's' : ''} applied.`);
|
||||
|
||||
loadDashboard();
|
||||
}
|
||||
@@ -14,7 +14,6 @@ async function loadShares() {
|
||||
const shareJson = esc(JSON.stringify(s));
|
||||
const nameJson = esc(JSON.stringify(s.name));
|
||||
const pathJson = esc(JSON.stringify(s.path));
|
||||
const usersJson = esc(JSON.stringify(s.valid_users));
|
||||
return `<tr style="${enabled ? '' : 'opacity:.55'}">
|
||||
<td>${esc(s.name)}</td>
|
||||
<td><code>${esc(s.path)}</code></td>
|
||||
@@ -23,8 +22,6 @@ async function loadShares() {
|
||||
<td>
|
||||
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
||||
onclick="openEditModal('smb',${shareJson})">Edit</button>
|
||||
${window._quotaSupported !== false ? `<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
||||
onclick="openQuotaModal('smb',${nameJson},${pathJson},${usersJson})">Quota</button>` : ''}
|
||||
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
||||
onclick="toggleShare('smb',${nameJson},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
@@ -50,8 +47,6 @@ async function loadShares() {
|
||||
<td>
|
||||
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
||||
onclick="openEditModal('nfs',${shareJson})">Edit</button>
|
||||
${window._quotaSupported !== false ? `<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
||||
onclick="openQuotaModal('nfs',null,${pathJson},null)">Quota</button>` : ''}
|
||||
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
||||
onclick="toggleShare('nfs',${pathJson},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
|
||||
@@ -31,23 +31,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quota modal -->
|
||||
<div class="modal fade" id="quotaModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="quotaModalTitle">Set Quota</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="quotaModalBody"></div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitQuota()">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Share modal -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
@@ -163,15 +146,6 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="fw-semibold mb-2">Quota Usage</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead><tr><th>User</th><th>Used</th><th>Soft limit</th><th style="min-width:200px">Usage</th></tr></thead>
|
||||
<tbody id="dashQuotasBody">
|
||||
<tr><td colspan="4" style="color:var(--muted)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMB -->
|
||||
@@ -245,7 +219,6 @@
|
||||
<script src="{{ url_for('static', filename='js/utils.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/shares.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/quotas.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/users.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
|
||||
|
||||
Reference in new issue
Block a user