Phase 2.5: UI redesign, inline quotas, toasts, Users tab
- Full UI redesign: GitHub-dark palette, custom CSS vars, better contrast, card layout, cleaner tables and typography - Dashboard auto-refresh every 30s (was 60s) - Remove Quotas tab; each share row now has a Quota button that opens a modal pre-filled with the share's valid users (SMB) or path (NFS) - Replace all alert() with Bootstrap toast notifications - Add Users tab: list system users (uid >= 1000) with SMB status, create user (useradd + smbpasswd), delete user, change SMB password - Backend: /api/users GET/POST, /api/users/<name> DELETE, /api/users/<name>/password POST Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
d237f4d290
commit
822e6b3b87
3 files changed
+561
-180
No files matched your search
+29
-9
@@ -64,19 +64,40 @@ on a second client). This app does not implement backup.
|
|||||||
|
|
||||||
### Phase 2 — Dashboard & health (the main goal)
|
### Phase 2 — Dashboard & health (the main goal)
|
||||||
A landing page that shows the state of the server at a glance:
|
A landing page that shows the state of the server at a glance:
|
||||||
- [ ] **Daemon status** — is `smbd` running? is `nfsd` running? (green/red).
|
- [x] **Daemon status** — is `smbd` running? is `nfsd` running? (green/red).
|
||||||
- [ ] **Disk usage** — free / used / total for `/srv` (and per-disk if simple).
|
- [x] **Disk usage** — free / used / total for `/srv`.
|
||||||
- [ ] **All shares in one view** — every SMB share and NFS export, their paths,
|
- [x] **All shares in one view** — every SMB share and NFS export, their paths,
|
||||||
and who can access them.
|
and who can access them.
|
||||||
- [ ] **Per-share data used** — size on disk of each share's path (`du`).
|
- [x] **Per-share data used** — size on disk of each share's path (`du`).
|
||||||
- [ ] **Quota usage** — for each user with a quota: used vs limit, with a
|
- [x] **Quota usage** — for each user with a quota: used vs limit, with a
|
||||||
progress bar (e.g. "12 GB / 50 GB").
|
progress bar.
|
||||||
- [ ] Auto-refresh or a manual refresh button.
|
- [x] Auto-refresh every 30 seconds + manual refresh button.
|
||||||
|
|
||||||
|
### Phase 2.5 — UX improvements (next up)
|
||||||
|
Improvements identified after first real test:
|
||||||
|
|
||||||
|
- [x] **UI redesign** — GitHub-dark palette, better contrast, card layout,
|
||||||
|
breathing room. Custom CSS variables, no extra dependencies.
|
||||||
|
- [x] **Inline quota management** — removed dedicated Quotas tab. Each SMB
|
||||||
|
share and NFS export row has a "Quota" button that opens a modal.
|
||||||
|
For SMB: lists valid_users with individual GB inputs. For NFS: manual
|
||||||
|
user + GB. Submits one API call per user in parallel.
|
||||||
|
- [x] **Toast notifications** — replaced all `alert()` with Bootstrap toasts.
|
||||||
|
- [x] **Auto-refresh every 30 s** (was 60 s).
|
||||||
|
- [ ] **User management tab** — create, delete, and change the SMB password
|
||||||
|
of NAS users without touching the command line:
|
||||||
|
- List all system users (uid ≥ 1000), show whether they have a Samba
|
||||||
|
account.
|
||||||
|
- Create user: `useradd -M -s /usr/sbin/nologin` + `smbpasswd -a`.
|
||||||
|
- Delete user: `smbpasswd -x` + `userdel`.
|
||||||
|
- Change SMB password: `smbpasswd -s` via modal.
|
||||||
|
- Backend: `/api/users` GET/POST, `/api/users/<name>` DELETE,
|
||||||
|
`/api/users/<name>/password` POST.
|
||||||
|
|
||||||
### Phase 3 — Full management (complete the CRUD)
|
### Phase 3 — Full management (complete the CRUD)
|
||||||
- [ ] Working **delete** for SMB & NFS (parse config into blocks, rewrite the
|
- [ ] Working **delete** for SMB & NFS (parse config into blocks, rewrite the
|
||||||
whole file — not append-only).
|
whole file — not append-only).
|
||||||
- [ ] **Edit** existing shares.
|
- [ ] **Edit** existing shares (path, valid users).
|
||||||
- [ ] Enable/disable a share without deleting it.
|
- [ ] Enable/disable a share without deleting it.
|
||||||
|
|
||||||
### Phase 4 — Nice-to-have
|
### Phase 4 — Nice-to-have
|
||||||
@@ -84,7 +105,6 @@ A landing page that shows the state of the server at a glance:
|
|||||||
app drop an Apache/nginx WebDAV config snippet, same pattern as SMB/NFS,
|
app drop an Apache/nginx WebDAV config snippet, same pattern as SMB/NFS,
|
||||||
so it reuses the existing architecture.
|
so it reuses the existing architecture.
|
||||||
- [ ] Simple in-browser file browser for `/srv`.
|
- [ ] Simple in-browser file browser for `/srv`.
|
||||||
- [ ] Toast notifications instead of `alert()`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,101 @@ def set_quota():
|
|||||||
return jsonify({'status': 'ok'})
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
|
||||||
|
# ===== USERS =====
|
||||||
|
|
||||||
|
def _smb_users():
|
||||||
|
try:
|
||||||
|
r = subprocess.run(['pdbedit', '-L'], capture_output=True, text=True)
|
||||||
|
return {line.split(':')[0].strip() for line in r.stdout.splitlines() if ':' in line}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_users():
|
||||||
|
smb = _smb_users()
|
||||||
|
users = []
|
||||||
|
with open('/etc/passwd', 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.strip().split(':')
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
name, _, uid = parts[0], parts[1], parts[2]
|
||||||
|
uid = int(uid)
|
||||||
|
if uid < 1000 or uid == 65534:
|
||||||
|
continue
|
||||||
|
users.append({'name': name, 'uid': uid, 'smb': name in smb})
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
def _set_smb_password(name, password):
|
||||||
|
r = subprocess.run(
|
||||||
|
['smbpasswd', '-a', '-s', name],
|
||||||
|
input=f'{password}\n{password}\n',
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(r.stderr.strip() or 'smbpasswd failed')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/users', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def list_users():
|
||||||
|
return jsonify(_list_users())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/users', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def create_user():
|
||||||
|
data = request.json or {}
|
||||||
|
name = data.get('name', '')
|
||||||
|
password = data.get('password', '')
|
||||||
|
|
||||||
|
if not VALID_NAME.match(name):
|
||||||
|
return jsonify({'error': 'invalid username (letters, digits, _ -)'}), 400
|
||||||
|
if len(password) < 8:
|
||||||
|
return jsonify({'error': 'password must be at least 8 characters'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(['useradd', '-M', '-s', '/usr/sbin/nologin', name], check=True)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return jsonify({'error': f'user {name!r} already exists or useradd failed'}), 400
|
||||||
|
try:
|
||||||
|
_set_smb_password(name, password)
|
||||||
|
except RuntimeError as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
return jsonify({'status': 'created'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/users/<name>', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def delete_user(name):
|
||||||
|
if not VALID_NAME.match(name):
|
||||||
|
return jsonify({'error': 'invalid username'}), 400
|
||||||
|
subprocess.run(['smbpasswd', '-x', name], capture_output=True)
|
||||||
|
try:
|
||||||
|
subprocess.run(['userdel', name], check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
return jsonify({'error': f'userdel failed: {e}'}), 500
|
||||||
|
return jsonify({'status': 'deleted'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/users/<name>/password', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def change_password(name):
|
||||||
|
if not VALID_NAME.match(name):
|
||||||
|
return jsonify({'error': 'invalid username'}), 400
|
||||||
|
data = request.json or {}
|
||||||
|
password = data.get('password', '')
|
||||||
|
if len(password) < 8:
|
||||||
|
return jsonify({'error': 'password must be at least 8 characters'}), 400
|
||||||
|
try:
|
||||||
|
_set_smb_password(name, password)
|
||||||
|
except RuntimeError as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
return jsonify({'status': 'updated'})
|
||||||
|
|
||||||
|
|
||||||
# ===== DASHBOARD =====
|
# ===== DASHBOARD =====
|
||||||
|
|
||||||
def _daemon_status():
|
def _daemon_status():
|
||||||
|
|||||||
+437
-171
@@ -4,16 +4,120 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>MicroNAS</title>
|
<title>MicroNAS</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--surf: #161b22;
|
||||||
|
--surf2: #21262d;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--muted: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--red: #f85149;
|
||||||
|
--yellow: #d29922;
|
||||||
|
}
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||||
|
|
||||||
|
/* Nav */
|
||||||
|
.topbar { background: var(--surf); border-bottom: 1px solid var(--border); padding: .75rem 1.5rem; }
|
||||||
|
.nav-tabs { border-color: var(--border); }
|
||||||
|
.nav-tabs .nav-link { color: var(--muted); border-color: transparent; padding: .6rem 1rem; }
|
||||||
|
.nav-tabs .nav-link:hover { color: var(--text); background: var(--surf2); border-color: var(--border); }
|
||||||
|
.nav-tabs .nav-link.active { color: var(--text); background: var(--bg); border-color: var(--border) var(--border) var(--bg); }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card { background: var(--surf); border: 1px solid var(--border); border-radius: 8px; }
|
||||||
|
.card-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: .3rem; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table { color: var(--text); }
|
||||||
|
.table > :not(caption) > * > * { background: transparent; border-color: var(--border); padding: .65rem .85rem; }
|
||||||
|
.table thead th { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 500; }
|
||||||
|
.table-striped > tbody > tr:nth-of-type(odd) > * { background: var(--surf2); }
|
||||||
|
.table tbody tr:hover > * { background: #1c2128; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-control, .form-select { background: var(--surf2); border-color: var(--border); color: var(--text); }
|
||||||
|
.form-control:focus, .form-select:focus { background: var(--surf2); border-color: var(--accent); color: var(--text); box-shadow: 0 0 0 3px rgba(88,166,255,.15); }
|
||||||
|
.form-control::placeholder { color: var(--muted); }
|
||||||
|
.input-group-text { background: var(--surf2); border-color: var(--border); color: var(--muted); }
|
||||||
|
|
||||||
|
/* Status dot */
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 6px; flex-shrink: 0; }
|
||||||
|
.dot-green { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
|
.dot-red { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.progress { background: var(--surf2); border-radius: 4px; }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge-smb { background: #1f3a5f; color: #58a6ff; font-size: .72rem; }
|
||||||
|
.badge-nfs { background: #2d1f5f; color: #a78bfa; font-size: .72rem; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-content { background: var(--surf); border: 1px solid var(--border); }
|
||||||
|
.modal-header, .modal-footer { border-color: var(--border); }
|
||||||
|
.btn-close-white { filter: invert(1) grayscale(100%) brightness(200%); }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
#toastContainer { position: fixed; top: 1rem; right: 1rem; z-index: 1100; min-width: 260px; }
|
||||||
|
|
||||||
|
code { color: var(--accent); background: var(--surf2); padding: .1em .35em; border-radius: 4px; font-size: .87em; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-dark text-white p-4">
|
<body>
|
||||||
|
|
||||||
<div class="container-lg">
|
<!-- Toast container -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div id="toastContainer"></div>
|
||||||
<h1 class="mb-0">MicroNAS</h1>
|
|
||||||
<span id="lastRefresh" class="text-muted small"></span>
|
<!-- Password modal -->
|
||||||
|
<div class="modal fade" id="pwModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="pwModalTitle">Change Password</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="text" id="pwUsername" class="form-control mb-3" readonly style="color:var(--muted)">
|
||||||
|
<input type="password" id="pwNew" class="form-control mb-2" placeholder="New password (min 8 chars)">
|
||||||
|
<input type="password" id="pwConfirm" class="form-control" placeholder="Confirm password">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="submitPassword()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="nav nav-tabs" role="tablist">
|
<!-- Quota modal -->
|
||||||
|
<div class="modal fade" id="quotaModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="quotaModalTitle">Set Quota</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="quotaModalBody"></div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="submitQuota()">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="topbar d-flex justify-content-between align-items-center mb-0">
|
||||||
|
<span class="fw-semibold fs-5">MicroNAS</span>
|
||||||
|
<span id="lastRefresh" class="small" style="color:var(--muted)"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container-lg py-4">
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-0" role="tablist">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#dashboard" type="button">Dashboard</button>
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#dashboard" type="button">Dashboard</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -24,181 +128,198 @@
|
|||||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#nfs" type="button">NFS Exports</button>
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#nfs" type="button">NFS Exports</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#quotas" type="button">Quotas</button>
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#users" type="button">Users</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content" style="border: 1px solid var(--border); border-top: none; border-radius: 0 0 8px 8px; padding: 1.5rem; background: var(--bg);">
|
||||||
|
|
||||||
<!-- DASHBOARD TAB -->
|
<!-- ===== DASHBOARD ===== -->
|
||||||
<div class="tab-pane fade show active mt-4" id="dashboard">
|
<div class="tab-pane fade show active" id="dashboard">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h3 class="mb-0">Dashboard</h3>
|
|
||||||
<button class="btn btn-sm btn-outline-light" onclick="loadDashboard()">↻ Refresh</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Daemon status + disk -->
|
<!-- Status row -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-md-2">
|
<div class="col-6 col-md-2">
|
||||||
<div class="card border-secondary bg-secondary text-center">
|
<div class="card h-100">
|
||||||
<div class="card-body py-3">
|
<div class="card-body py-3 px-3">
|
||||||
<div class="small text-muted mb-1">smbd</div>
|
<div class="card-label">smbd</div>
|
||||||
<span id="statusSmbd" class="badge fs-6">…</span>
|
<div class="d-flex align-items-center mt-1" id="statusSmbd">
|
||||||
</div>
|
<span class="dot dot-red"></span><span class="small">…</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<div class="card border-secondary bg-secondary text-center">
|
|
||||||
<div class="card-body py-3">
|
|
||||||
<div class="small text-muted mb-1">nfsd</div>
|
|
||||||
<span id="statusNfsd" class="badge fs-6">…</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card border-secondary bg-secondary">
|
|
||||||
<div class="card-body py-3">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span class="small fw-semibold">/srv</span>
|
|
||||||
<span id="diskLabel" class="small text-muted">…</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="progress" style="height: 18px;">
|
</div>
|
||||||
<div id="diskBar" class="progress-bar" role="progressbar" style="width:0%">0%</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-2">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body py-3 px-3">
|
||||||
|
<div class="card-label">nfsd</div>
|
||||||
|
<div class="d-flex align-items-center mt-1" id="statusNfsd">
|
||||||
|
<span class="dot dot-red"></span><span class="small">…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-8">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body py-3 px-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-baseline mb-2">
|
||||||
|
<div class="card-label">/srv disk</div>
|
||||||
|
<span id="diskLabel" class="small" style="color:var(--muted)">…</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height:10px">
|
||||||
|
<div id="diskBar" class="progress-bar" style="width:0%; transition: width .6s ease"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shares overview -->
|
<!-- Shares -->
|
||||||
<h5>Shares</h5>
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="fw-semibold">Shares</span>
|
||||||
|
<button class="btn btn-sm" style="color:var(--muted); border: 1px solid var(--border);" onclick="loadDashboard()">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-4">
|
||||||
<table class="table table-dark table-striped table-sm align-middle">
|
<table class="table table-striped mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Type</th>
|
<th>Type</th><th>Name</th><th>Path</th><th>Access</th><th class="text-end">Size</th>
|
||||||
<th>Name</th>
|
|
||||||
<th>Path</th>
|
|
||||||
<th>Access</th>
|
|
||||||
<th class="text-end">Size on disk</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="dashSharesBody">
|
<tbody id="dashSharesBody">
|
||||||
<tr><td colspan="5" class="text-muted">Loading…</td></tr>
|
<tr><td colspan="5" style="color:var(--muted)">Loading…</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quota usage -->
|
<!-- Quotas -->
|
||||||
<h5>Quota Usage</h5>
|
<div class="fw-semibold mb-2">Quota Usage</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-dark table-striped table-sm align-middle">
|
<table class="table table-striped mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr><th>User</th><th>Used</th><th>Soft limit</th><th style="min-width:200px">Usage</th></tr>
|
||||||
<th>User</th>
|
|
||||||
<th>Used</th>
|
|
||||||
<th>Soft limit</th>
|
|
||||||
<th style="min-width:180px">Usage</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="dashQuotasBody">
|
<tbody id="dashQuotasBody">
|
||||||
<tr><td colspan="4" class="text-muted">Loading…</td></tr>
|
<tr><td colspan="4" style="color:var(--muted)">Loading…</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SMB TAB -->
|
<!-- ===== SMB ===== -->
|
||||||
<div class="tab-pane fade mt-4" id="smb">
|
<div class="tab-pane fade" id="smb">
|
||||||
<h3>SMB Shares</h3>
|
<div class="fw-semibold mb-3">Add SMB Share</div>
|
||||||
<form class="row g-3 mb-4">
|
<div class="row g-2 mb-4">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<input type="text" id="smbName" class="form-control" placeholder="Share name" required>
|
<input type="text" id="smbName" class="form-control" placeholder="Share name">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<input type="text" id="smbPath" class="form-control" placeholder="/srv/samba/…" required>
|
<input type="text" id="smbPath" class="form-control" placeholder="/srv/samba/…">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<input type="text" id="smbUsers" class="form-control" placeholder="user1,user2">
|
<input type="text" id="smbUsers" class="form-control" placeholder="user1,user2">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<button type="button" class="btn btn-success w-100" onclick="addSMBShare()">Add SMB</button>
|
<button type="button" class="btn btn-success w-100" onclick="addSMBShare()">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
<div class="fw-semibold mb-2">Shares</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-dark table-striped">
|
<table class="table table-striped mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Name</th><th>Path</th><th>Valid Users</th><th>Actions</th></tr>
|
<tr><th>Name</th><th>Path</th><th>Valid Users</th><th>Actions</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="smbTableBody"></tbody>
|
<tbody id="smbTableBody">
|
||||||
|
<tr><td colspan="4" style="color:var(--muted)">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- NFS TAB -->
|
<!-- ===== NFS ===== -->
|
||||||
<div class="tab-pane fade mt-4" id="nfs">
|
<div class="tab-pane fade" id="nfs">
|
||||||
<h3>NFS Exports</h3>
|
<div class="fw-semibold mb-3">Add NFS Export</div>
|
||||||
<form class="row g-3 mb-4">
|
<div class="row g-2 mb-4">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<input type="text" id="nfsPath" class="form-control" placeholder="/srv/data" required>
|
<input type="text" id="nfsPath" class="form-control" placeholder="/srv/data">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<input type="text" id="nfsClients" class="form-control" placeholder="192.168.1.0/24 or *">
|
<input type="text" id="nfsClients" class="form-control" placeholder="192.168.1.0/24 or *">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 d-flex align-items-center">
|
<div class="col-md-2 d-flex align-items-center">
|
||||||
<div class="form-check">
|
<div class="form-check ms-1">
|
||||||
<input type="checkbox" id="nfsReadOnly" class="form-check-input">
|
<input type="checkbox" id="nfsReadOnly" class="form-check-input">
|
||||||
<label class="form-check-label" for="nfsReadOnly">Read Only</label>
|
<label class="form-check-label" for="nfsReadOnly" style="color:var(--muted)">Read only</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<button type="button" class="btn btn-success w-100" onclick="addNFSShare()">Add NFS</button>
|
<button type="button" class="btn btn-success w-100" onclick="addNFSShare()">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
<div class="fw-semibold mb-2">Exports</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-dark table-striped">
|
<table class="table table-striped mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Path</th><th>Clients</th><th>Actions</th></tr>
|
<tr><th>Path</th><th>Clients</th><th>Actions</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="nfsTableBody"></tbody>
|
<tbody id="nfsTableBody">
|
||||||
|
<tr><td colspan="3" style="color:var(--muted)">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- QUOTAS TAB -->
|
<!-- ===== USERS ===== -->
|
||||||
<div class="tab-pane fade mt-4" id="quotas">
|
<div class="tab-pane fade" id="users">
|
||||||
<h3>Set Quota</h3>
|
<div class="fw-semibold mb-3">Create User</div>
|
||||||
<form class="row g-3 mb-4">
|
<div class="row g-2 mb-4">
|
||||||
<div class="col-md-2">
|
|
||||||
<select id="quotaType" class="form-select">
|
|
||||||
<option value="smb">SMB</option>
|
|
||||||
<option value="nfs">NFS</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<input type="text" id="quotaUser" class="form-control" placeholder="Username">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<input type="text" id="quotaPath" class="form-control" placeholder="/srv/data (NFS only)">
|
<input type="text" id="newUsername" class="form-control" placeholder="Username">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="password" id="newPassword" class="form-control" placeholder="Password (min 8 chars)">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<input type="number" id="quotaGB" class="form-control" placeholder="Quota (GB)" min="1">
|
<button type="button" class="btn btn-success w-100" onclick="createUser()">Create</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
</div>
|
||||||
<button type="button" class="btn btn-warning w-100" onclick="setQuota()">Set Quota</button>
|
<div class="fw-semibold mb-2">Users</div>
|
||||||
</div>
|
<div class="table-responsive">
|
||||||
</form>
|
<table class="table table-striped mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Username</th><th>UID</th><th>SMB Account</th><th>Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="usersTableBody">
|
||||||
|
<tr><td colspan="4" style="color:var(--muted)">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /.tab-content -->
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s ?? '')
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
.replace(/&/g, '&').replace(/</g, '<')
|
}
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
|
function showToast(msg, type = 'success') {
|
||||||
|
const colors = { success: '#3fb950', danger: '#f85149', warning: '#d29922' };
|
||||||
|
const id = 'toast-' + Date.now();
|
||||||
|
document.getElementById('toastContainer').insertAdjacentHTML('beforeend', `
|
||||||
|
<div id="${id}" class="toast align-items-center border-0 mb-2" role="alert"
|
||||||
|
style="background:#161b22; border-left: 3px solid ${colors[type] ?? colors.success} !important; color:#e6edf3;">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">${esc(msg)}</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const t = new bootstrap.Toast(el, { delay: 3500 });
|
||||||
|
t.show();
|
||||||
|
el.addEventListener('hidden.bs.toast', () => el.remove());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiFetch(url, opts) {
|
async function apiFetch(url, opts) {
|
||||||
@@ -214,93 +335,102 @@ async function loadDashboard() {
|
|||||||
if (!res) return;
|
if (!res) return;
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
|
|
||||||
function setStatus(id, running) {
|
function setDaemon(id, running) {
|
||||||
const el = document.getElementById(id);
|
document.getElementById(id).innerHTML =
|
||||||
el.textContent = running ? 'Running' : 'Stopped';
|
`<span class="dot ${running ? 'dot-green' : 'dot-red'}"></span>
|
||||||
el.className = 'badge fs-6 ' + (running ? 'bg-success' : 'bg-danger');
|
<span class="small">${running ? 'Running' : 'Stopped'}</span>`;
|
||||||
}
|
}
|
||||||
setStatus('statusSmbd', d.status.smbd);
|
setDaemon('statusSmbd', d.status.smbd);
|
||||||
setStatus('statusNfsd', d.status.nfsd);
|
setDaemon('statusNfsd', d.status.nfsd);
|
||||||
|
|
||||||
if (d.disk && !d.disk.error) {
|
if (d.disk && !d.disk.error) {
|
||||||
const pct = d.disk.percent;
|
const p = d.disk.percent;
|
||||||
const bar = document.getElementById('diskBar');
|
const bar = document.getElementById('diskBar');
|
||||||
bar.style.width = pct + '%';
|
bar.style.width = p + '%';
|
||||||
bar.textContent = pct + '%';
|
bar.style.background = p > 90 ? 'var(--red)' : p > 75 ? 'var(--yellow)' : 'var(--accent)';
|
||||||
bar.className = 'progress-bar ' + (pct > 90 ? 'bg-danger' : pct > 75 ? 'bg-warning' : 'bg-info');
|
|
||||||
document.getElementById('diskLabel').textContent =
|
document.getElementById('diskLabel').textContent =
|
||||||
`${d.disk.used_gb} GB used / ${d.disk.total_gb} GB total (${d.disk.free_gb} GB free)`;
|
`${d.disk.used_gb} GB / ${d.disk.total_gb} GB — ${d.disk.free_gb} GB free`;
|
||||||
} else if (d.disk?.error) {
|
|
||||||
document.getElementById('diskLabel').textContent = 'Error: ' + d.disk.error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharesBody = document.getElementById('dashSharesBody');
|
const sb = document.getElementById('dashSharesBody');
|
||||||
if (!d.shares?.length) {
|
if (!d.shares?.length) {
|
||||||
sharesBody.innerHTML = '<tr><td colspan="5" class="text-muted">No shares configured.</td></tr>';
|
sb.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">No shares configured.</td></tr>`;
|
||||||
} else {
|
} else {
|
||||||
sharesBody.innerHTML = d.shares.map(s => `
|
sb.innerHTML = d.shares.map(s => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="badge bg-secondary">${esc(s.type.toUpperCase())}</span></td>
|
<td><span class="badge rounded-pill ${s.type==='smb'?'badge-smb':'badge-nfs'}">${s.type.toUpperCase()}</span></td>
|
||||||
<td>${esc(s.name ?? '—')}</td>
|
<td>${esc(s.name ?? '—')}</td>
|
||||||
<td><code>${esc(s.path)}</code></td>
|
<td><code>${esc(s.path)}</code></td>
|
||||||
<td class="text-muted small">${esc(s.access || '—')}</td>
|
<td style="color:var(--muted); font-size:.87em">${esc(s.access || '—')}</td>
|
||||||
<td class="text-end">${s.size_gb} GB</td>
|
<td class="text-end" style="color:var(--muted)">${s.size_gb} GB</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotasBody = document.getElementById('dashQuotasBody');
|
const qb = document.getElementById('dashQuotasBody');
|
||||||
if (!d.quotas?.length) {
|
if (!d.quotas?.length) {
|
||||||
quotasBody.innerHTML = '<tr><td colspan="4" class="text-muted">No quotas set.</td></tr>';
|
qb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No quotas set.</td></tr>`;
|
||||||
} else {
|
} else {
|
||||||
quotasBody.innerHTML = d.quotas.map(q => {
|
qb.innerHTML = d.quotas.map(q => {
|
||||||
const pct = Math.min(q.percent, 100);
|
const p = Math.min(q.percent, 100);
|
||||||
const cls = pct > 90 ? 'bg-danger' : pct > 75 ? 'bg-warning' : 'bg-success';
|
const col = p > 90 ? 'var(--red)' : p > 75 ? 'var(--yellow)' : 'var(--green)';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td>${esc(q.user)}</td>
|
<td>${esc(q.user)}</td>
|
||||||
<td>${q.used_gb} GB</td>
|
<td>${q.used_gb} GB</td>
|
||||||
<td>${q.soft_gb} GB</td>
|
<td>${q.soft_gb} GB</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="progress" style="height:14px">
|
<div class="progress" style="height:8px">
|
||||||
<div class="progress-bar ${cls}" style="width:${pct}%">${pct.toFixed(1)}%</div>
|
<div class="progress-bar" style="width:${p}%; background:${col}; transition:width .6s ease"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-1" style="font-size:.75rem; color:var(--muted)">${p.toFixed(1)}%</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('lastRefresh').textContent =
|
document.getElementById('lastRefresh').textContent = new Date().toLocaleTimeString();
|
||||||
'Last refresh: ' + new Date().toLocaleTimeString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== SMB / NFS TABS =====
|
// ===== SMB / NFS =====
|
||||||
|
|
||||||
async function loadShares() {
|
async function loadShares() {
|
||||||
const res = await apiFetch('/api/shares');
|
const res = await apiFetch('/api/shares');
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
const shares = await res.json();
|
const shares = await res.json();
|
||||||
|
|
||||||
const smbBody = document.getElementById('smbTableBody');
|
const sb = document.getElementById('smbTableBody');
|
||||||
if (!shares.smb?.length) {
|
if (!shares.smb?.length) {
|
||||||
smbBody.innerHTML = '<tr><td colspan="4" class="text-muted">No SMB shares.</td></tr>';
|
sb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No SMB shares.</td></tr>`;
|
||||||
} else {
|
} else {
|
||||||
smbBody.innerHTML = shares.smb.map(s => `
|
sb.innerHTML = shares.smb.map(s => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${esc(s.name)}</td>
|
<td>${esc(s.name)}</td>
|
||||||
<td><code>${esc(s.path)}</code></td>
|
<td><code>${esc(s.path)}</code></td>
|
||||||
<td>${esc(s.valid_users)}</td>
|
<td style="color:var(--muted); font-size:.87em">${esc(s.valid_users || '—')}</td>
|
||||||
<td><button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button></td>
|
<td>
|
||||||
|
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
||||||
|
onclick="openQuotaModal('smb',${JSON.stringify(s.name)},${JSON.stringify(s.path)},${JSON.stringify(s.valid_users)})">
|
||||||
|
Quota
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button>
|
||||||
|
</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
const nfsBody = document.getElementById('nfsTableBody');
|
const nb = document.getElementById('nfsTableBody');
|
||||||
if (!shares.nfs?.length) {
|
if (!shares.nfs?.length) {
|
||||||
nfsBody.innerHTML = '<tr><td colspan="3" class="text-muted">No NFS exports.</td></tr>';
|
nb.innerHTML = `<tr><td colspan="3" style="color:var(--muted)">No NFS exports.</td></tr>`;
|
||||||
} else {
|
} else {
|
||||||
nfsBody.innerHTML = shares.nfs.map(s => `
|
nb.innerHTML = shares.nfs.map(s => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>${esc(s.path)}</code></td>
|
<td><code>${esc(s.path)}</code></td>
|
||||||
<td>${esc(s.clients)}</td>
|
<td style="color:var(--muted); font-size:.87em">${esc(s.clients)}</td>
|
||||||
<td><button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button></td>
|
<td>
|
||||||
|
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
||||||
|
onclick="openQuotaModal('nfs',null,${JSON.stringify(s.path)},null)">
|
||||||
|
Quota
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button>
|
||||||
|
</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,65 +439,201 @@ async function addSMBShare() {
|
|||||||
const name = document.getElementById('smbName').value.trim();
|
const name = document.getElementById('smbName').value.trim();
|
||||||
const path = document.getElementById('smbPath').value.trim();
|
const path = document.getElementById('smbPath').value.trim();
|
||||||
const validUsers = document.getElementById('smbUsers').value.trim();
|
const validUsers = document.getElementById('smbUsers').value.trim();
|
||||||
if (!name || !path) return alert('Name and path are required.');
|
if (!name || !path) return showToast('Name and path are required.', 'danger');
|
||||||
|
|
||||||
const res = await apiFetch('/api/share/smb', {
|
const res = await apiFetch('/api/share/smb', {
|
||||||
method: 'POST',
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({name, path, validUsers}),
|
body: JSON.stringify({name, path, validUsers}),
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) return alert('Error: ' + (data.error || res.status));
|
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
|
||||||
alert('SMB share created.');
|
showToast('SMB share created.');
|
||||||
loadShares();
|
document.getElementById('smbName').value = '';
|
||||||
loadDashboard();
|
document.getElementById('smbPath').value = '';
|
||||||
|
document.getElementById('smbUsers').value = '';
|
||||||
|
loadShares(); loadDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addNFSShare() {
|
async function addNFSShare() {
|
||||||
const path = document.getElementById('nfsPath').value.trim();
|
const path = document.getElementById('nfsPath').value.trim();
|
||||||
const clients = document.getElementById('nfsClients').value.trim() || '*';
|
const clients = document.getElementById('nfsClients').value.trim() || '*';
|
||||||
const readOnly = document.getElementById('nfsReadOnly').checked;
|
const readOnly = document.getElementById('nfsReadOnly').checked;
|
||||||
if (!path) return alert('Path is required.');
|
if (!path) return showToast('Path is required.', 'danger');
|
||||||
|
|
||||||
const res = await apiFetch('/api/share/nfs', {
|
const res = await apiFetch('/api/share/nfs', {
|
||||||
method: 'POST',
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({path, clients, readOnly}),
|
body: JSON.stringify({path, clients, readOnly}),
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) return alert('Error: ' + (data.error || res.status));
|
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
|
||||||
alert('NFS export created.');
|
showToast('NFS export created.');
|
||||||
loadShares();
|
document.getElementById('nfsPath').value = '';
|
||||||
|
document.getElementById('nfsClients').value = '';
|
||||||
|
document.getElementById('nfsReadOnly').checked = false;
|
||||||
|
loadShares(); loadDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== QUOTA MODAL =====
|
||||||
|
|
||||||
|
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 => `
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" style="color:var(--muted); font-size:.85rem">${esc(u)}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="number" class="form-control quota-inp" data-user="${esc(u)}" placeholder="GB" min="1">
|
||||||
|
<span class="input-group-text">GB</span>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
} else {
|
||||||
|
const note = type === 'nfs'
|
||||||
|
? `<p class="mb-3" style="color:var(--muted); font-size:.87rem">Path: <code>${esc(path)}</code></p>`
|
||||||
|
: `<p class="mb-3" style="color:var(--muted); font-size:.87rem">No valid users on this share — enter a username manually.</p>`;
|
||||||
|
body = note + `
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" style="color:var(--muted); font-size:.85rem">Username</label>
|
||||||
|
<input type="text" id="qManualUser" class="form-control" placeholder="username">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" style="color:var(--muted); font-size:.85rem">Quota</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="number" id="qManualGB" class="form-control" placeholder="GB" min="1">
|
||||||
|
<span class="input-group-text">GB</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ type: 'smb', user: inp.dataset.user, quota: gb });
|
||||||
|
}
|
||||||
|
} 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({ type: _qCtx.type, user, path: _qCtx.path, quota: gb });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!calls.length) return showToast('No quota values entered.', 'warning');
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('quotaModal')).hide();
|
||||||
|
|
||||||
|
const results = await Promise.all(calls.map(body =>
|
||||||
|
apiFetch('/api/quota', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) })
|
||||||
|
));
|
||||||
|
const failed = results.filter(r => r && !r.ok).length;
|
||||||
|
if (failed) showToast(`${failed} quota(s) failed to apply.`, 'danger');
|
||||||
|
else showToast(`Quota${calls.length > 1 ? 's' : ''} applied.`);
|
||||||
|
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setQuota() {
|
// ===== USERS =====
|
||||||
const user = document.getElementById('quotaUser').value.trim();
|
|
||||||
const quota = document.getElementById('quotaGB').value;
|
|
||||||
if (!user || !quota) return alert('Username and quota are required.');
|
|
||||||
|
|
||||||
const res = await apiFetch('/api/quota', {
|
async function loadUsers() {
|
||||||
method: 'POST',
|
const res = await apiFetch('/api/users');
|
||||||
headers: {'Content-Type': 'application/json'},
|
if (!res) return;
|
||||||
body: JSON.stringify({
|
const users = await res.json();
|
||||||
type: document.getElementById('quotaType').value,
|
const tb = document.getElementById('usersTableBody');
|
||||||
user,
|
if (!users.length) {
|
||||||
path: document.getElementById('quotaPath').value.trim(),
|
tb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No users found.</td></tr>`;
|
||||||
quota: Number(quota),
|
return;
|
||||||
}),
|
}
|
||||||
|
tb.innerHTML = users.map(u => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(u.name)}</td>
|
||||||
|
<td style="color:var(--muted)">${u.uid}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge rounded-pill" style="${u.smb ? 'background:#1a3a1f;color:var(--green)' : 'background:var(--surf2);color:var(--muted)'}">
|
||||||
|
${u.smb ? 'Active' : 'No'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm me-1" style="border:1px solid var(--border);color:var(--muted)"
|
||||||
|
onclick="openPwModal(${JSON.stringify(u.name)})">Password</button>
|
||||||
|
<button class="btn btn-sm btn-danger"
|
||||||
|
onclick="deleteUser(${JSON.stringify(u.name)})">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser() {
|
||||||
|
const name = document.getElementById('newUsername').value.trim();
|
||||||
|
const password = document.getElementById('newPassword').value;
|
||||||
|
if (!name || !password) return showToast('Username and password are required.', 'danger');
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/users', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name, password}),
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) return alert('Error: ' + (data.error || res.status));
|
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
|
||||||
alert('Quota set.');
|
showToast(`User "${name}" created.`);
|
||||||
loadDashboard();
|
document.getElementById('newUsername').value = '';
|
||||||
|
document.getElementById('newPassword').value = '';
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(name) {
|
||||||
|
if (!confirm(`Delete user "${name}"? This cannot be undone.`)) return;
|
||||||
|
const res = await apiFetch(`/api/users/${encodeURIComponent(name)}`, {method: 'DELETE'});
|
||||||
|
if (!res) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
|
||||||
|
showToast(`User "${name}" deleted.`);
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPwModal(name) {
|
||||||
|
document.getElementById('pwUsername').value = name;
|
||||||
|
document.getElementById('pwNew').value = '';
|
||||||
|
document.getElementById('pwConfirm').value = '';
|
||||||
|
new bootstrap.Modal(document.getElementById('pwModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPassword() {
|
||||||
|
const name = document.getElementById('pwUsername').value;
|
||||||
|
const pw = document.getElementById('pwNew').value;
|
||||||
|
const confirm = document.getElementById('pwConfirm').value;
|
||||||
|
if (pw.length < 8) return showToast('Password must be at least 8 characters.', 'danger');
|
||||||
|
if (pw !== confirm) return showToast('Passwords do not match.', 'danger');
|
||||||
|
|
||||||
|
const res = await apiFetch(`/api/users/${encodeURIComponent(name)}/password`, {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({password: pw}),
|
||||||
|
});
|
||||||
|
if (!res) return;
|
||||||
|
const data = await res.json();
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('pwModal')).hide();
|
||||||
|
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
|
||||||
|
showToast(`Password updated for "${name}".`);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
loadShares();
|
loadShares();
|
||||||
setInterval(loadDashboard, 60_000);
|
loadUsers();
|
||||||
|
setInterval(loadDashboard, 30_000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in new issue
Block a user