Add dashboard IPs/restart/connections, SMB read-only and recycle bin

Dashboard:
- Server IPs card showing all non-loopback IPv4 interfaces
- Restart buttons next to smbd/nfsd status dots (POST /api/service/<name>/restart)
- Active SMB connections table (smbstatus -b parsed in backend)

SMB edit modal:
- Read only toggle (writable vs read only in smb.conf)
- Recycle Bin toggle (vfs objects = recycle, files go to .recycle folder)

Docs: PLANNING.md phase 3.5, README recycle bin + read-only, CLAUDE.md JS overview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Sonnet 4.6 committed 2026-06-20 01:13:32 +02:00
1 parent 1810d60d21
commit 1be6a05cef
7 files changed
+171 -21

No files matched your search

+72 -6
View File
@@ -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: <idx>: <iface> inet <ip/prefix> ...
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/<name>/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(),
})