From bf7daf1c2cddbdb19b39f92bb88a8d44d9d6469b Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 19 Jun 2026 23:07:28 +0200 Subject: [PATCH] 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 --- PLANNING.md | 7 +-- app.py | 101 ----------------------------------------- static/js/dashboard.js | 27 ----------- static/js/quotas.js | 76 ------------------------------- static/js/shares.js | 5 -- templates/index.html | 27 ----------- 6 files changed, 4 insertions(+), 239 deletions(-) delete mode 100644 static/js/quotas.js diff --git a/PLANNING.md b/PLANNING.md index 68c9f2f..6b8ba05 100644 --- a/PLANNING.md +++ b/PLANNING.md @@ -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=`. diff --git a/app.py b/app.py index 87d395b..2f3b2da 100644 --- a/app.py +++ b/app.py @@ -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 ' - 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, }) diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 02628aa..835b232 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -36,32 +36,5 @@ async function loadDashboard() { `).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 = `${esc(d.quota_reason)}`; - } else 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(''); - } - _startRefreshCountdown(); } diff --git a/static/js/quotas.js b/static/js/quotas.js deleted file mode 100644 index eff8ee5..0000000 --- a/static/js/quotas.js +++ /dev/null @@ -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 => ` -
- -
- - 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({ 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(); -} diff --git a/static/js/shares.js b/static/js/shares.js index fba41fa..f373ecd 100644 --- a/static/js/shares.js +++ b/static/js/shares.js @@ -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 ` ${esc(s.name)} ${esc(s.path)} @@ -23,8 +22,6 @@ async function loadShares() { - ${window._quotaSupported !== false ? `` : ''} - ${window._quotaSupported !== false ? `` : ''} - - - - - - - @@ -245,7 +219,6 @@ -