Add Phase 2 dashboard + fix Phase 1 issues
- Implement parse_smb(), remove shell=True, add input validation, HTTP Basic Auth (Phase 1) - Add /api/dashboard with daemon status, disk usage, per-share du, quota usage (Phase 2) - Rewrite index.html with Dashboard as landing tab, progress bars, XSS-safe rendering - Update README with full LXC install guide including git clone and update steps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
10a1e24420
commit
458092a3c6
3 files changed
+485
-188
No files matched your search
@@ -2,45 +2,89 @@
|
|||||||
|
|
||||||
Lightweight web GUI to manage SMB shares, NFS exports, and quotas on a single home server (Proxmox LXC/VM). Runs directly on the host as a systemd service.
|
Lightweight web GUI to manage SMB shares, NFS exports, and quotas on a single home server (Proxmox LXC/VM). Runs directly on the host as a systemd service.
|
||||||
|
|
||||||
## Requirements
|
## Proxmox LXC setup
|
||||||
|
|
||||||
```
|
Use a **privileged** Debian/Ubuntu container — required for the NFS server kernel module and quotas.
|
||||||
apt install samba nfs-kernel-server quota python3 python3-venv
|
|
||||||
|
On the **Proxmox host**, load the NFS kernel module before starting the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
modprobe nfsd
|
||||||
|
echo nfsd >> /etc/modules
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
Run everything below **inside the LXC container** as root.
|
||||||
# Copy files
|
|
||||||
cp -r . /opt/micronas
|
|
||||||
|
|
||||||
# Create virtualenv and install deps
|
### 1. System dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt update
|
||||||
|
apt install -y git samba nfs-kernel-server quota python3 python3-venv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Clone the repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/micronas.git /opt/micronas
|
||||||
|
# or if you're self-hosting:
|
||||||
|
# git clone git@yourserver:micronas.git /opt/micronas
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Python environment
|
||||||
|
|
||||||
|
```bash
|
||||||
python3 -m venv /opt/micronas/venv
|
python3 -m venv /opt/micronas/venv
|
||||||
/opt/micronas/venv/bin/pip install flask gunicorn
|
/opt/micronas/venv/bin/pip install flask gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
# Create environment file with your credentials
|
### 4. Credentials
|
||||||
|
|
||||||
|
```bash
|
||||||
cat > /etc/micronas.env <<EOF
|
cat > /etc/micronas.env <<EOF
|
||||||
MICRONAS_USER=admin
|
MICRONAS_USER=admin
|
||||||
MICRONAS_PASS=changeme
|
MICRONAS_PASS=changeme
|
||||||
EOF
|
EOF
|
||||||
chmod 600 /etc/micronas.env
|
chmod 600 /etc/micronas.env
|
||||||
|
```
|
||||||
|
|
||||||
# Install and enable the systemd service
|
### 5. systemd service
|
||||||
|
|
||||||
|
```bash
|
||||||
cp /opt/micronas/micronas.service /etc/systemd/system/
|
cp /opt/micronas/micronas.service /etc/systemd/system/
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now micronas
|
systemctl enable --now micronas
|
||||||
```
|
```
|
||||||
|
|
||||||
The app listens on `127.0.0.1:5000`. Put a reverse proxy (nginx) in front if you want HTTPS, or reach it directly on the LAN by binding to `0.0.0.0:5000` (change `-b` in the unit file).
|
Open `http://<lxc-ip>:5000` in your browser.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/micronas
|
||||||
|
git pull
|
||||||
|
systemctl restart micronas
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network
|
||||||
|
|
||||||
|
The service binds to `127.0.0.1:5000` by default. To reach it directly from the LAN, change `-b 127.0.0.1:5000` to `-b 0.0.0.0:5000` in `micronas.service`, then run `systemctl daemon-reload && systemctl restart micronas`.
|
||||||
|
|
||||||
|
For HTTPS, put nginx in front as a reverse proxy.
|
||||||
|
|
||||||
|
LAN or Tailscale only — never expose to the internet.
|
||||||
|
|
||||||
## Dev / test
|
## Dev / test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MICRONAS_USER=admin MICRONAS_PASS=secret flask --app app run --debug
|
# Quick local run (no gunicorn needed):
|
||||||
# or with docker-compose for a sandbox:
|
MICRONAS_USER=admin MICRONAS_PASS=secret flask --app app.py run --debug --host=0.0.0.0
|
||||||
|
|
||||||
|
# Or with docker-compose (UI only — systemctl/samba calls won't work):
|
||||||
docker compose up
|
docker compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
Set `MICRONAS_USER` and `MICRONAS_PASS` in the environment (or `EnvironmentFile`). All routes require HTTP Basic Auth. Never expose this to the internet — LAN or Tailscale only.
|
All routes require HTTP Basic Auth. Credentials come from `MICRONAS_USER` / `MICRONAS_PASS` environment variables (set via `/etc/micronas.env` in production). If `MICRONAS_PASS` is not set the app starts unprotected and logs a warning — don't do this in production.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import functools
|
import functools
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -219,5 +220,106 @@ def set_quota():
|
|||||||
return jsonify({'status': 'ok'})
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
|
||||||
|
# ===== DASHBOARD =====
|
||||||
|
|
||||||
|
def _daemon_status():
|
||||||
|
def is_active(service):
|
||||||
|
r = subprocess.run(['systemctl', 'is-active', service], capture_output=True, text=True)
|
||||||
|
return r.stdout.strip() == 'active'
|
||||||
|
return {'smbd': is_active('smbd'), 'nfsd': is_active('nfs-server')}
|
||||||
|
|
||||||
|
|
||||||
|
def _disk_usage():
|
||||||
|
try:
|
||||||
|
u = shutil.disk_usage('/srv')
|
||||||
|
return {
|
||||||
|
'path': '/srv',
|
||||||
|
'total_gb': round(u.total / 1024**3, 1),
|
||||||
|
'used_gb': round(u.used / 1024**3, 1),
|
||||||
|
'free_gb': round(u.free / 1024**3, 1),
|
||||||
|
'percent': round(u.used / u.total * 100, 1) if u.total else 0,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _du_bytes(path):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(['du', '-sb', path], capture_output=True, text=True, timeout=15)
|
||||||
|
if r.returncode == 0:
|
||||||
|
parts = r.stdout.split()
|
||||||
|
return int(parts[0]) if parts else 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _shares_with_usage():
|
||||||
|
result = []
|
||||||
|
for s in parse_smb():
|
||||||
|
result.append({
|
||||||
|
'type': 'smb',
|
||||||
|
'name': s['name'],
|
||||||
|
'path': s['path'],
|
||||||
|
'access': s['valid_users'],
|
||||||
|
'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0,
|
||||||
|
})
|
||||||
|
for s in get_nfs_shares():
|
||||||
|
result.append({
|
||||||
|
'type': 'nfs',
|
||||||
|
'name': None,
|
||||||
|
'path': s['path'],
|
||||||
|
'access': s['clients'],
|
||||||
|
'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _quota_usage():
|
||||||
|
try:
|
||||||
|
r = subprocess.run(['repquota', '-u', '/srv'], capture_output=True, text=True, timeout=10)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
quotas = []
|
||||||
|
past_sep = False
|
||||||
|
for line in r.stdout.splitlines():
|
||||||
|
if line.startswith('---'):
|
||||||
|
past_sep = True
|
||||||
|
continue
|
||||||
|
if not past_sep:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 5 or not re.match(r'^[+-]{2}$', parts[1]):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
used_kb = int(parts[2])
|
||||||
|
soft_kb = int(parts[3])
|
||||||
|
hard_kb = int(parts[4])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
if soft_kb == 0 and hard_kb == 0 and used_kb == 0:
|
||||||
|
continue
|
||||||
|
quotas.append({
|
||||||
|
'user': parts[0],
|
||||||
|
'used_gb': round(used_kb / 1024**2, 2),
|
||||||
|
'soft_gb': round(soft_kb / 1024**2, 2),
|
||||||
|
'hard_gb': round(hard_kb / 1024**2, 2),
|
||||||
|
'percent': round(used_kb / soft_kb * 100, 1) if soft_kb else 0,
|
||||||
|
})
|
||||||
|
return quotas
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/dashboard', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_dashboard():
|
||||||
|
return jsonify({
|
||||||
|
'status': _daemon_status(),
|
||||||
|
'disk': _disk_usage(),
|
||||||
|
'shares': _shares_with_usage(),
|
||||||
|
'quotas': _quota_usage(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||||
+326
-175
@@ -1,223 +1,374 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>SMB + NFS Manager</title>
|
<meta charset="UTF-8">
|
||||||
|
<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">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-dark text-white p-4">
|
<body class="bg-dark text-white p-4">
|
||||||
|
|
||||||
<div class="container">
|
<div class="container-lg">
|
||||||
<h1 class="mb-4">📁 Share Manager (SMB + NFS)</h1>
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">MicroNAS</h1>
|
||||||
<!-- Tabs -->
|
<span id="lastRefresh" class="text-muted small"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="nav nav-tabs" role="tablist">
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" data-bs-toggle="tab" href="#smb">SMB Shares</a>
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#dashboard" type="button">Dashboard</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#nfs">NFS Exports</a>
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#smb" type="button">SMB Shares</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#quotas">Quotas</a>
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#nfs" type="button">NFS Exports</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#quotas" type="button">Quotas</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- SMB TAB -->
|
<div class="tab-content">
|
||||||
<div id="smb" class="tab-pane fade show active mt-4">
|
|
||||||
<h3>SMB Shares</h3>
|
|
||||||
|
|
||||||
<form class="row g-3 mb-4">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<input type="text" id="smbName" class="form-control" placeholder="Share Name" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<input type="text" id="smbPath" class="form-control" placeholder="/srv/samba/..." required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<input type="text" id="smbUsers" class="form-control" placeholder="user1, user2">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<button type="button" class="btn btn-success w-100" onclick="addSMBShare()">
|
|
||||||
Add SMB
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div id="smbList" class="table-responsive">
|
<!-- DASHBOARD TAB -->
|
||||||
<table class="table table-dark table-striped">
|
<div class="tab-pane fade show active mt-4" id="dashboard">
|
||||||
<thead>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<tr>
|
<h3 class="mb-0">Dashboard</h3>
|
||||||
<th>Name</th>
|
<button class="btn btn-sm btn-outline-light" onclick="loadDashboard()">↻ Refresh</button>
|
||||||
<th>Path</th>
|
</div>
|
||||||
<th>Valid Users</th>
|
|
||||||
<th>Actions</th>
|
<!-- Daemon status + disk -->
|
||||||
</tr>
|
<div class="row g-3 mb-4">
|
||||||
</thead>
|
<div class="col-md-2">
|
||||||
<tbody id="smbTableBody"></tbody>
|
<div class="card border-secondary bg-secondary text-center">
|
||||||
</table>
|
<div class="card-body py-3">
|
||||||
|
<div class="small text-muted mb-1">smbd</div>
|
||||||
|
<span id="statusSmbd" class="badge fs-6">…</span>
|
||||||
|
</div>
|
||||||
|
</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 class="progress" style="height: 18px;">
|
||||||
|
<div id="diskBar" class="progress-bar" role="progressbar" style="width:0%">0%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shares overview -->
|
||||||
|
<h5>Shares</h5>
|
||||||
|
<div class="table-responsive mb-4">
|
||||||
|
<table class="table table-dark table-striped table-sm align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>Access</th>
|
||||||
|
<th class="text-end">Size on disk</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="dashSharesBody">
|
||||||
|
<tr><td colspan="5" class="text-muted">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quota usage -->
|
||||||
|
<h5>Quota Usage</h5>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped table-sm align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Used</th>
|
||||||
|
<th>Soft limit</th>
|
||||||
|
<th style="min-width:180px">Usage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="dashQuotasBody">
|
||||||
|
<tr><td colspan="4" class="text-muted">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- NFS TAB -->
|
<!-- SMB TAB -->
|
||||||
<div id="nfs" class="tab-pane fade mt-4">
|
<div class="tab-pane fade mt-4" id="smb">
|
||||||
<h3>NFS Exports</h3>
|
<h3>SMB Shares</h3>
|
||||||
|
<form class="row g-3 mb-4">
|
||||||
<form class="row g-3 mb-4">
|
<div class="col-md-3">
|
||||||
<div class="col-md-4">
|
<input type="text" id="smbName" class="form-control" placeholder="Share name" required>
|
||||||
<input type="text" id="nfsPath" class="form-control" placeholder="/srv/data" required>
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="text" id="smbPath" class="form-control" placeholder="/srv/samba/…" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" id="smbUsers" class="form-control" placeholder="user1,user2">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="button" class="btn btn-success w-100" onclick="addSMBShare()">Add SMB</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Path</th><th>Valid Users</th><th>Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="smbTableBody"></tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
|
||||||
<input type="text" id="nfsClients" class="form-control" placeholder="192.168.1.0/24 or *">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-check">
|
|
||||||
<input type="checkbox" id="nfsReadOnly" class="form-check-input">
|
|
||||||
Read Only
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<button type="button" class="btn btn-success w-100" onclick="addNFSShare()">
|
|
||||||
Add NFS
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div id="nfsList" class="table-responsive">
|
|
||||||
<table class="table table-dark table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Path</th>
|
|
||||||
<th>Clients</th>
|
|
||||||
<th>Options</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="nfsTableBody"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- QUOTAS TAB -->
|
<!-- NFS TAB -->
|
||||||
<div id="quotas" class="tab-pane fade mt-4">
|
<div class="tab-pane fade mt-4" id="nfs">
|
||||||
<h3>Set Quotas</h3>
|
<h3>NFS Exports</h3>
|
||||||
|
<form class="row g-3 mb-4">
|
||||||
<form class="row g-3 mb-4">
|
<div class="col-md-4">
|
||||||
<div class="col-md-2">
|
<input type="text" id="nfsPath" class="form-control" placeholder="/srv/data" required>
|
||||||
<select id="quotaType" class="form-select">
|
</div>
|
||||||
<option value="smb">SMB</option>
|
<div class="col-md-3">
|
||||||
<option value="nfs">NFS</option>
|
<input type="text" id="nfsClients" class="form-control" placeholder="192.168.1.0/24 or *">
|
||||||
</select>
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="nfsReadOnly" class="form-check-input">
|
||||||
|
<label class="form-check-label" for="nfsReadOnly">Read Only</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<button type="button" class="btn btn-success w-100" onclick="addNFSShare()">Add NFS</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Path</th><th>Clients</th><th>Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="nfsTableBody"></tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
</div>
|
||||||
<input type="text" id="quotaUser" class="form-control" placeholder="Username">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<input type="text" id="quotaPath" class="form-control" placeholder="/srv/data (NFS only)">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<input type="number" id="quotaGB" class="form-control" placeholder="Quota (GB)">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<button type="button" class="btn btn-warning w-100" onclick="setQuota()">
|
|
||||||
Set Quota
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- QUOTAS TAB -->
|
||||||
|
<div class="tab-pane fade mt-4" id="quotas">
|
||||||
|
<h3>Set Quota</h3>
|
||||||
|
<form class="row g-3 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">
|
||||||
|
<input type="text" id="quotaPath" class="form-control" placeholder="/srv/data (NFS only)">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="number" id="quotaGB" class="form-control" placeholder="Quota (GB)" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<button type="button" class="btn btn-warning w-100" onclick="setQuota()">Set Quota</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /.tab-content -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(url, opts) {
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
if (res.status === 401) { location.reload(); return null; }
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== DASHBOARD =====
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
const res = await apiFetch('/api/dashboard');
|
||||||
|
if (!res) return;
|
||||||
|
const d = await res.json();
|
||||||
|
|
||||||
|
function setStatus(id, running) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.textContent = running ? 'Running' : 'Stopped';
|
||||||
|
el.className = 'badge fs-6 ' + (running ? 'bg-success' : 'bg-danger');
|
||||||
|
}
|
||||||
|
setStatus('statusSmbd', d.status.smbd);
|
||||||
|
setStatus('statusNfsd', d.status.nfsd);
|
||||||
|
|
||||||
|
if (d.disk && !d.disk.error) {
|
||||||
|
const pct = d.disk.percent;
|
||||||
|
const bar = document.getElementById('diskBar');
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
bar.textContent = pct + '%';
|
||||||
|
bar.className = 'progress-bar ' + (pct > 90 ? 'bg-danger' : pct > 75 ? 'bg-warning' : 'bg-info');
|
||||||
|
document.getElementById('diskLabel').textContent =
|
||||||
|
`${d.disk.used_gb} GB used / ${d.disk.total_gb} GB total (${d.disk.free_gb} GB free)`;
|
||||||
|
} else if (d.disk?.error) {
|
||||||
|
document.getElementById('diskLabel').textContent = 'Error: ' + d.disk.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharesBody = document.getElementById('dashSharesBody');
|
||||||
|
if (!d.shares?.length) {
|
||||||
|
sharesBody.innerHTML = '<tr><td colspan="5" class="text-muted">No shares configured.</td></tr>';
|
||||||
|
} else {
|
||||||
|
sharesBody.innerHTML = d.shares.map(s => `
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge bg-secondary">${esc(s.type.toUpperCase())}</span></td>
|
||||||
|
<td>${esc(s.name ?? '—')}</td>
|
||||||
|
<td><code>${esc(s.path)}</code></td>
|
||||||
|
<td class="text-muted small">${esc(s.access || '—')}</td>
|
||||||
|
<td class="text-end">${s.size_gb} GB</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotasBody = document.getElementById('dashQuotasBody');
|
||||||
|
if (!d.quotas?.length) {
|
||||||
|
quotasBody.innerHTML = '<tr><td colspan="4" class="text-muted">No quotas set.</td></tr>';
|
||||||
|
} else {
|
||||||
|
quotasBody.innerHTML = d.quotas.map(q => {
|
||||||
|
const pct = Math.min(q.percent, 100);
|
||||||
|
const cls = pct > 90 ? 'bg-danger' : pct > 75 ? 'bg-warning' : 'bg-success';
|
||||||
|
return `<tr>
|
||||||
|
<td>${esc(q.user)}</td>
|
||||||
|
<td>${q.used_gb} GB</td>
|
||||||
|
<td>${q.soft_gb} GB</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress" style="height:14px">
|
||||||
|
<div class="progress-bar ${cls}" style="width:${pct}%">${pct.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('lastRefresh').textContent =
|
||||||
|
'Last refresh: ' + new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SMB / NFS TABS =====
|
||||||
|
|
||||||
|
async function loadShares() {
|
||||||
|
const res = await apiFetch('/api/shares');
|
||||||
|
if (!res) return;
|
||||||
|
const shares = await res.json();
|
||||||
|
|
||||||
|
const smbBody = document.getElementById('smbTableBody');
|
||||||
|
if (!shares.smb?.length) {
|
||||||
|
smbBody.innerHTML = '<tr><td colspan="4" class="text-muted">No SMB shares.</td></tr>';
|
||||||
|
} else {
|
||||||
|
smbBody.innerHTML = shares.smb.map(s => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(s.name)}</td>
|
||||||
|
<td><code>${esc(s.path)}</code></td>
|
||||||
|
<td>${esc(s.valid_users)}</td>
|
||||||
|
<td><button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nfsBody = document.getElementById('nfsTableBody');
|
||||||
|
if (!shares.nfs?.length) {
|
||||||
|
nfsBody.innerHTML = '<tr><td colspan="3" class="text-muted">No NFS exports.</td></tr>';
|
||||||
|
} else {
|
||||||
|
nfsBody.innerHTML = shares.nfs.map(s => `
|
||||||
|
<tr>
|
||||||
|
<td><code>${esc(s.path)}</code></td>
|
||||||
|
<td>${esc(s.clients)}</td>
|
||||||
|
<td><button class="btn btn-sm btn-danger" disabled title="Coming in Phase 3">Delete</button></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addSMBShare() {
|
async function addSMBShare() {
|
||||||
const data = {
|
const name = document.getElementById('smbName').value.trim();
|
||||||
name: document.getElementById('smbName').value,
|
const path = document.getElementById('smbPath').value.trim();
|
||||||
path: document.getElementById('smbPath').value,
|
const validUsers = document.getElementById('smbUsers').value.trim();
|
||||||
validUsers: document.getElementById('smbUsers').value
|
if (!name || !path) return alert('Name and path are required.');
|
||||||
};
|
|
||||||
|
const res = await apiFetch('/api/share/smb', {
|
||||||
const res = await fetch('/api/share/smb', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify({name, path, validUsers}),
|
||||||
});
|
});
|
||||||
|
if (!res) return;
|
||||||
if (res.ok) {
|
const data = await res.json();
|
||||||
alert('SMB Share created!');
|
if (!res.ok) return alert('Error: ' + (data.error || res.status));
|
||||||
location.reload();
|
alert('SMB share created.');
|
||||||
}
|
loadShares();
|
||||||
|
loadDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addNFSShare() {
|
async function addNFSShare() {
|
||||||
const data = {
|
const path = document.getElementById('nfsPath').value.trim();
|
||||||
path: document.getElementById('nfsPath').value,
|
const clients = document.getElementById('nfsClients').value.trim() || '*';
|
||||||
clients: document.getElementById('nfsClients').value,
|
const readOnly = document.getElementById('nfsReadOnly').checked;
|
||||||
readOnly: document.getElementById('nfsReadOnly').checked
|
if (!path) return alert('Path is required.');
|
||||||
};
|
|
||||||
|
const res = await apiFetch('/api/share/nfs', {
|
||||||
const res = await fetch('/api/share/nfs', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify({path, clients, readOnly}),
|
||||||
});
|
});
|
||||||
|
if (!res) return;
|
||||||
if (res.ok) {
|
const data = await res.json();
|
||||||
alert('NFS Export created!');
|
if (!res.ok) return alert('Error: ' + (data.error || res.status));
|
||||||
location.reload();
|
alert('NFS export created.');
|
||||||
}
|
loadShares();
|
||||||
|
loadDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setQuota() {
|
async function setQuota() {
|
||||||
const data = {
|
const user = document.getElementById('quotaUser').value.trim();
|
||||||
type: document.getElementById('quotaType').value,
|
const quota = document.getElementById('quotaGB').value;
|
||||||
user: document.getElementById('quotaUser').value,
|
if (!user || !quota) return alert('Username and quota are required.');
|
||||||
path: document.getElementById('quotaPath').value,
|
|
||||||
quota: document.getElementById('quotaGB').value
|
const res = await apiFetch('/api/quota', {
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch('/api/quota', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify({
|
||||||
});
|
type: document.getElementById('quotaType').value,
|
||||||
|
user,
|
||||||
alert(res.ok ? 'Quota set!' : 'Error');
|
path: document.getElementById('quotaPath').value.trim(),
|
||||||
}
|
quota: Number(quota),
|
||||||
|
}),
|
||||||
// Load shares on startup
|
|
||||||
async function loadShares() {
|
|
||||||
const res = await fetch('/api/shares');
|
|
||||||
const shares = await res.json();
|
|
||||||
|
|
||||||
// Populate SMB table
|
|
||||||
const smbBody = document.getElementById('smbTableBody');
|
|
||||||
shares.smb.forEach(share => {
|
|
||||||
smbBody.innerHTML += `
|
|
||||||
<tr>
|
|
||||||
<td>${share.name}</td>
|
|
||||||
<td>${share.path}</td>
|
|
||||||
<td>${share.valid_users}</td>
|
|
||||||
<td><button class="btn btn-sm btn-danger">Delete</button></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Populate NFS table
|
|
||||||
const nfsBody = document.getElementById('nfsTableBody');
|
|
||||||
shares.nfs.forEach(share => {
|
|
||||||
nfsBody.innerHTML += `
|
|
||||||
<tr>
|
|
||||||
<td>${share.path}</td>
|
|
||||||
<td>${share.clients}</td>
|
|
||||||
<td>-</td>
|
|
||||||
<td><button class="btn btn-sm btn-danger">Delete</button></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
});
|
});
|
||||||
|
if (!res) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) return alert('Error: ' + (data.error || res.status));
|
||||||
|
alert('Quota set.');
|
||||||
|
loadDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadDashboard();
|
||||||
loadShares();
|
loadShares();
|
||||||
|
setInterval(loadDashboard, 60_000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in new issue
Block a user