diff --git a/CLAUDE.md b/CLAUDE.md index fcdceff..48abe92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,11 +14,10 @@ daemons. Home/LAN use only. - `templates/login.html` — login form. - `static/css/app.css` — custom dark theme (GitHub-palette CSS variables). - `static/js/utils.js` — shared helpers: `esc()`, `showToast()`, `apiFetch()`. -- `static/js/dashboard.js` — dashboard data loading. -- `static/js/shares.js` — SMB/NFS CRUD (create, edit, delete, toggle). -- `static/js/quotas.js` — quota modal logic. -- `static/js/users.js` — user CRUD + password modal. -- `static/js/main.js` — page init and 30 s auto-refresh. +- `static/js/dashboard.js` — dashboard: status, IPs, shares, active connections, restart buttons. +- `static/js/shares.js` — SMB/NFS CRUD (create, edit, delete, toggle). Edit modal is async (fetches users+groups for dropdowns). +- `static/js/users.js` — user CRUD + password modal + group management (loadGroups, createGroup, addMember, removeMember). Caches user list in `_cachedUsers` for group table. +- `static/js/main.js` — page init and 30 s auto-refresh (calls loadDashboard, loadShares, loadUsers, loadGroups). - `micronas.service` — systemd unit (gunicorn, reads `/etc/micronas.env`). ## How it runs diff --git a/PLANNING.md b/PLANNING.md index 40f43de..1f03374 100644 --- a/PLANNING.md +++ b/PLANNING.md @@ -89,7 +89,20 @@ MicroNAS/ - [x] Status indicator per share row (green/red dot, reduced opacity when disabled). -### Phase 4 — Nice-to-have +### Phase 3.5 — Share & dashboard polish ✅ +- [x] **Permission editor in edit modal** — owner, group, chmod mode per share + folder. Owner/Group are dropdowns from live system users/groups. +- [x] **Group management** — create/delete groups, add/remove members. Users tab. +- [x] **SMB extra settings** — force user, force group, guest ok, read only, + recycle bin (vfs objects = recycle, files go to .recycle inside share). +- [x] **NFS extra settings** — no_root_squash, all_squash toggles. +- [x] **Server IPs on dashboard** — shows all non-loopback IPv4 addresses. +- [x] **Service restart buttons** — restart smbd / nfsd from the dashboard. +- [x] **Active SMB connections** — live table on dashboard via smbstatus. +- [x] **Delete confirmation modal** — replaces browser confirm(). +- [x] **Auto-refresh countdown** — 30 s timer with live seconds display. + +### Phase 4 — WebDAV + extras - [ ] **WebDAV** support. Preferred approach: drop an Apache/nginx config snippet — don't build it in Flask. - [ ] Simple in-browser file browser for `/srv`. diff --git a/README.md b/README.md index c1bd83b..a6b5735 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,14 @@ Typical shared folder recipe: **Guest OK** — allows connecting without any username or password. Anyone on the network can access the share. Good for a public read-only share (combine with a `755` folder mode so guests can read but not write). For a writable guest share, set mode `777` — but only do this on a fully trusted LAN. +**Read only** — clients can read files but not write, rename, or delete anything. Overrides writable setting. + +**Recycle Bin** — when enabled, files deleted through the share go to a `.recycle` folder inside the share instead of being permanently gone. Clients (Windows Explorer, Nautilus) can browse and restore from there. Files stay until you manually delete them — there is no automatic expiry. To clean files older than 30 days add a cron job: + +```bash +find /srv/yourshare/.recycle -mtime +30 -delete +``` + --- ### NFS export settings diff --git a/app.py b/app.py index d7085c8..12c94cd 100644 --- a/app.py +++ b/app.py @@ -125,8 +125,8 @@ def parse_smb(): if current is not None and current['name'].lower() not in _SMB_SKIP: shares.append(current) current = {'name': m.group(1), 'path': '', 'valid_users': '', - 'enabled': True, 'guest_ok': False, - 'force_user': '', 'force_group': ''} + 'enabled': True, 'guest_ok': False, 'read_only': False, + 'force_user': '', 'force_group': '', 'recycle_bin': False} elif current is not None and '=' in line: key, _, val = line.partition('=') key = key.strip().lower().replace(' ', '_') @@ -139,10 +139,16 @@ def parse_smb(): current['enabled'] = val.lower() not in ('no', 'false', '0') elif key == 'guest_ok': current['guest_ok'] = val.lower() in ('yes', 'true', '1') + elif key in ('writable', 'writeable'): + current['read_only'] = val.lower() in ('no', 'false', '0') + elif key == 'read_only': + current['read_only'] = val.lower() in ('yes', 'true', '1') elif key == 'force_user': current['force_user'] = val elif key == 'force_group': current['force_group'] = val + elif key == 'vfs_objects' and 'recycle' in val: + current['recycle_bin'] = True if current is not None and current['name'].lower() not in _SMB_SKIP: shares.append(current) @@ -318,9 +324,11 @@ def edit_smb(name): new_name = data.get('name', name).strip() path = data.get('path', '') valid_users = data.get('validUsers', '') - force_user = data.get('forceUser', '').strip() - force_group = data.get('forceGroup', '').strip() - guest_ok = data.get('guestOk', False) + force_user = data.get('forceUser', '').strip() + force_group = data.get('forceGroup', '').strip() + guest_ok = data.get('guestOk', False) + read_only = data.get('readOnly', False) + recycle_bin = data.get('recycleBin', False) if not VALID_NAME.match(new_name): return jsonify({'error': 'invalid share name'}), 400 @@ -338,7 +346,7 @@ def edit_smb(name): f'[{new_name}]\n', f' path = {safe_path}\n', f' valid users = {clean_users}\n', - ' writable = yes\n', + f' {"read only" if read_only else "writable"} = yes\n', ' browseable = yes\n', ] if force_user: @@ -347,6 +355,11 @@ def edit_smb(name): lines.append(f' force group = {force_group}\n') if guest_ok: lines.append(' guest ok = yes\n') + if recycle_bin: + lines.append(' vfs objects = recycle\n') + lines.append(' recycle:repository = .recycle\n') + lines.append(' recycle:keeptree = yes\n') + lines.append(' recycle:versions = yes\n') sections = _parse_smb_conf() for s in sections: @@ -696,6 +709,57 @@ def _daemon_status(): return {'smbd': is_active('smbd'), 'nfsd': is_active('nfs-server')} +def _server_ips(): + """Return list of {iface, ip} for all non-loopback IPv4 addresses.""" + try: + r = subprocess.run(['ip', '-4', '-o', 'addr', 'show'], capture_output=True, text=True) + result = [] + for line in r.stdout.splitlines(): + # Format: : inet ... + m = re.match(r'\d+:\s+(\S+)\s+inet\s+([\d.]+)', line) + if m and m.group(1) != 'lo': + result.append({'iface': m.group(1), 'ip': m.group(2)}) + return result + except Exception: + return [] + + +def _active_connections(): + """Parse smbstatus output to return list of active SMB sessions.""" + try: + r = subprocess.run(['smbstatus', '-b'], capture_output=True, text=True, timeout=5) + sessions = [] + in_section = False + for line in r.stdout.splitlines(): + if re.match(r'-{10,}', line): + in_section = True + continue + if not in_section or not line.strip(): + continue + # "PID Username Group Machine Protocol Encryption Signing" + # Skip the header row + if line.strip().startswith('PID'): + continue + parts = line.split() + if len(parts) >= 4: + sessions.append({'user': parts[1], 'machine': parts[3]}) + return sessions + except Exception: + return [] + + +@app.route('/api/service//restart', methods=['POST']) +@require_auth +def restart_service(name): + service_map = {'smbd': 'smbd', 'nfsd': 'nfs-server'} + if name not in service_map: + return jsonify({'error': 'unknown service'}), 400 + r = subprocess.run(['systemctl', 'restart', service_map[name]], capture_output=True, text=True) + if r.returncode != 0: + return jsonify({'error': r.stderr.strip() or 'restart failed'}), 500 + return jsonify({'status': 'restarted'}) + + def _disk_usage(): try: u = shutil.disk_usage('/srv') @@ -746,6 +810,8 @@ def get_dashboard(): 'status': _daemon_status(), 'disk': _disk_usage(), 'shares': _shares_with_usage(), + 'ips': _server_ips(), + 'connections': _active_connections(), }) diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 835b232..79d0e67 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -3,14 +3,20 @@ async function loadDashboard() { if (!res) return; const d = await res.json(); - function setDaemon(id, running) { + // Service status cards with restart buttons + function setDaemon(id, service, running) { document.getElementById(id).innerHTML = ` - ${running ? 'Running' : 'Stopped'}`; + ${running ? 'Running' : 'Stopped'} + `; + document.getElementById(id).style.display = 'flex'; + document.getElementById(id).style.alignItems = 'center'; } - setDaemon('statusSmbd', d.status.smbd); - setDaemon('statusNfsd', d.status.nfsd); + setDaemon('statusSmbd', 'smbd', d.status.smbd); + setDaemon('statusNfsd', 'nfsd', d.status.nfsd); + // Disk bar if (d.disk && !d.disk.error) { const p = d.disk.percent; const bar = document.getElementById('diskBar'); @@ -22,6 +28,15 @@ async function loadDashboard() { document.getElementById('diskLabel').textContent = 'Error: ' + d.disk.error; } + // Server IPs + const ipEl = document.getElementById('serverIps'); + if (ipEl) { + ipEl.innerHTML = d.ips?.length + ? d.ips.map(i => `${esc(i.iface)}: ${esc(i.ip)}`).join('') + : '—'; + } + + // Shares table const sb = document.getElementById('dashSharesBody'); if (!d.shares?.length) { sb.innerHTML = `No shares configured.`; @@ -36,5 +51,26 @@ async function loadDashboard() { `).join(''); } + // Active connections + const connEl = document.getElementById('activeConnectionsBody'); + if (connEl) { + connEl.innerHTML = d.connections?.length + ? d.connections.map(c => ` + + ${esc(c.user)} + ${esc(c.machine)} + `).join('') + : `No active connections.`; + } + _startRefreshCountdown(); } + +async function restartService(name) { + const res = await apiFetch(`/api/service/${name}/restart`, { method: 'POST' }); + if (!res) return; + const data = await res.json(); + if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); + showToast(`${name} restarted.`); + loadDashboard(); +} diff --git a/static/js/shares.js b/static/js/shares.js index edf96f6..835b2e7 100644 --- a/static/js/shares.js +++ b/static/js/shares.js @@ -187,10 +187,18 @@ async function openEditModal(type, share) { -
+
+ + +
+
+
+ + +
${permSection}`; } else { const isRo = /\(ro[,)]/.test(share.clients); @@ -231,12 +239,14 @@ async function submitEdit() { const name = document.getElementById('editName').value.trim(); const path = document.getElementById('editPath').value.trim(); const validUsers = document.getElementById('editUsers').value.trim(); - const forceUser = document.getElementById('editForceUser').value; - const forceGroup = document.getElementById('editForceGroup').value; - const guestOk = document.getElementById('editGuestOk').checked; + const forceUser = document.getElementById('editForceUser').value; + const forceGroup = document.getElementById('editForceGroup').value; + const guestOk = document.getElementById('editGuestOk').checked; + const readOnly = document.getElementById('editReadOnly').checked; + const recycleBin = document.getElementById('editRecycleBin').checked; res = await apiFetch(`/api/share/smb/${encodeURIComponent(ctx.original.name)}`, { method: 'PATCH', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({name, path, validUsers, forceUser, forceGroup, guestOk}), + body: JSON.stringify({name, path, validUsers, forceUser, forceGroup, guestOk, readOnly, recycleBin}), }); } else { const clients = document.getElementById('editClients').value.trim(); diff --git a/templates/index.html b/templates/index.html index 4d80f35..099b36b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -131,6 +131,14 @@
+
+
+
+ Server IPs + … +
+
+
@@ -146,6 +154,16 @@
+
Active SMB Connections
+
+ + + + + +
UserFrom
Loading…
+
+