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
@@ -14,11 +14,10 @@ daemons. Home/LAN use only.
|
|||||||
- `templates/login.html` — login form.
|
- `templates/login.html` — login form.
|
||||||
- `static/css/app.css` — custom dark theme (GitHub-palette CSS variables).
|
- `static/css/app.css` — custom dark theme (GitHub-palette CSS variables).
|
||||||
- `static/js/utils.js` — shared helpers: `esc()`, `showToast()`, `apiFetch()`.
|
- `static/js/utils.js` — shared helpers: `esc()`, `showToast()`, `apiFetch()`.
|
||||||
- `static/js/dashboard.js` — dashboard data loading.
|
- `static/js/dashboard.js` — dashboard: status, IPs, shares, active connections, restart buttons.
|
||||||
- `static/js/shares.js` — SMB/NFS CRUD (create, edit, delete, toggle).
|
- `static/js/shares.js` — SMB/NFS CRUD (create, edit, delete, toggle). Edit modal is async (fetches users+groups for dropdowns).
|
||||||
- `static/js/quotas.js` — quota modal logic.
|
- `static/js/users.js` — user CRUD + password modal + group management (loadGroups, createGroup, addMember, removeMember). Caches user list in `_cachedUsers` for group table.
|
||||||
- `static/js/users.js` — user CRUD + password modal.
|
- `static/js/main.js` — page init and 30 s auto-refresh (calls loadDashboard, loadShares, loadUsers, loadGroups).
|
||||||
- `static/js/main.js` — page init and 30 s auto-refresh.
|
|
||||||
- `micronas.service` — systemd unit (gunicorn, reads `/etc/micronas.env`).
|
- `micronas.service` — systemd unit (gunicorn, reads `/etc/micronas.env`).
|
||||||
|
|
||||||
## How it runs
|
## How it runs
|
||||||
|
|||||||
+14
-1
@@ -89,7 +89,20 @@ MicroNAS/
|
|||||||
- [x] Status indicator per share row (green/red dot, reduced opacity when
|
- [x] Status indicator per share row (green/red dot, reduced opacity when
|
||||||
disabled).
|
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
|
- [ ] **WebDAV** support. Preferred approach: drop an Apache/nginx config
|
||||||
snippet — don't build it in Flask.
|
snippet — don't build it in Flask.
|
||||||
- [ ] Simple in-browser file browser for `/srv`.
|
- [ ] Simple in-browser file browser for `/srv`.
|
||||||
|
|||||||
@@ -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.
|
**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
|
### NFS export settings
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ def parse_smb():
|
|||||||
if current is not None and current['name'].lower() not in _SMB_SKIP:
|
if current is not None and current['name'].lower() not in _SMB_SKIP:
|
||||||
shares.append(current)
|
shares.append(current)
|
||||||
current = {'name': m.group(1), 'path': '', 'valid_users': '',
|
current = {'name': m.group(1), 'path': '', 'valid_users': '',
|
||||||
'enabled': True, 'guest_ok': False,
|
'enabled': True, 'guest_ok': False, 'read_only': False,
|
||||||
'force_user': '', 'force_group': ''}
|
'force_user': '', 'force_group': '', 'recycle_bin': False}
|
||||||
elif current is not None and '=' in line:
|
elif current is not None and '=' in line:
|
||||||
key, _, val = line.partition('=')
|
key, _, val = line.partition('=')
|
||||||
key = key.strip().lower().replace(' ', '_')
|
key = key.strip().lower().replace(' ', '_')
|
||||||
@@ -139,10 +139,16 @@ def parse_smb():
|
|||||||
current['enabled'] = val.lower() not in ('no', 'false', '0')
|
current['enabled'] = val.lower() not in ('no', 'false', '0')
|
||||||
elif key == 'guest_ok':
|
elif key == 'guest_ok':
|
||||||
current['guest_ok'] = val.lower() in ('yes', 'true', '1')
|
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':
|
elif key == 'force_user':
|
||||||
current['force_user'] = val
|
current['force_user'] = val
|
||||||
elif key == 'force_group':
|
elif key == 'force_group':
|
||||||
current['force_group'] = val
|
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:
|
if current is not None and current['name'].lower() not in _SMB_SKIP:
|
||||||
shares.append(current)
|
shares.append(current)
|
||||||
@@ -318,9 +324,11 @@ def edit_smb(name):
|
|||||||
new_name = data.get('name', name).strip()
|
new_name = data.get('name', name).strip()
|
||||||
path = data.get('path', '')
|
path = data.get('path', '')
|
||||||
valid_users = data.get('validUsers', '')
|
valid_users = data.get('validUsers', '')
|
||||||
force_user = data.get('forceUser', '').strip()
|
force_user = data.get('forceUser', '').strip()
|
||||||
force_group = data.get('forceGroup', '').strip()
|
force_group = data.get('forceGroup', '').strip()
|
||||||
guest_ok = data.get('guestOk', False)
|
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):
|
if not VALID_NAME.match(new_name):
|
||||||
return jsonify({'error': 'invalid share name'}), 400
|
return jsonify({'error': 'invalid share name'}), 400
|
||||||
@@ -338,7 +346,7 @@ def edit_smb(name):
|
|||||||
f'[{new_name}]\n',
|
f'[{new_name}]\n',
|
||||||
f' path = {safe_path}\n',
|
f' path = {safe_path}\n',
|
||||||
f' valid users = {clean_users}\n',
|
f' valid users = {clean_users}\n',
|
||||||
' writable = yes\n',
|
f' {"read only" if read_only else "writable"} = yes\n',
|
||||||
' browseable = yes\n',
|
' browseable = yes\n',
|
||||||
]
|
]
|
||||||
if force_user:
|
if force_user:
|
||||||
@@ -347,6 +355,11 @@ def edit_smb(name):
|
|||||||
lines.append(f' force group = {force_group}\n')
|
lines.append(f' force group = {force_group}\n')
|
||||||
if guest_ok:
|
if guest_ok:
|
||||||
lines.append(' guest ok = yes\n')
|
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()
|
sections = _parse_smb_conf()
|
||||||
for s in sections:
|
for s in sections:
|
||||||
@@ -696,6 +709,57 @@ def _daemon_status():
|
|||||||
return {'smbd': is_active('smbd'), 'nfsd': is_active('nfs-server')}
|
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():
|
def _disk_usage():
|
||||||
try:
|
try:
|
||||||
u = shutil.disk_usage('/srv')
|
u = shutil.disk_usage('/srv')
|
||||||
@@ -746,6 +810,8 @@ def get_dashboard():
|
|||||||
'status': _daemon_status(),
|
'status': _daemon_status(),
|
||||||
'disk': _disk_usage(),
|
'disk': _disk_usage(),
|
||||||
'shares': _shares_with_usage(),
|
'shares': _shares_with_usage(),
|
||||||
|
'ips': _server_ips(),
|
||||||
|
'connections': _active_connections(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+40
-4
@@ -3,14 +3,20 @@ async function loadDashboard() {
|
|||||||
if (!res) return;
|
if (!res) return;
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
|
|
||||||
function setDaemon(id, running) {
|
// Service status cards with restart buttons
|
||||||
|
function setDaemon(id, service, running) {
|
||||||
document.getElementById(id).innerHTML =
|
document.getElementById(id).innerHTML =
|
||||||
`<span class="dot ${running ? 'dot-green' : 'dot-red'}"></span>
|
`<span class="dot ${running ? 'dot-green' : 'dot-red'}"></span>
|
||||||
<span class="small">${running ? 'Running' : 'Stopped'}</span>`;
|
<span class="small me-2">${running ? 'Running' : 'Stopped'}</span>
|
||||||
|
<button class="btn btn-sm ms-auto" style="border:1px solid var(--border); color:var(--muted); padding:1px 8px; font-size:.75rem"
|
||||||
|
onclick="restartService('${service}')">Restart</button>`;
|
||||||
|
document.getElementById(id).style.display = 'flex';
|
||||||
|
document.getElementById(id).style.alignItems = 'center';
|
||||||
}
|
}
|
||||||
setDaemon('statusSmbd', d.status.smbd);
|
setDaemon('statusSmbd', 'smbd', d.status.smbd);
|
||||||
setDaemon('statusNfsd', d.status.nfsd);
|
setDaemon('statusNfsd', 'nfsd', d.status.nfsd);
|
||||||
|
|
||||||
|
// Disk bar
|
||||||
if (d.disk && !d.disk.error) {
|
if (d.disk && !d.disk.error) {
|
||||||
const p = d.disk.percent;
|
const p = d.disk.percent;
|
||||||
const bar = document.getElementById('diskBar');
|
const bar = document.getElementById('diskBar');
|
||||||
@@ -22,6 +28,15 @@ async function loadDashboard() {
|
|||||||
document.getElementById('diskLabel').textContent = 'Error: ' + d.disk.error;
|
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 => `<code class="me-3">${esc(i.iface)}: ${esc(i.ip)}</code>`).join('')
|
||||||
|
: '<span style="color:var(--muted)">—</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shares table
|
||||||
const sb = document.getElementById('dashSharesBody');
|
const sb = document.getElementById('dashSharesBody');
|
||||||
if (!d.shares?.length) {
|
if (!d.shares?.length) {
|
||||||
sb.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">No shares configured.</td></tr>`;
|
sb.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">No shares configured.</td></tr>`;
|
||||||
@@ -36,5 +51,26 @@ async function loadDashboard() {
|
|||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active connections
|
||||||
|
const connEl = document.getElementById('activeConnectionsBody');
|
||||||
|
if (connEl) {
|
||||||
|
connEl.innerHTML = d.connections?.length
|
||||||
|
? d.connections.map(c => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(c.user)}</td>
|
||||||
|
<td style="color:var(--muted)">${esc(c.machine)}</td>
|
||||||
|
</tr>`).join('')
|
||||||
|
: `<tr><td colspan="2" style="color:var(--muted)">No active connections.</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
_startRefreshCountdown();
|
_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();
|
||||||
|
}
|
||||||
+15
-5
@@ -187,10 +187,18 @@ async function openEditModal(type, share) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check ms-1 mb-3">
|
<div class="form-check ms-1 mb-1">
|
||||||
|
<input type="checkbox" id="editReadOnly" class="form-check-input" ${share.read_only ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="editReadOnly" style="color:var(--muted)">Read only — clients cannot write</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check ms-1 mb-1">
|
||||||
<input type="checkbox" id="editGuestOk" class="form-check-input" ${share.guest_ok ? 'checked' : ''}>
|
<input type="checkbox" id="editGuestOk" class="form-check-input" ${share.guest_ok ? 'checked' : ''}>
|
||||||
<label class="form-check-label" for="editGuestOk" style="color:var(--muted)">Guest OK — allow access without a password</label>
|
<label class="form-check-label" for="editGuestOk" style="color:var(--muted)">Guest OK — allow access without a password</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check ms-1 mb-3">
|
||||||
|
<input type="checkbox" id="editRecycleBin" class="form-check-input" ${share.recycle_bin ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="editRecycleBin" style="color:var(--muted)">Recycle Bin — deleted files go to <code>.recycle</code> instead of being gone</label>
|
||||||
|
</div>
|
||||||
${permSection}`;
|
${permSection}`;
|
||||||
} else {
|
} else {
|
||||||
const isRo = /\(ro[,)]/.test(share.clients);
|
const isRo = /\(ro[,)]/.test(share.clients);
|
||||||
@@ -231,12 +239,14 @@ async function submitEdit() {
|
|||||||
const name = document.getElementById('editName').value.trim();
|
const name = document.getElementById('editName').value.trim();
|
||||||
const path = document.getElementById('editPath').value.trim();
|
const path = document.getElementById('editPath').value.trim();
|
||||||
const validUsers = document.getElementById('editUsers').value.trim();
|
const validUsers = document.getElementById('editUsers').value.trim();
|
||||||
const forceUser = document.getElementById('editForceUser').value;
|
const forceUser = document.getElementById('editForceUser').value;
|
||||||
const forceGroup = document.getElementById('editForceGroup').value;
|
const forceGroup = document.getElementById('editForceGroup').value;
|
||||||
const guestOk = document.getElementById('editGuestOk').checked;
|
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)}`, {
|
res = await apiFetch(`/api/share/smb/${encodeURIComponent(ctx.original.name)}`, {
|
||||||
method: 'PATCH', headers: {'Content-Type': 'application/json'},
|
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 {
|
} else {
|
||||||
const clients = document.getElementById('editClients').value.trim();
|
const clients = document.getElementById('editClients').value.trim();
|
||||||
|
|||||||
@@ -131,6 +131,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body py-2 px-3">
|
||||||
|
<span class="card-label me-3">Server IPs</span>
|
||||||
|
<span id="serverIps" style="font-size:.87em">…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
@@ -146,6 +154,16 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="fw-semibold mb-2">Active SMB Connections</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped mb-0">
|
||||||
|
<thead><tr><th>User</th><th>From</th></tr></thead>
|
||||||
|
<tbody id="activeConnectionsBody">
|
||||||
|
<tr><td colspan="2" style="color:var(--muted)">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SMB -->
|
<!-- SMB -->
|
||||||
|
|||||||
Reference in new issue
Block a user