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:
1 parent
1810d60d21
commit
1be6a05cef
7 files changed
+171
-21
No files matched your search
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user