CORVO-AI commited on
Commit
1d6a95a
·
verified ·
1 Parent(s): c0f1791

Upload 8 files

Browse files
Files changed (8) hide show
  1. .gitattributes +35 -35
  2. Dockerfile +58 -0
  3. README.md +40 -10
  4. app.py +249 -0
  5. requirements.txt +7 -0
  6. static/css/style.css +1493 -0
  7. static/js/dashboard.js +705 -0
  8. templates/index.html +486 -0
.gitattributes CHANGED
@@ -1,35 +1,35 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1.6
2
+ FROM python:3.11-slim
3
+
4
+ # ---------------------------------------------------------------
5
+ # System setup
6
+ # ---------------------------------------------------------------
7
+ ENV PYTHONDONTWRITEBYTECODE=1 \
8
+ PYTHONUNBUFFERED=1 \
9
+ PIP_NO_CACHE_DIR=1 \
10
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
11
+ PORT=7860 \
12
+ POLL_INTERVAL=8 \
13
+ UPSTREAM_API=https://dooratre-db.hf.space \
14
+ MASTER_PASSWORD=Dbpassword2000$ \
15
+ UPSTREAM_ADMIN_SECRET=Dbpassword2000$ \
16
+ UPSTREAM_API_KEY=Dbpassword2000$
17
+
18
+ # Minimal build deps (eventlet/greenlet sometimes need them on slim)
19
+ RUN apt-get update && apt-get install -y --no-install-recommends \
20
+ curl \
21
+ build-essential \
22
+ && rm -rf /var/lib/apt/lists/*
23
+
24
+ # ---------------------------------------------------------------
25
+ # Non-root user (HF Spaces friendly)
26
+ # ---------------------------------------------------------------
27
+ RUN useradd -m -u 1000 appuser
28
+ WORKDIR /app
29
+
30
+ # ---------------------------------------------------------------
31
+ # Python deps (cache layer)
32
+ # ---------------------------------------------------------------
33
+ COPY requirements.txt .
34
+ RUN pip install --upgrade pip && pip install -r requirements.txt
35
+
36
+ # ---------------------------------------------------------------
37
+ # App source
38
+ # ---------------------------------------------------------------
39
+ COPY --chown=appuser:appuser . .
40
+
41
+ USER appuser
42
+
43
+ EXPOSE 7860
44
+
45
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
46
+ CMD curl -fsS http://localhost:7860/health || exit 1
47
+
48
+ # ---------------------------------------------------------------
49
+ # Run with gunicorn + eventlet worker for Socket.IO
50
+ # ---------------------------------------------------------------
51
+ CMD ["gunicorn", \
52
+ "-k", "eventlet", \
53
+ "-w", "1", \
54
+ "--bind", "0.0.0.0:7860", \
55
+ "--access-logfile", "-", \
56
+ "--error-logfile", "-", \
57
+ "--timeout", "120", \
58
+ "app:app"]
README.md CHANGED
@@ -1,10 +1,40 @@
1
- ---
2
- title: Admin
3
- emoji: 👁
4
- colorFrom: red
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Admin
3
+ emoji: 🐢
4
+ colorFrom: blue
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # ServerClass Admin Console v2
11
+
12
+ Pro real-time dashboard for the ServerClass DB mesh.
13
+
14
+ ## Features
15
+ - 🔒 Single master-password unlock (server-side credential vault)
16
+ - 💾 30-day signed-cookie session — no re-typing
17
+ - 📡 Live Socket.IO updates every 8 seconds
18
+ - 📱 Mobile-first PWA-style UI with bottom nav + safe-area insets
19
+ - 📊 KPI cards, load chart, sortable/searchable tables, CSV export
20
+ - 🌙 Dark glassmorphism theme
21
+
22
+ ## Environment variables
23
+ | Variable | Default | Purpose |
24
+ |----------|---------|---------|
25
+ | `MASTER_PASSWORD` | `Dbpassword2000$` | What the user types to unlock |
26
+ | `UPSTREAM_ADMIN_SECRET` | `Dbpassword2000$` | `X-Admin-Secret` sent to upstream |
27
+ | `UPSTREAM_API_KEY` | `Dbpassword2000$` | `X-API-Key` sent to upstream |
28
+ | `UPSTREAM_API` | `https://dooratre-db.hf.space` | Backing DB instance |
29
+ | `FLASK_SECRET_KEY` | (auto) | Cookie signing key — **set in prod** |
30
+ | `POLL_INTERVAL` | `8` | Seconds between auto-refreshes |
31
+
32
+ ## Keyboard
33
+ - `Ctrl/Cmd + L` — lock dashboard
34
+ - `/` — focus search on the current page
35
+
36
+ ## Local
37
+ ```
38
+ pip install -r requirements.txt
39
+ python app.py
40
+ ```
app.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ServerClass DB Admin Dashboard
3
+ Flask + Socket.IO backend with background polling and real-time broadcasts.
4
+
5
+ Auth model:
6
+ - One MASTER PASSWORD unlocks the dashboard.
7
+ - On unlock, server uses pre-configured ADMIN_SECRET / API_KEY against upstream.
8
+ - Session is stored in a signed cookie -> auto-login on revisit.
9
+ - Credentials are NEVER sent to the browser.
10
+ """
11
+ import os
12
+ import time
13
+ import threading
14
+ from typing import Dict, Any, Optional
15
+
16
+ import requests
17
+ from flask import (
18
+ Flask, render_template, request, jsonify,
19
+ session, redirect, url_for, make_response
20
+ )
21
+ from flask_socketio import SocketIO, emit, disconnect
22
+
23
+ # -----------------------------------------------------------------------------
24
+ # Configuration
25
+ # -----------------------------------------------------------------------------
26
+ UPSTREAM_API = os.environ.get("UPSTREAM_API", "https://dooratre-db.hf.space")
27
+ POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "8")) # seconds
28
+ SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "ServerClass-Admin-Key-Change-Me-In-Prod-9182734")
29
+
30
+ # The master password the user types to unlock the dashboard.
31
+ MASTER_PASSWORD = os.environ.get("MASTER_PASSWORD", "Dbpassword2000$")
32
+
33
+ # Upstream credentials (same value used for both X-Admin-Secret and X-API-Key).
34
+ UPSTREAM_ADMIN_SECRET = os.environ.get("UPSTREAM_ADMIN_SECRET", "Dbpassword2000$")
35
+ UPSTREAM_API_KEY = os.environ.get("UPSTREAM_API_KEY", "Dbpassword2000$")
36
+
37
+ app = Flask(__name__)
38
+ app.config.update(
39
+ SECRET_KEY=SECRET_KEY,
40
+ SESSION_COOKIE_NAME="sc_admin",
41
+ SESSION_COOKIE_HTTPONLY=True,
42
+ SESSION_COOKIE_SAMESITE="Lax",
43
+ PERMANENT_SESSION_LIFETIME=60 * 60 * 24 * 30, # 30 days
44
+ )
45
+ socketio = SocketIO(app, cors_allowed_origins="*", async_mode="eventlet")
46
+
47
+ # -----------------------------------------------------------------------------
48
+ # Per-connection auth state (sid -> bool)
49
+ # -----------------------------------------------------------------------------
50
+ _authed_sids: Dict[str, bool] = {}
51
+ _authed_lock = threading.Lock()
52
+
53
+
54
+ def mark_authed(sid: str) -> None:
55
+ with _authed_lock:
56
+ _authed_sids[sid] = True
57
+
58
+
59
+ def is_authed(sid: str) -> bool:
60
+ with _authed_lock:
61
+ return _authed_sids.get(sid, False)
62
+
63
+
64
+ def drop_sid(sid: str) -> None:
65
+ with _authed_lock:
66
+ _authed_sids.pop(sid, None)
67
+
68
+
69
+ # -----------------------------------------------------------------------------
70
+ # Upstream API client (uses server-side credentials only)
71
+ # -----------------------------------------------------------------------------
72
+ def fetch_upstream() -> Dict[str, Any]:
73
+ result: Dict[str, Any] = {
74
+ "ok": True,
75
+ "users": [],
76
+ "total": 0,
77
+ "status": None,
78
+ "errors": {},
79
+ "timestamp": int(time.time()),
80
+ }
81
+
82
+ # Users (admin endpoint)
83
+ try:
84
+ r = requests.get(
85
+ f"{UPSTREAM_API}/admin/users",
86
+ headers={
87
+ "X-Admin-Secret": UPSTREAM_ADMIN_SECRET,
88
+ "Content-Type": "application/json",
89
+ },
90
+ timeout=15,
91
+ )
92
+ if r.ok:
93
+ data = r.json()
94
+ result["users"] = data.get("users", [])
95
+ result["total"] = data.get("total", len(result["users"]))
96
+ else:
97
+ result["errors"]["users"] = f"{r.status_code}: {r.text[:200]}"
98
+ except Exception as e:
99
+ result["errors"]["users"] = str(e)
100
+
101
+ # Server status
102
+ try:
103
+ r = requests.get(
104
+ f"{UPSTREAM_API}/api/server-status",
105
+ headers={
106
+ "X-API-Key": UPSTREAM_API_KEY,
107
+ "Content-Type": "application/json",
108
+ },
109
+ timeout=15,
110
+ )
111
+ if r.ok:
112
+ result["status"] = r.json()
113
+ else:
114
+ result["errors"]["status"] = f"{r.status_code}: {r.text[:200]}"
115
+ except Exception as e:
116
+ result["errors"]["status"] = str(e)
117
+
118
+ if result["errors"] and not result["users"] and not result["status"]:
119
+ result["ok"] = False
120
+
121
+ return result
122
+
123
+
124
+ # -----------------------------------------------------------------------------
125
+ # Background poller — pushes to authed sockets only
126
+ # -----------------------------------------------------------------------------
127
+ def background_poller():
128
+ while True:
129
+ socketio.sleep(POLL_INTERVAL)
130
+
131
+ with _authed_lock:
132
+ sids = [sid for sid, ok in _authed_sids.items() if ok]
133
+ if not sids:
134
+ continue
135
+
136
+ try:
137
+ payload = fetch_upstream()
138
+ payload["source"] = "auto"
139
+ for sid in sids:
140
+ socketio.emit("data_update", payload, to=sid)
141
+ except Exception as e:
142
+ for sid in sids:
143
+ socketio.emit("error", {"message": str(e)}, to=sid)
144
+
145
+
146
+ # -----------------------------------------------------------------------------
147
+ # Routes
148
+ # -----------------------------------------------------------------------------
149
+ @app.route("/")
150
+ def index():
151
+ # If already unlocked, go straight to dashboard.
152
+ unlocked = bool(session.get("unlocked"))
153
+ return render_template(
154
+ "index.html",
155
+ poll_interval=POLL_INTERVAL,
156
+ unlocked=unlocked,
157
+ )
158
+
159
+
160
+ @app.route("/api/unlock", methods=["POST"])
161
+ def unlock():
162
+ data = request.get_json(silent=True) or {}
163
+ pw = (data.get("password") or "").strip()
164
+ if pw == MASTER_PASSWORD:
165
+ session.permanent = True
166
+ session["unlocked"] = True
167
+ return jsonify({"ok": True})
168
+ # small delay to discourage brute force
169
+ time.sleep(0.6)
170
+ return jsonify({"ok": False, "error": "Invalid password"}), 401
171
+
172
+
173
+ @app.route("/api/lock", methods=["POST"])
174
+ def lock():
175
+ session.pop("unlocked", None)
176
+ return jsonify({"ok": True})
177
+
178
+
179
+ @app.route("/api/session")
180
+ def session_state():
181
+ return jsonify({"unlocked": bool(session.get("unlocked"))})
182
+
183
+
184
+ @app.route("/health")
185
+ def health():
186
+ return jsonify({"status": "ok", "authed_sockets": len(_authed_sids)})
187
+
188
+
189
+ # -----------------------------------------------------------------------------
190
+ # Socket.IO events
191
+ # -----------------------------------------------------------------------------
192
+ @socketio.on("connect")
193
+ def on_connect():
194
+ # Auto-authorize this socket if the HTTP session is unlocked.
195
+ if session.get("unlocked"):
196
+ mark_authed(request.sid)
197
+ emit("connected", {
198
+ "sid": request.sid,
199
+ "poll_interval": POLL_INTERVAL,
200
+ "authed": True,
201
+ })
202
+ # immediate snapshot
203
+ payload = fetch_upstream()
204
+ payload["source"] = "initial"
205
+ emit("data_update", payload)
206
+ else:
207
+ emit("connected", {
208
+ "sid": request.sid,
209
+ "poll_interval": POLL_INTERVAL,
210
+ "authed": False,
211
+ })
212
+
213
+
214
+ @socketio.on("disconnect")
215
+ def on_disconnect():
216
+ drop_sid(request.sid)
217
+
218
+
219
+ @socketio.on("refresh")
220
+ def on_refresh():
221
+ if not is_authed(request.sid):
222
+ emit("error", {"message": "Not authenticated"})
223
+ return
224
+ payload = fetch_upstream()
225
+ payload["source"] = "manual"
226
+ emit("data_update", payload)
227
+
228
+
229
+ # -----------------------------------------------------------------------------
230
+ # Start background poller at import time (gunicorn-friendly)
231
+ # -----------------------------------------------------------------------------
232
+ _poller_started = False
233
+ _poller_lock = threading.Lock()
234
+
235
+ def _ensure_poller():
236
+ global _poller_started
237
+ with _poller_lock:
238
+ if not _poller_started:
239
+ socketio.start_background_task(background_poller)
240
+ _poller_started = True
241
+
242
+ _ensure_poller()
243
+
244
+ # -----------------------------------------------------------------------------
245
+ # Entry point (only used for local `python app.py`)
246
+ # -----------------------------------------------------------------------------
247
+ if __name__ == "__main__":
248
+ port = int(os.environ.get("PORT", 7860))
249
+ socketio.run(app, host="0.0.0.0", port=port, debug=False)
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ Flask==3.0.3
2
+ Flask-SocketIO==5.3.6
3
+ python-socketio==5.11.4
4
+ python-engineio==4.9.1
5
+ eventlet==0.36.1
6
+ requests==2.32.3
7
+ gunicorn==22.0.0
static/css/style.css ADDED
@@ -0,0 +1,1493 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =============================================================
2
+ ServerClass Admin v2 — Pro Dark UI · Mobile-First
3
+ ============================================================= */
4
+
5
+ :root {
6
+ /* Surfaces */
7
+ --bg: #07070b;
8
+ --bg-soft: #0c0c14;
9
+ --surface: #11121c;
10
+ --surface-2: #161725;
11
+ --surface-3: #1d1f30;
12
+ --elevated: #1a1c2b;
13
+ --line: rgba(255,255,255,0.06);
14
+ --line-2: rgba(255,255,255,0.10);
15
+
16
+ /* Text */
17
+ --text: #f4f4f8;
18
+ --text-soft: #b9bac8;
19
+ --text-mute: #71748a;
20
+ --text-dim: #4a4d65;
21
+
22
+ /* Accents */
23
+ --accent: #7c7cff;
24
+ --accent-2: #a78bfa;
25
+ --accent-glow: rgba(124, 124, 255, 0.35);
26
+ --success: #34d399;
27
+ --success-soft: rgba(52, 211, 153, 0.15);
28
+ --warn: #fbbf24;
29
+ --warn-soft: rgba(251, 191, 36, 0.15);
30
+ --danger: #f87171;
31
+ --danger-soft: rgba(248, 113, 113, 0.15);
32
+
33
+ /* Radii */
34
+ --r-sm: 8px;
35
+ --r: 12px;
36
+ --r-lg: 18px;
37
+ --r-xl: 24px;
38
+
39
+ /* Shadows */
40
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
41
+ --shadow: 0 8px 24px rgba(0,0,0,0.35);
42
+ --shadow-lg: 0 20px 50px rgba(0,0,0,0.55);
43
+
44
+ /* Layout */
45
+ --sidebar-w: 248px;
46
+ --header-h: 64px;
47
+ --bottomnav-h: 68px;
48
+
49
+ /* Safe-area (iOS) */
50
+ --sa-t: env(safe-area-inset-top, 0px);
51
+ --sa-b: env(safe-area-inset-bottom, 0px);
52
+ }
53
+
54
+ * { box-sizing: border-box; margin: 0; padding: 0; }
55
+ *::selection { background: var(--accent); color: #fff; }
56
+
57
+ html, body {
58
+ background: var(--bg);
59
+ color: var(--text);
60
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
61
+ font-size: 15px;
62
+ line-height: 1.55;
63
+ -webkit-font-smoothing: antialiased;
64
+ -moz-osx-font-smoothing: grayscale;
65
+ font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
66
+ min-height: 100vh;
67
+ overflow-x: hidden;
68
+ }
69
+
70
+ body {
71
+ background:
72
+ radial-gradient(1200px 600px at 80% -10%, rgba(124,124,255,0.15), transparent 60%),
73
+ radial-gradient(900px 500px at -10% 30%, rgba(167,139,250,0.08), transparent 60%),
74
+ var(--bg);
75
+ background-attachment: fixed;
76
+ }
77
+
78
+ .mono {
79
+ font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
80
+ font-size: 0.7rem;
81
+ letter-spacing: 0.12em;
82
+ text-transform: uppercase;
83
+ font-weight: 500;
84
+ }
85
+
86
+ button { font-family: inherit; cursor: pointer; }
87
+ input { font-family: inherit; }
88
+
89
+ /* =============================================================
90
+ LOCK SCREEN
91
+ ============================================================= */
92
+ .lock {
93
+ position: fixed;
94
+ inset: 0;
95
+ z-index: 1000;
96
+ display: flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ padding: 24px;
100
+ padding-top: calc(24px + var(--sa-t));
101
+ padding-bottom: calc(24px + var(--sa-b));
102
+ background: var(--bg);
103
+ overflow: hidden;
104
+ animation: lockFadeIn 400ms ease;
105
+ }
106
+ .lock--hidden {
107
+ opacity: 0;
108
+ pointer-events: none;
109
+ transform: scale(0.98);
110
+ transition: opacity 350ms ease, transform 350ms ease;
111
+ }
112
+ .lock__bg {
113
+ position: absolute; inset: 0;
114
+ background:
115
+ radial-gradient(800px 500px at 30% 20%, rgba(124,124,255,0.22), transparent 60%),
116
+ radial-gradient(700px 500px at 80% 80%, rgba(167,139,250,0.18), transparent 60%);
117
+ filter: blur(20px);
118
+ animation: lockBgFloat 14s ease-in-out infinite alternate;
119
+ }
120
+ @keyframes lockBgFloat {
121
+ 0% { transform: translate(0,0) scale(1); }
122
+ 100% { transform: translate(-20px,30px) scale(1.05); }
123
+ }
124
+ @keyframes lockFadeIn {
125
+ from { opacity: 0; transform: scale(0.96); }
126
+ to { opacity: 1; transform: scale(1); }
127
+ }
128
+
129
+ .lock__card {
130
+ position: relative;
131
+ width: 100%;
132
+ max-width: 420px;
133
+ padding: 40px 32px;
134
+ background: rgba(17, 18, 28, 0.72);
135
+ backdrop-filter: blur(24px) saturate(160%);
136
+ -webkit-backdrop-filter: blur(24px) saturate(160%);
137
+ border: 1px solid var(--line-2);
138
+ border-radius: var(--r-xl);
139
+ box-shadow: var(--shadow-lg);
140
+ }
141
+
142
+ .lock__logo {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 14px;
146
+ margin-bottom: 36px;
147
+ }
148
+ .lock__logo-mark {
149
+ width: 44px; height: 44px;
150
+ border-radius: 12px;
151
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
152
+ color: #fff;
153
+ display: grid; place-items: center;
154
+ box-shadow: 0 8px 24px var(--accent-glow);
155
+ }
156
+ .lock__logo-mark svg { width: 22px; height: 22px; }
157
+ .lock__brand-name {
158
+ font-weight: 700;
159
+ font-size: 1rem;
160
+ letter-spacing: -0.01em;
161
+ }
162
+ .lock__brand-sub {
163
+ font-family: 'JetBrains Mono', monospace;
164
+ font-size: 0.65rem;
165
+ color: var(--text-mute);
166
+ letter-spacing: 0.15em;
167
+ text-transform: uppercase;
168
+ }
169
+
170
+ .lock__title {
171
+ font-size: 1.75rem;
172
+ font-weight: 700;
173
+ letter-spacing: -0.02em;
174
+ margin-bottom: 6px;
175
+ }
176
+ .lock__sub {
177
+ color: var(--text-soft);
178
+ margin-bottom: 28px;
179
+ font-size: 0.95rem;
180
+ }
181
+
182
+ .lock__form { display: flex; flex-direction: column; gap: 16px; }
183
+
184
+ .lock__field {
185
+ position: relative;
186
+ display: flex;
187
+ align-items: center;
188
+ }
189
+ .lock__input {
190
+ width: 100%;
191
+ padding: 16px 48px 16px 18px;
192
+ background: var(--surface);
193
+ border: 1.5px solid var(--line-2);
194
+ border-radius: var(--r);
195
+ color: var(--text);
196
+ font-size: 0.95rem;
197
+ letter-spacing: 0.02em;
198
+ transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease;
199
+ }
200
+ .lock__input::placeholder { color: var(--text-dim); }
201
+ .lock__input:focus {
202
+ outline: none;
203
+ border-color: var(--accent);
204
+ box-shadow: 0 0 0 4px var(--accent-glow);
205
+ background: var(--surface-2);
206
+ }
207
+ .lock__toggle {
208
+ position: absolute;
209
+ right: 8px;
210
+ width: 36px; height: 36px;
211
+ background: transparent;
212
+ border: none;
213
+ border-radius: 8px;
214
+ color: var(--text-mute);
215
+ display: grid; place-items: center;
216
+ transition: color 150ms ease, background 150ms ease;
217
+ }
218
+ .lock__toggle:hover { color: var(--text); background: var(--surface-2); }
219
+ .lock__toggle svg { width: 18px; height: 18px; }
220
+
221
+ .lock__submit {
222
+ margin-top: 4px;
223
+ padding: 16px 20px;
224
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
225
+ color: #fff;
226
+ border: none;
227
+ border-radius: var(--r);
228
+ font-weight: 600;
229
+ font-size: 0.95rem;
230
+ letter-spacing: 0.01em;
231
+ display: flex;
232
+ align-items: center;
233
+ justify-content: center;
234
+ gap: 10px;
235
+ box-shadow: 0 10px 30px var(--accent-glow);
236
+ transition: transform 120ms ease, box-shadow 200ms ease, opacity 150ms ease;
237
+ }
238
+ .lock__submit:hover { transform: translateY(-1px); box-shadow: 0 16px 40px var(--accent-glow); }
239
+ .lock__submit:active { transform: translateY(0); }
240
+ .lock__submit:disabled { opacity: 0.6; cursor: wait; }
241
+ .lock__submit-arrow { width: 18px; height: 18px; transition: transform 150ms ease; }
242
+ .lock__submit:hover .lock__submit-arrow { transform: translateX(3px); }
243
+
244
+ .lock__error {
245
+ min-height: 22px;
246
+ font-size: 0.85rem;
247
+ color: var(--danger);
248
+ text-align: center;
249
+ font-weight: 500;
250
+ }
251
+ .lock__error.shake { animation: shake 400ms ease; }
252
+ @keyframes shake {
253
+ 0%,100% { transform: translateX(0); }
254
+ 20% { transform: translateX(-8px); }
255
+ 40% { transform: translateX(8px); }
256
+ 60% { transform: translateX(-5px); }
257
+ 80% { transform: translateX(5px); }
258
+ }
259
+
260
+ .lock__footer {
261
+ margin-top: 32px;
262
+ display: flex;
263
+ align-items: center;
264
+ justify-content: center;
265
+ gap: 8px;
266
+ color: var(--text-dim);
267
+ }
268
+ .lock__footer .dot {
269
+ width: 6px; height: 6px;
270
+ border-radius: 50%;
271
+ background: var(--success);
272
+ box-shadow: 0 0 8px var(--success);
273
+ }
274
+
275
+ /* =============================================================
276
+ APP SHELL
277
+ ============================================================= */
278
+ .app {
279
+ display: grid;
280
+ grid-template-columns: var(--sidebar-w) 1fr;
281
+ min-height: 100vh;
282
+ opacity: 1;
283
+ transition: opacity 300ms ease;
284
+ }
285
+ .app--hidden { opacity: 0; pointer-events: none; }
286
+
287
+ /* =============================================================
288
+ SIDEBAR (desktop)
289
+ ============================================================= */
290
+ .sidebar {
291
+ position: sticky;
292
+ top: 0;
293
+ height: 100vh;
294
+ background: rgba(12, 12, 20, 0.6);
295
+ backdrop-filter: blur(20px);
296
+ -webkit-backdrop-filter: blur(20px);
297
+ border-right: 1px solid var(--line);
298
+ padding: 24px 16px;
299
+ display: flex;
300
+ flex-direction: column;
301
+ z-index: 50;
302
+ }
303
+
304
+ .sidebar__brand {
305
+ display: flex;
306
+ align-items: center;
307
+ gap: 12px;
308
+ padding: 8px 12px 24px;
309
+ border-bottom: 1px solid var(--line);
310
+ margin-bottom: 16px;
311
+ }
312
+ .sidebar__mark {
313
+ width: 38px; height: 38px;
314
+ border-radius: 10px;
315
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
316
+ color: #fff;
317
+ display: grid; place-items: center;
318
+ box-shadow: 0 6px 18px var(--accent-glow);
319
+ }
320
+ .sidebar__mark svg { width: 20px; height: 20px; }
321
+ .sidebar__title {
322
+ font-weight: 700;
323
+ font-size: 0.98rem;
324
+ letter-spacing: -0.01em;
325
+ }
326
+ .sidebar__subtitle {
327
+ color: var(--text-mute);
328
+ font-size: 0.62rem;
329
+ }
330
+
331
+ .sidebar__nav {
332
+ display: flex;
333
+ flex-direction: column;
334
+ gap: 4px;
335
+ flex: 1;
336
+ }
337
+
338
+ .nav-item {
339
+ display: flex;
340
+ align-items: center;
341
+ gap: 12px;
342
+ padding: 11px 12px;
343
+ background: transparent;
344
+ border: none;
345
+ border-radius: var(--r);
346
+ color: var(--text-soft);
347
+ font-size: 0.92rem;
348
+ font-weight: 500;
349
+ text-align: left;
350
+ position: relative;
351
+ transition: background 150ms ease, color 150ms ease;
352
+ }
353
+ .nav-item svg { width: 18px; height: 18px; flex-shrink: 0; }
354
+ .nav-item:hover { background: var(--surface); color: var(--text); }
355
+ .nav-item.active {
356
+ background: linear-gradient(135deg, rgba(124,124,255,0.15), rgba(167,139,250,0.08));
357
+ color: var(--text);
358
+ }
359
+ .nav-item.active::before {
360
+ content: "";
361
+ position: absolute;
362
+ left: -16px;
363
+ top: 50%;
364
+ transform: translateY(-50%);
365
+ width: 3px;
366
+ height: 22px;
367
+ border-radius: 0 3px 3px 0;
368
+ background: linear-gradient(180deg, var(--accent), var(--accent-2));
369
+ }
370
+ .nav-item__count {
371
+ margin-left: auto;
372
+ font-family: 'JetBrains Mono', monospace;
373
+ font-size: 0.65rem;
374
+ background: var(--surface-2);
375
+ color: var(--text-mute);
376
+ padding: 2px 8px;
377
+ border-radius: 999px;
378
+ font-weight: 500;
379
+ }
380
+ .nav-item.active .nav-item__count {
381
+ background: var(--accent);
382
+ color: #fff;
383
+ }
384
+
385
+ .sidebar__footer {
386
+ padding-top: 16px;
387
+ border-top: 1px solid var(--line);
388
+ display: flex;
389
+ align-items: center;
390
+ gap: 8px;
391
+ }
392
+
393
+ .conn-pill {
394
+ display: inline-flex;
395
+ align-items: center;
396
+ gap: 8px;
397
+ padding: 6px 10px 6px 8px;
398
+ background: var(--surface);
399
+ border: 1px solid var(--line-2);
400
+ border-radius: 999px;
401
+ font-family: 'JetBrains Mono', monospace;
402
+ font-size: 0.65rem;
403
+ letter-spacing: 0.1em;
404
+ text-transform: uppercase;
405
+ color: var(--text-soft);
406
+ flex: 1;
407
+ }
408
+ .conn-pill__dot {
409
+ width: 7px; height: 7px;
410
+ border-radius: 50%;
411
+ background: var(--text-dim);
412
+ box-shadow: 0 0 0 0 transparent;
413
+ flex-shrink: 0;
414
+ }
415
+ .conn-pill[data-state="on"] { color: var(--success); border-color: var(--success-soft); }
416
+ .conn-pill[data-state="on"] .conn-pill__dot {
417
+ background: var(--success);
418
+ box-shadow: 0 0 10px var(--success);
419
+ animation: dotPulse 2s ease-in-out infinite;
420
+ }
421
+ .conn-pill[data-state="warn"] { color: var(--warn); border-color: var(--warn-soft); }
422
+ .conn-pill[data-state="warn"] .conn-pill__dot {
423
+ background: var(--warn);
424
+ box-shadow: 0 0 8px var(--warn);
425
+ }
426
+ .conn-pill[data-state="off"] { color: var(--danger); border-color: var(--danger-soft); }
427
+ .conn-pill[data-state="off"] .conn-pill__dot { background: var(--danger); }
428
+ .conn-pill--mini {
429
+ padding: 3px 8px 3px 6px;
430
+ font-size: 0.6rem;
431
+ background: transparent;
432
+ border: none;
433
+ }
434
+
435
+ @keyframes dotPulse {
436
+ 0%, 100% { transform: scale(1); opacity: 1; }
437
+ 50% { transform: scale(1.3); opacity: 0.75; }
438
+ }
439
+
440
+ .sidebar__logout {
441
+ width: 36px; height: 36px;
442
+ background: var(--surface);
443
+ border: 1px solid var(--line-2);
444
+ border-radius: 10px;
445
+ color: var(--text-mute);
446
+ display: grid; place-items: center;
447
+ transition: all 150ms ease;
448
+ }
449
+ .sidebar__logout:hover {
450
+ color: var(--danger);
451
+ border-color: var(--danger);
452
+ background: var(--danger-soft);
453
+ }
454
+ .sidebar__logout svg { width: 16px; height: 16px; }
455
+
456
+ /* =============================================================
457
+ MAIN
458
+ ============================================================= */
459
+ .main {
460
+ min-width: 0;
461
+ padding-bottom: calc(var(--bottomnav-h) + var(--sa-b));
462
+ }
463
+
464
+ /* =============================================================
465
+ TOPBAR (mobile-prominent)
466
+ ============================================================= */
467
+ .topbar {
468
+ position: sticky;
469
+ top: 0;
470
+ z-index: 40;
471
+ display: flex;
472
+ align-items: center;
473
+ justify-content: space-between;
474
+ gap: 12px;
475
+ padding: 14px 24px;
476
+ padding-top: calc(14px + var(--sa-t));
477
+ background: rgba(7, 7, 11, 0.75);
478
+ backdrop-filter: blur(20px) saturate(160%);
479
+ -webkit-backdrop-filter: blur(20px) saturate(160%);
480
+ border-bottom: 1px solid var(--line);
481
+ }
482
+
483
+ .topbar__left, .topbar__right {
484
+ display: flex; align-items: center; gap: 10px;
485
+ }
486
+ .topbar__brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
487
+ .topbar__mark {
488
+ width: 38px; height: 38px;
489
+ border-radius: 10px;
490
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
491
+ color: #fff;
492
+ display: grid; place-items: center;
493
+ box-shadow: 0 6px 18px var(--accent-glow);
494
+ flex-shrink: 0;
495
+ }
496
+ .topbar__mark svg { width: 20px; height: 20px; }
497
+ .topbar__title {
498
+ font-weight: 700;
499
+ font-size: 1rem;
500
+ letter-spacing: -0.01em;
501
+ white-space: nowrap;
502
+ overflow: hidden;
503
+ text-overflow: ellipsis;
504
+ }
505
+ .topbar__sub {
506
+ color: var(--text-mute);
507
+ font-size: 0.62rem;
508
+ display: flex;
509
+ align-items: center;
510
+ gap: 6px;
511
+ white-space: nowrap;
512
+ }
513
+
514
+ .icon-btn {
515
+ width: 40px; height: 40px;
516
+ background: var(--surface);
517
+ border: 1px solid var(--line);
518
+ border-radius: 10px;
519
+ color: var(--text-soft);
520
+ display: grid; place-items: center;
521
+ transition: all 150ms ease;
522
+ }
523
+ .icon-btn:hover {
524
+ background: var(--surface-2);
525
+ color: var(--text);
526
+ border-color: var(--line-2);
527
+ }
528
+ .icon-btn:active { transform: scale(0.94); }
529
+ .icon-btn svg { width: 18px; height: 18px; }
530
+ .icon-btn--mobile { display: none; }
531
+
532
+ /* =============================================================
533
+ VIEWS
534
+ ============================================================= */
535
+ .view {
536
+ padding: 32px 32px 40px;
537
+ max-width: 1400px;
538
+ margin: 0 auto;
539
+ display: none;
540
+ animation: viewFadeIn 280ms cubic-bezier(0.2, 0.8, 0.2, 1);
541
+ }
542
+ .view--active { display: block; }
543
+
544
+ @keyframes viewFadeIn {
545
+ from { opacity: 0; transform: translateY(8px); }
546
+ to { opacity: 1; transform: translateY(0); }
547
+ }
548
+
549
+ .view__head {
550
+ display: flex;
551
+ align-items: flex-start;
552
+ justify-content: space-between;
553
+ gap: 24px;
554
+ margin-bottom: 28px;
555
+ flex-wrap: wrap;
556
+ }
557
+ .eyebrow { color: var(--accent); margin-bottom: 8px; }
558
+ .view__title {
559
+ font-size: clamp(1.75rem, 3vw, 2.5rem);
560
+ font-weight: 800;
561
+ letter-spacing: -0.025em;
562
+ line-height: 1.1;
563
+ margin-bottom: 6px;
564
+ }
565
+ .view__sub {
566
+ color: var(--text-soft);
567
+ font-size: 0.95rem;
568
+ max-width: 560px;
569
+ }
570
+
571
+ .live-indicator {
572
+ display: inline-flex;
573
+ align-items: center;
574
+ gap: 8px;
575
+ padding: 8px 14px;
576
+ background: var(--success-soft);
577
+ border: 1px solid rgba(52, 211, 153, 0.3);
578
+ border-radius: 999px;
579
+ color: var(--success);
580
+ font-size: 0.65rem;
581
+ }
582
+ .live-indicator__pulse {
583
+ width: 8px; height: 8px;
584
+ border-radius: 50%;
585
+ background: var(--success);
586
+ box-shadow: 0 0 10px var(--success);
587
+ animation: dotPulse 2s ease-in-out infinite;
588
+ }
589
+
590
+ /* =============================================================
591
+ KPI CARDS
592
+ ============================================================= */
593
+ .kpi-grid {
594
+ display: grid;
595
+ grid-template-columns: repeat(4, 1fr);
596
+ gap: 16px;
597
+ margin-bottom: 28px;
598
+ }
599
+
600
+ .kpi {
601
+ position: relative;
602
+ padding: 22px;
603
+ background: var(--surface);
604
+ border: 1px solid var(--line);
605
+ border-radius: var(--r-lg);
606
+ overflow: hidden;
607
+ transition: transform 200ms ease, border-color 200ms ease;
608
+ }
609
+ .kpi:hover {
610
+ transform: translateY(-2px);
611
+ border-color: var(--line-2);
612
+ }
613
+ .kpi::after {
614
+ content: "";
615
+ position: absolute;
616
+ inset: 0;
617
+ background: radial-gradient(400px 200px at 0% 0%, rgba(124,124,255,0.08), transparent 60%);
618
+ pointer-events: none;
619
+ }
620
+ .kpi--accent {
621
+ background: linear-gradient(135deg, rgba(124,124,255,0.12), rgba(167,139,250,0.05)), var(--surface);
622
+ border-color: rgba(124,124,255,0.25);
623
+ }
624
+ .kpi__icon {
625
+ width: 38px; height: 38px;
626
+ border-radius: 10px;
627
+ background: var(--surface-2);
628
+ border: 1px solid var(--line-2);
629
+ color: var(--accent);
630
+ display: grid; place-items: center;
631
+ margin-bottom: 14px;
632
+ }
633
+ .kpi--accent .kpi__icon {
634
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
635
+ color: #fff;
636
+ border-color: transparent;
637
+ }
638
+ .kpi__icon svg { width: 18px; height: 18px; }
639
+ .kpi__label {
640
+ color: var(--text-mute);
641
+ margin-bottom: 8px;
642
+ font-size: 0.62rem;
643
+ }
644
+ .kpi__value {
645
+ font-size: 2rem;
646
+ font-weight: 800;
647
+ letter-spacing: -0.03em;
648
+ line-height: 1;
649
+ margin-bottom: 8px;
650
+ font-variant-numeric: tabular-nums;
651
+ }
652
+ .kpi__value-suffix { font-size: 0.65em; color: var(--text-mute); margin-left: 2px; }
653
+ .kpi__trend {
654
+ color: var(--text-mute);
655
+ font-size: 0.65rem;
656
+ }
657
+ .kpi__bar {
658
+ position: relative;
659
+ height: 4px;
660
+ background: var(--surface-2);
661
+ border-radius: 999px;
662
+ margin: 10px 0 8px;
663
+ overflow: hidden;
664
+ }
665
+ .kpi__bar-fill {
666
+ position: absolute;
667
+ inset: 0 auto 0 0;
668
+ background: linear-gradient(90deg, var(--accent), var(--accent-2));
669
+ border-radius: 999px;
670
+ transition: width 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
671
+ }
672
+
673
+ /* =============================================================
674
+ CARDS
675
+ ============================================================= */
676
+ .card {
677
+ background: var(--surface);
678
+ border: 1px solid var(--line);
679
+ border-radius: var(--r-lg);
680
+ padding: 24px;
681
+ margin-bottom: 20px;
682
+ }
683
+ .card__head {
684
+ display: flex;
685
+ align-items: flex-start;
686
+ justify-content: space-between;
687
+ gap: 16px;
688
+ margin-bottom: 20px;
689
+ flex-wrap: wrap;
690
+ }
691
+ .card__eyebrow {
692
+ color: var(--accent);
693
+ margin-bottom: 4px;
694
+ }
695
+ .card__title {
696
+ font-size: 1.15rem;
697
+ font-weight: 700;
698
+ letter-spacing: -0.015em;
699
+ }
700
+ .card__legend {
701
+ display: flex;
702
+ gap: 14px;
703
+ flex-wrap: wrap;
704
+ }
705
+ .legend {
706
+ display: inline-flex;
707
+ align-items: center;
708
+ gap: 6px;
709
+ color: var(--text-mute);
710
+ font-family: 'JetBrains Mono', monospace;
711
+ font-size: 0.65rem;
712
+ letter-spacing: 0.08em;
713
+ text-transform: uppercase;
714
+ }
715
+ .legend__sw {
716
+ width: 10px; height: 10px;
717
+ border-radius: 3px;
718
+ display: inline-block;
719
+ }
720
+ .legend__sw--ok { background: var(--success); }
721
+ .legend__sw--warn { background: var(--warn); }
722
+ .legend__sw--full { background: var(--danger); }
723
+
724
+ .link-btn {
725
+ background: none;
726
+ border: none;
727
+ color: var(--accent);
728
+ font-size: 0.85rem;
729
+ font-weight: 500;
730
+ padding: 6px 10px;
731
+ border-radius: 8px;
732
+ transition: background 150ms ease;
733
+ }
734
+ .link-btn:hover { background: rgba(124,124,255,0.1); }
735
+
736
+ /* =============================================================
737
+ CHART (load distribution)
738
+ ============================================================= */
739
+ .chart {
740
+ display: grid;
741
+ grid-template-columns: repeat(auto-fit, minmax(50px, 1fr));
742
+ gap: 8px;
743
+ align-items: end;
744
+ height: 200px;
745
+ padding: 16px 8px 0;
746
+ }
747
+ .chart__empty {
748
+ grid-column: 1 / -1;
749
+ text-align: center;
750
+ color: var(--text-dim);
751
+ align-self: center;
752
+ }
753
+ .chart__bar {
754
+ position: relative;
755
+ display: flex;
756
+ flex-direction: column;
757
+ align-items: center;
758
+ height: 100%;
759
+ justify-content: flex-end;
760
+ gap: 8px;
761
+ }
762
+ .chart__bar-track {
763
+ width: 100%;
764
+ flex: 1;
765
+ display: flex;
766
+ flex-direction: column;
767
+ justify-content: flex-end;
768
+ background: var(--surface-2);
769
+ border-radius: 8px;
770
+ overflow: hidden;
771
+ position: relative;
772
+ }
773
+ .chart__bar-fill {
774
+ width: 100%;
775
+ border-radius: 8px;
776
+ background: linear-gradient(180deg, var(--accent), var(--accent-2));
777
+ transition: height 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
778
+ min-height: 4px;
779
+ position: relative;
780
+ }
781
+ .chart__bar-fill[data-state="warn"] { background: linear-gradient(180deg, #fbbf24, #f59e0b); }
782
+ .chart__bar-fill[data-state="full"] { background: linear-gradient(180deg, #f87171, #ef4444); }
783
+ .chart__bar-fill::after {
784
+ content: attr(data-value);
785
+ position: absolute;
786
+ top: -22px;
787
+ left: 50%;
788
+ transform: translateX(-50%);
789
+ font-family: 'JetBrains Mono', monospace;
790
+ font-size: 0.62rem;
791
+ color: var(--text-soft);
792
+ white-space: nowrap;
793
+ opacity: 0;
794
+ transition: opacity 150ms ease;
795
+ }
796
+ .chart__bar:hover .chart__bar-fill::after { opacity: 1; }
797
+ .chart__bar-label {
798
+ font-family: 'JetBrains Mono', monospace;
799
+ font-size: 0.6rem;
800
+ color: var(--text-mute);
801
+ letter-spacing: 0.06em;
802
+ }
803
+
804
+ /* =============================================================
805
+ SPLIT (recent users + top servers)
806
+ ============================================================= */
807
+ .split {
808
+ display: grid;
809
+ grid-template-columns: 1fr 1fr;
810
+ gap: 20px;
811
+ }
812
+ .split .card { margin-bottom: 0; }
813
+
814
+ .recent {
815
+ display: flex;
816
+ flex-direction: column;
817
+ gap: 8px;
818
+ }
819
+ .recent__empty {
820
+ padding: 32px 0;
821
+ color: var(--text-dim);
822
+ text-align: center;
823
+ }
824
+ .recent-row {
825
+ display: flex;
826
+ align-items: center;
827
+ gap: 12px;
828
+ padding: 12px;
829
+ background: var(--surface-2);
830
+ border: 1px solid var(--line);
831
+ border-radius: var(--r);
832
+ transition: background 150ms ease;
833
+ }
834
+ .recent-row:hover { background: var(--surface-3); }
835
+ .recent-row__avatar {
836
+ width: 36px; height: 36px;
837
+ border-radius: 50%;
838
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
839
+ color: #fff;
840
+ display: grid; place-items: center;
841
+ font-weight: 700;
842
+ font-size: 0.85rem;
843
+ flex-shrink: 0;
844
+ }
845
+ .recent-row__body { flex: 1; min-width: 0; }
846
+ .recent-row__name {
847
+ font-weight: 600;
848
+ font-size: 0.92rem;
849
+ white-space: nowrap;
850
+ overflow: hidden;
851
+ text-overflow: ellipsis;
852
+ }
853
+ .recent-row__meta {
854
+ font-family: 'JetBrains Mono', monospace;
855
+ font-size: 0.65rem;
856
+ color: var(--text-mute);
857
+ letter-spacing: 0.05em;
858
+ }
859
+ .recent-row__tag {
860
+ font-family: 'JetBrains Mono', monospace;
861
+ font-size: 0.65rem;
862
+ color: var(--text-soft);
863
+ padding: 4px 8px;
864
+ background: var(--surface);
865
+ border: 1px solid var(--line-2);
866
+ border-radius: 6px;
867
+ letter-spacing: 0.05em;
868
+ white-space: nowrap;
869
+ }
870
+
871
+ /* Top servers rows */
872
+ .recent-row--server .recent-row__avatar {
873
+ background: var(--surface);
874
+ border: 1px solid var(--line-2);
875
+ color: var(--text);
876
+ font-family: 'JetBrains Mono', monospace;
877
+ font-size: 0.72rem;
878
+ }
879
+ .recent-row__bar {
880
+ width: 100%;
881
+ height: 4px;
882
+ background: var(--surface);
883
+ border-radius: 999px;
884
+ margin-top: 6px;
885
+ overflow: hidden;
886
+ }
887
+ .recent-row__bar-fill {
888
+ height: 100%;
889
+ background: linear-gradient(90deg, var(--accent), var(--accent-2));
890
+ transition: width 500ms ease;
891
+ }
892
+ .recent-row__bar-fill[data-state="warn"] { background: linear-gradient(90deg, var(--warn), #f59e0b); }
893
+ .recent-row__bar-fill[data-state="full"] { background: linear-gradient(90deg, var(--danger), #ef4444); }
894
+
895
+ /* =============================================================
896
+ TOOLBAR (search + filters)
897
+ ============================================================= */
898
+ .toolbar {
899
+ display: flex;
900
+ align-items: center;
901
+ gap: 12px;
902
+ margin-bottom: 20px;
903
+ flex-wrap: wrap;
904
+ }
905
+
906
+ .search {
907
+ position: relative;
908
+ display: flex;
909
+ align-items: center;
910
+ flex: 1;
911
+ min-width: 240px;
912
+ background: var(--surface);
913
+ border: 1px solid var(--line);
914
+ border-radius: var(--r);
915
+ padding: 0 14px 0 12px;
916
+ transition: border-color 150ms ease, box-shadow 150ms ease;
917
+ }
918
+ .search:focus-within {
919
+ border-color: var(--accent);
920
+ box-shadow: 0 0 0 4px var(--accent-glow);
921
+ }
922
+ .search__icon {
923
+ width: 16px; height: 16px;
924
+ color: var(--text-mute);
925
+ flex-shrink: 0;
926
+ }
927
+ .search__input {
928
+ flex: 1;
929
+ background: transparent;
930
+ border: none;
931
+ padding: 12px 12px;
932
+ font-size: 0.92rem;
933
+ color: var(--text);
934
+ min-width: 0;
935
+ }
936
+ .search__input::placeholder { color: var(--text-dim); }
937
+ .search__input:focus { outline: none; }
938
+ .search__hint {
939
+ color: var(--text-mute);
940
+ font-size: 0.62rem;
941
+ flex-shrink: 0;
942
+ }
943
+
944
+ .seg {
945
+ display: flex;
946
+ background: var(--surface);
947
+ border: 1px solid var(--line);
948
+ border-radius: var(--r);
949
+ padding: 4px;
950
+ }
951
+ .seg__btn {
952
+ background: transparent;
953
+ border: none;
954
+ color: var(--text-soft);
955
+ padding: 8px 14px;
956
+ border-radius: 8px;
957
+ font-size: 0.82rem;
958
+ font-weight: 500;
959
+ transition: all 150ms ease;
960
+ }
961
+ .seg__btn.active {
962
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
963
+ color: #fff;
964
+ box-shadow: 0 4px 12px var(--accent-glow);
965
+ }
966
+ .seg__btn:not(.active):hover { color: var(--text); background: var(--surface-2); }
967
+
968
+ /* =============================================================
969
+ BUTTONS
970
+ ============================================================= */
971
+ .btn {
972
+ display: inline-flex;
973
+ align-items: center;
974
+ gap: 8px;
975
+ padding: 11px 16px;
976
+ border-radius: var(--r);
977
+ font-size: 0.85rem;
978
+ font-weight: 500;
979
+ border: 1px solid transparent;
980
+ transition: all 150ms ease;
981
+ white-space: nowrap;
982
+ }
983
+ .btn--primary {
984
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
985
+ color: #fff;
986
+ box-shadow: 0 6px 18px var(--accent-glow);
987
+ }
988
+ .btn--primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px var(--accent-glow); }
989
+ .btn--ghost {
990
+ background: var(--surface);
991
+ color: var(--text-soft);
992
+ border-color: var(--line-2);
993
+ }
994
+ .btn--ghost:hover { background: var(--surface-2); color: var(--text); }
995
+ .btn--danger {
996
+ background: var(--danger-soft);
997
+ color: var(--danger);
998
+ border-color: rgba(248, 113, 113, 0.3);
999
+ }
1000
+ .btn--danger:hover {
1001
+ background: var(--danger);
1002
+ color: #fff;
1003
+ border-color: var(--danger);
1004
+ }
1005
+
1006
+ /* =============================================================
1007
+ SERVER CARDS GRID
1008
+ ============================================================= */
1009
+ .cards-grid {
1010
+ display: grid;
1011
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1012
+ gap: 16px;
1013
+ }
1014
+
1015
+ .server-card {
1016
+ position: relative;
1017
+ padding: 20px;
1018
+ background: var(--surface);
1019
+ border: 1px solid var(--line);
1020
+ border-radius: var(--r-lg);
1021
+ transition: transform 200ms ease, border-color 200ms ease, background 200ms ease;
1022
+ overflow: hidden;
1023
+ }
1024
+ .server-card:hover {
1025
+ transform: translateY(-2px);
1026
+ border-color: var(--line-2);
1027
+ background: var(--surface-2);
1028
+ }
1029
+ .server-card__head {
1030
+ display: flex;
1031
+ align-items: center;
1032
+ justify-content: space-between;
1033
+ margin-bottom: 16px;
1034
+ }
1035
+ .server-card__num {
1036
+ font-family: 'JetBrains Mono', monospace;
1037
+ font-size: 0.72rem;
1038
+ color: var(--text-mute);
1039
+ letter-spacing: 0.08em;
1040
+ }
1041
+ .server-card__name {
1042
+ font-size: 1.15rem;
1043
+ font-weight: 700;
1044
+ letter-spacing: -0.01em;
1045
+ margin-top: 2px;
1046
+ }
1047
+
1048
+ .server-card__metrics {
1049
+ display: grid;
1050
+ grid-template-columns: 1fr 1fr;
1051
+ gap: 10px;
1052
+ margin-bottom: 14px;
1053
+ }
1054
+ .metric {
1055
+ padding: 10px 12px;
1056
+ background: var(--surface-2);
1057
+ border: 1px solid var(--line);
1058
+ border-radius: var(--r-sm);
1059
+ }
1060
+ .metric__label {
1061
+ font-family: 'JetBrains Mono', monospace;
1062
+ font-size: 0.58rem;
1063
+ color: var(--text-mute);
1064
+ letter-spacing: 0.1em;
1065
+ text-transform: uppercase;
1066
+ }
1067
+ .metric__value {
1068
+ font-size: 1.1rem;
1069
+ font-weight: 700;
1070
+ margin-top: 2px;
1071
+ font-variant-numeric: tabular-nums;
1072
+ }
1073
+
1074
+ .server-card__cap {
1075
+ margin-bottom: 12px;
1076
+ }
1077
+ .server-card__cap-head {
1078
+ display: flex;
1079
+ justify-content: space-between;
1080
+ align-items: baseline;
1081
+ margin-bottom: 6px;
1082
+ }
1083
+ .server-card__cap-label {
1084
+ font-family: 'JetBrains Mono', monospace;
1085
+ font-size: 0.6rem;
1086
+ color: var(--text-mute);
1087
+ letter-spacing: 0.1em;
1088
+ text-transform: uppercase;
1089
+ }
1090
+ .server-card__cap-value {
1091
+ font-family: 'JetBrains Mono', monospace;
1092
+ font-size: 0.78rem;
1093
+ color: var(--text-soft);
1094
+ font-variant-numeric: tabular-nums;
1095
+ }
1096
+ .server-card__bar {
1097
+ height: 6px;
1098
+ background: var(--surface-3);
1099
+ border-radius: 999px;
1100
+ overflow: hidden;
1101
+ }
1102
+ .server-card__bar-fill {
1103
+ height: 100%;
1104
+ background: linear-gradient(90deg, var(--success), #10b981);
1105
+ transition: width 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
1106
+ border-radius: 999px;
1107
+ }
1108
+ .server-card__bar-fill[data-state="warn"] { background: linear-gradient(90deg, var(--warn), #f59e0b); }
1109
+ .server-card__bar-fill[data-state="full"] { background: linear-gradient(90deg, var(--danger), #ef4444); }
1110
+
1111
+ .server-card__foot {
1112
+ display: flex;
1113
+ align-items: center;
1114
+ justify-content: space-between;
1115
+ gap: 8px;
1116
+ }
1117
+ .server-card__url {
1118
+ font-family: 'JetBrains Mono', monospace;
1119
+ font-size: 0.65rem;
1120
+ color: var(--text-mute);
1121
+ overflow: hidden;
1122
+ text-overflow: ellipsis;
1123
+ white-space: nowrap;
1124
+ flex: 1;
1125
+ min-width: 0;
1126
+ }
1127
+
1128
+ /* Status pill */
1129
+ .pill {
1130
+ display: inline-flex;
1131
+ align-items: center;
1132
+ gap: 6px;
1133
+ padding: 4px 10px;
1134
+ border-radius: 999px;
1135
+ font-family: 'JetBrains Mono', monospace;
1136
+ font-size: 0.62rem;
1137
+ letter-spacing: 0.1em;
1138
+ text-transform: uppercase;
1139
+ font-weight: 500;
1140
+ }
1141
+ .pill__dot {
1142
+ width: 6px; height: 6px;
1143
+ border-radius: 50%;
1144
+ }
1145
+ .pill--ok { background: var(--success-soft); color: var(--success); }
1146
+ .pill--ok .pill__dot { background: var(--success); box-shadow: 0 0 6px var(--success); }
1147
+ .pill--warn { background: var(--warn-soft); color: var(--warn); }
1148
+ .pill--warn .pill__dot { background: var(--warn); }
1149
+ .pill--full { background: var(--danger-soft); color: var(--danger); }
1150
+ .pill--full .pill__dot { background: var(--danger); }
1151
+ .pill--neutral { background: var(--surface-2); color: var(--text-mute); }
1152
+ .pill--neutral .pill__dot { background: var(--text-mute); }
1153
+
1154
+ /* =============================================================
1155
+ USERS TABLE
1156
+ ============================================================= */
1157
+ .users-wrap {
1158
+ background: var(--surface);
1159
+ border: 1px solid var(--line);
1160
+ border-radius: var(--r-lg);
1161
+ overflow: hidden;
1162
+ }
1163
+
1164
+ .dataset {
1165
+ width: 100%;
1166
+ border-collapse: collapse;
1167
+ font-size: 0.9rem;
1168
+ }
1169
+ .dataset thead th {
1170
+ text-align: left;
1171
+ padding: 14px 18px;
1172
+ background: var(--surface-2);
1173
+ border-bottom: 1px solid var(--line);
1174
+ font-family: 'JetBrains Mono', monospace;
1175
+ font-size: 0.62rem;
1176
+ letter-spacing: 0.12em;
1177
+ text-transform: uppercase;
1178
+ color: var(--text-mute);
1179
+ font-weight: 500;
1180
+ white-space: nowrap;
1181
+ position: sticky;
1182
+ top: 0;
1183
+ z-index: 1;
1184
+ }
1185
+ .dataset thead th.sortable { cursor: pointer; user-select: none; transition: color 150ms ease; }
1186
+ .dataset thead th.sortable:hover { color: var(--text); }
1187
+ .dataset thead th .sort-ind {
1188
+ display: inline-block;
1189
+ margin-left: 4px;
1190
+ opacity: 0.4;
1191
+ }
1192
+ .dataset thead th.sort-asc .sort-ind,
1193
+ .dataset thead th.sort-desc .sort-ind { opacity: 1; color: var(--accent); }
1194
+
1195
+ .dataset tbody td {
1196
+ padding: 14px 18px;
1197
+ border-bottom: 1px solid var(--line);
1198
+ vertical-align: middle;
1199
+ }
1200
+ .dataset tbody tr {
1201
+ transition: background 120ms ease;
1202
+ }
1203
+ .dataset tbody tr:hover { background: var(--surface-2); }
1204
+ .dataset tbody tr:last-child td { border-bottom: none; }
1205
+
1206
+ .cell-idx {
1207
+ font-family: 'JetBrains Mono', monospace;
1208
+ font-size: 0.75rem;
1209
+ color: var(--text-dim);
1210
+ width: 48px;
1211
+ }
1212
+ .cell-user {
1213
+ display: flex;
1214
+ align-items: center;
1215
+ gap: 10px;
1216
+ min-width: 0;
1217
+ }
1218
+ .cell-user__avatar {
1219
+ width: 30px; height: 30px;
1220
+ border-radius: 50%;
1221
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
1222
+ color: #fff;
1223
+ display: grid; place-items: center;
1224
+ font-weight: 700;
1225
+ font-size: 0.72rem;
1226
+ flex-shrink: 0;
1227
+ }
1228
+ .cell-user__name {
1229
+ font-weight: 600;
1230
+ white-space: nowrap;
1231
+ overflow: hidden;
1232
+ text-overflow: ellipsis;
1233
+ max-width: 180px;
1234
+ }
1235
+ .cell-mono {
1236
+ font-family: 'JetBrains Mono', monospace;
1237
+ font-size: 0.78rem;
1238
+ color: var(--text-soft);
1239
+ }
1240
+ .cell-meta {
1241
+ color: var(--text-mute);
1242
+ font-size: 0.82rem;
1243
+ white-space: nowrap;
1244
+ }
1245
+
1246
+ /* =============================================================
1247
+ SETTINGS
1248
+ ============================================================= */
1249
+ .settings {
1250
+ background: var(--surface);
1251
+ border: 1px solid var(--line);
1252
+ border-radius: var(--r-lg);
1253
+ overflow: hidden;
1254
+ }
1255
+ .setting {
1256
+ display: flex;
1257
+ align-items: center;
1258
+ justify-content: space-between;
1259
+ gap: 16px;
1260
+ padding: 20px 24px;
1261
+ border-bottom: 1px solid var(--line);
1262
+ flex-wrap: wrap;
1263
+ }
1264
+ .setting:last-child { border-bottom: none; }
1265
+ .setting__label {
1266
+ font-weight: 600;
1267
+ font-size: 0.95rem;
1268
+ margin-bottom: 4px;
1269
+ }
1270
+ .setting__hint {
1271
+ color: var(--text-mute);
1272
+ font-size: 0.82rem;
1273
+ }
1274
+ .setting__value {
1275
+ color: var(--text-soft);
1276
+ flex-shrink: 0;
1277
+ }
1278
+
1279
+ /* =============================================================
1280
+ ERROR STACK
1281
+ ============================================================= */
1282
+ .error-stack {
1283
+ padding: 0 32px;
1284
+ max-width: 1400px;
1285
+ margin: 0 auto;
1286
+ }
1287
+ .error-stack:empty { display: none; }
1288
+ .error-banner {
1289
+ display: flex;
1290
+ align-items: flex-start;
1291
+ gap: 12px;
1292
+ padding: 14px 16px;
1293
+ background: var(--danger-soft);
1294
+ border: 1px solid rgba(248, 113, 113, 0.3);
1295
+ border-radius: var(--r);
1296
+ margin-top: 16px;
1297
+ color: var(--text);
1298
+ }
1299
+ .error-banner__tag {
1300
+ font-family: 'JetBrains Mono', monospace;
1301
+ font-size: 0.6rem;
1302
+ letter-spacing: 0.1em;
1303
+ text-transform: uppercase;
1304
+ background: var(--danger);
1305
+ color: #fff;
1306
+ padding: 3px 8px;
1307
+ border-radius: 6px;
1308
+ flex-shrink: 0;
1309
+ }
1310
+ .error-banner__msg {
1311
+ font-size: 0.85rem;
1312
+ word-break: break-word;
1313
+ color: var(--text-soft);
1314
+ }
1315
+
1316
+ /* =============================================================
1317
+ EMPTY STATE
1318
+ ============================================================= */
1319
+ .empty {
1320
+ padding: 60px 20px;
1321
+ text-align: center;
1322
+ color: var(--text-dim);
1323
+ background: var(--surface-2);
1324
+ border-radius: var(--r);
1325
+ }
1326
+
1327
+ /* =============================================================
1328
+ BOTTOM NAV (mobile)
1329
+ ============================================================= */
1330
+ .bottomnav {
1331
+ display: none;
1332
+ position: fixed;
1333
+ bottom: 0; left: 0; right: 0;
1334
+ z-index: 50;
1335
+ background: rgba(7, 7, 11, 0.85);
1336
+ backdrop-filter: blur(20px) saturate(160%);
1337
+ -webkit-backdrop-filter: blur(20px) saturate(160%);
1338
+ border-top: 1px solid var(--line);
1339
+ padding: 8px 8px calc(8px + var(--sa-b));
1340
+ justify-content: space-around;
1341
+ }
1342
+ .bottomnav__item {
1343
+ flex: 1;
1344
+ display: flex;
1345
+ flex-direction: column;
1346
+ align-items: center;
1347
+ gap: 4px;
1348
+ padding: 8px 4px;
1349
+ background: transparent;
1350
+ border: none;
1351
+ color: var(--text-mute);
1352
+ font-size: 0.62rem;
1353
+ font-weight: 500;
1354
+ letter-spacing: 0.02em;
1355
+ border-radius: 10px;
1356
+ transition: color 150ms ease, background 150ms ease;
1357
+ }
1358
+ .bottomnav__item svg { width: 22px; height: 22px; transition: transform 150ms ease; }
1359
+ .bottomnav__item.active {
1360
+ color: var(--accent);
1361
+ }
1362
+ .bottomnav__item.active svg { transform: translateY(-1px); }
1363
+ .bottomnav__item:active { background: var(--surface); }
1364
+
1365
+ /* =============================================================
1366
+ TOASTS
1367
+ ============================================================= */
1368
+ .toasts {
1369
+ position: fixed;
1370
+ top: calc(20px + var(--sa-t));
1371
+ right: 20px;
1372
+ z-index: 200;
1373
+ display: flex;
1374
+ flex-direction: column;
1375
+ gap: 10px;
1376
+ pointer-events: none;
1377
+ }
1378
+ .toast {
1379
+ pointer-events: auto;
1380
+ display: flex;
1381
+ align-items: center;
1382
+ gap: 10px;
1383
+ padding: 12px 16px;
1384
+ background: var(--surface);
1385
+ border: 1px solid var(--line-2);
1386
+ border-radius: var(--r);
1387
+ box-shadow: var(--shadow);
1388
+ color: var(--text);
1389
+ font-size: 0.88rem;
1390
+ max-width: 360px;
1391
+ animation: toastIn 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
1392
+ }
1393
+ .toast--success { border-color: rgba(52, 211, 153, 0.3); }
1394
+ .toast--success .toast__icon { color: var(--success); }
1395
+ .toast--error { border-color: rgba(248, 113, 113, 0.3); }
1396
+ .toast--error .toast__icon { color: var(--danger); }
1397
+ .toast__icon { width: 18px; height: 18px; flex-shrink: 0; }
1398
+ .toast.out { animation: toastOut 200ms ease forwards; }
1399
+ @keyframes toastIn {
1400
+ from { opacity: 0; transform: translateX(20px); }
1401
+ to { opacity: 1; transform: translateX(0); }
1402
+ }
1403
+ @keyframes toastOut {
1404
+ to { opacity: 0; transform: translateX(20px); }
1405
+ }
1406
+
1407
+ /* =============================================================
1408
+ FLASH PULSE
1409
+ ============================================================= */
1410
+ @keyframes flash {
1411
+ 0% { box-shadow: 0 0 0 0 var(--accent-glow); }
1412
+ 100% { box-shadow: 0 0 0 14px transparent; }
1413
+ }
1414
+ .flash { animation: flash 700ms ease-out; }
1415
+
1416
+ /* =============================================================
1417
+ RESPONSIVE
1418
+ ============================================================= */
1419
+ @media (max-width: 1100px) {
1420
+ .kpi-grid { grid-template-columns: repeat(2, 1fr); }
1421
+ .split { grid-template-columns: 1fr; }
1422
+ }
1423
+
1424
+ @media (max-width: 860px) {
1425
+ .app { grid-template-columns: 1fr; }
1426
+ .sidebar { display: none; }
1427
+ .bottomnav { display: flex; }
1428
+ .icon-btn--mobile { display: grid; }
1429
+ .view { padding: 20px 16px 32px; }
1430
+ .error-stack { padding: 0 16px; }
1431
+ .topbar { padding: 12px 16px; padding-top: calc(12px + var(--sa-t)); }
1432
+ .view__head { margin-bottom: 20px; }
1433
+ .card { padding: 18px; border-radius: var(--r); }
1434
+ .toolbar { flex-direction: column; align-items: stretch; }
1435
+ .seg { width: 100%; }
1436
+ .seg__btn { flex: 1; text-align: center; }
1437
+ .kpi { padding: 16px; }
1438
+ .kpi__value { font-size: 1.6rem; }
1439
+
1440
+ /* Cards-as-rows for users on mobile */
1441
+ .users-wrap { background: transparent; border: none; }
1442
+ .dataset thead { display: none; }
1443
+ .dataset, .dataset tbody, .dataset tr, .dataset td { display: block; width: 100%; }
1444
+ .dataset tbody tr {
1445
+ background: var(--surface);
1446
+ border: 1px solid var(--line);
1447
+ border-radius: var(--r);
1448
+ padding: 14px 16px;
1449
+ margin-bottom: 10px;
1450
+ }
1451
+ .dataset tbody td {
1452
+ padding: 6px 0;
1453
+ border: none;
1454
+ display: flex;
1455
+ justify-content: space-between;
1456
+ align-items: center;
1457
+ gap: 12px;
1458
+ }
1459
+ .dataset tbody td::before {
1460
+ content: attr(data-label);
1461
+ font-family: 'JetBrains Mono', monospace;
1462
+ font-size: 0.6rem;
1463
+ letter-spacing: 0.1em;
1464
+ text-transform: uppercase;
1465
+ color: var(--text-mute);
1466
+ flex-shrink: 0;
1467
+ }
1468
+ .dataset tbody td.cell-user-wrap::before { display: none; }
1469
+ .cell-user__name { max-width: none; }
1470
+ }
1471
+
1472
+ @media (max-width: 480px) {
1473
+ .kpi-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
1474
+ .kpi { padding: 14px; }
1475
+ .kpi__icon { width: 32px; height: 32px; margin-bottom: 10px; }
1476
+ .kpi__value { font-size: 1.4rem; }
1477
+ .view__title { font-size: 1.6rem; }
1478
+ .lock__card { padding: 32px 24px; }
1479
+ .lock__title { font-size: 1.4rem; }
1480
+ .chart { height: 160px; }
1481
+ .cards-grid { grid-template-columns: 1fr; }
1482
+ .toasts { left: 16px; right: 16px; }
1483
+ .toast { max-width: none; }
1484
+ }
1485
+
1486
+ /* Reduce motion */
1487
+ @media (prefers-reduced-motion: reduce) {
1488
+ *, *::before, *::after {
1489
+ animation-duration: 0.01ms !important;
1490
+ animation-iteration-count: 1 !important;
1491
+ transition-duration: 0.01ms !important;
1492
+ }
1493
+ }
static/js/dashboard.js ADDED
@@ -0,0 +1,705 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =============================================================
2
+ ServerClass Admin v2 — Client
3
+ ============================================================= */
4
+
5
+ (() => {
6
+ "use strict";
7
+
8
+ // -----------------------------------------------------------
9
+ // DOM helpers
10
+ // -----------------------------------------------------------
11
+ const $ = (id) => document.getElementById(id);
12
+ const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
13
+
14
+ const escapeHtml = (s) => {
15
+ if (s === null || s === undefined) return "";
16
+ return String(s)
17
+ .replace(/&/g, "&")
18
+ .replace(/</g, "<")
19
+ .replace(/>/g, ">")
20
+ .replace(/"/g, "&quot;")
21
+ .replace(/'/g, "&#039;");
22
+ };
23
+
24
+ const fmtDate = (iso) => {
25
+ if (!iso) return "—";
26
+ try {
27
+ const d = new Date(iso);
28
+ if (isNaN(d)) return "—";
29
+ return d.toLocaleString(undefined, {
30
+ year: "numeric", month: "short", day: "2-digit",
31
+ hour: "2-digit", minute: "2-digit"
32
+ });
33
+ } catch { return "—"; }
34
+ };
35
+
36
+ const fmtRelative = (iso) => {
37
+ if (!iso) return "—";
38
+ const d = new Date(iso);
39
+ if (isNaN(d)) return "—";
40
+ const diff = (Date.now() - d.getTime()) / 1000;
41
+ if (diff < 60) return "just now";
42
+ if (diff < 3600) return `${Math.floor(diff/60)}m ago`;
43
+ if (diff < 86400) return `${Math.floor(diff/3600)}h ago`;
44
+ if (diff < 86400*7) return `${Math.floor(diff/86400)}d ago`;
45
+ return d.toLocaleDateString(undefined, { month: "short", day: "2-digit" });
46
+ };
47
+
48
+ const fmtTime = (ts) => {
49
+ const d = ts ? new Date(ts * 1000) : new Date();
50
+ return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
51
+ };
52
+
53
+ const initial = (s) => (s || "?").trim().charAt(0).toUpperCase();
54
+
55
+ const flash = (el) => {
56
+ if (!el) return;
57
+ el.classList.remove("flash");
58
+ void el.offsetWidth;
59
+ el.classList.add("flash");
60
+ };
61
+
62
+ // -----------------------------------------------------------
63
+ // Toasts
64
+ // -----------------------------------------------------------
65
+ const toastContainer = $("toasts");
66
+ const toast = (msg, type = "info", ms = 3200) => {
67
+ if (!toastContainer) return;
68
+ const el = document.createElement("div");
69
+ el.className = `toast toast--${type}`;
70
+ const icon = type === "success"
71
+ ? `<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>`
72
+ : type === "error"
73
+ ? `<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`
74
+ : `<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`;
75
+ el.innerHTML = `${icon}<span>${escapeHtml(msg)}</span>`;
76
+ toastContainer.appendChild(el);
77
+ setTimeout(() => {
78
+ el.classList.add("out");
79
+ setTimeout(() => el.remove(), 220);
80
+ }, ms);
81
+ };
82
+
83
+ // -----------------------------------------------------------
84
+ // LOCK SCREEN
85
+ // -----------------------------------------------------------
86
+ const lockScreen = $("lockScreen");
87
+ const appEl = $("app");
88
+ const unlockForm = $("unlockForm");
89
+ const masterPw = $("masterPassword");
90
+ const unlockBtn = $("unlockBtn");
91
+ const lockError = $("lockError");
92
+ const togglePw = $("togglePw");
93
+
94
+ const showApp = () => {
95
+ lockScreen.classList.add("lock--hidden");
96
+ appEl.classList.remove("app--hidden");
97
+ document.body.dataset.unlocked = "true";
98
+ // Connect socket only after unlock to avoid extra noise
99
+ initSocket();
100
+ setTimeout(() => { try { masterPw.value = ""; } catch {} }, 400);
101
+ };
102
+
103
+ const showLock = () => {
104
+ lockScreen.classList.remove("lock--hidden");
105
+ appEl.classList.add("app--hidden");
106
+ document.body.dataset.unlocked = "false";
107
+ if (socket) { try { socket.disconnect(); } catch {} }
108
+ };
109
+
110
+ togglePw?.addEventListener("click", () => {
111
+ if (masterPw.type === "password") {
112
+ masterPw.type = "text";
113
+ togglePw.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`;
114
+ } else {
115
+ masterPw.type = "password";
116
+ togglePw.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`;
117
+ }
118
+ masterPw.focus();
119
+ });
120
+
121
+ unlockForm?.addEventListener("submit", async (e) => {
122
+ e.preventDefault();
123
+ const pw = (masterPw.value || "").trim();
124
+ if (!pw) return;
125
+ lockError.textContent = "";
126
+ unlockBtn.disabled = true;
127
+ const labelEl = unlockBtn.querySelector(".lock__submit-label");
128
+ const original = labelEl ? labelEl.textContent : "Unlock";
129
+ if (labelEl) labelEl.textContent = "Unlocking…";
130
+
131
+ try {
132
+ const res = await fetch("/api/unlock", {
133
+ method: "POST",
134
+ headers: { "Content-Type": "application/json" },
135
+ body: JSON.stringify({ password: pw }),
136
+ credentials: "same-origin",
137
+ });
138
+ const data = await res.json().catch(() => ({}));
139
+ if (res.ok && data.ok) {
140
+ if (labelEl) labelEl.textContent = "Welcome";
141
+ toast("Dashboard unlocked", "success", 1800);
142
+ setTimeout(showApp, 250);
143
+ } else {
144
+ lockError.textContent = data.error || "Invalid password";
145
+ lockError.classList.remove("shake");
146
+ void lockError.offsetWidth;
147
+ lockError.classList.add("shake");
148
+ masterPw.value = "";
149
+ masterPw.focus();
150
+ }
151
+ } catch (err) {
152
+ lockError.textContent = "Network error — try again";
153
+ } finally {
154
+ setTimeout(() => {
155
+ unlockBtn.disabled = false;
156
+ if (labelEl) labelEl.textContent = original;
157
+ }, 350);
158
+ }
159
+ });
160
+
161
+ // Focus password field on load if locked
162
+ if (document.body.dataset.unlocked !== "true") {
163
+ setTimeout(() => masterPw?.focus(), 200);
164
+ }
165
+
166
+ // Lock buttons
167
+ const doLock = async () => {
168
+ try {
169
+ await fetch("/api/lock", { method: "POST", credentials: "same-origin" });
170
+ } catch {}
171
+ toast("Dashboard locked", "info", 1500);
172
+ showLock();
173
+ setTimeout(() => masterPw?.focus(), 300);
174
+ };
175
+ $("lockBtn")?.addEventListener("click", doLock);
176
+ $("lockBtnMobile")?.addEventListener("click", doLock);
177
+ $("lockBtnSettings")?.addEventListener("click", doLock);
178
+
179
+ // -----------------------------------------------------------
180
+ // VIEW ROUTER
181
+ // -----------------------------------------------------------
182
+ const VIEW_TITLES = {
183
+ overview: "Overview",
184
+ servers: "Servers",
185
+ users: "Users",
186
+ settings: "Settings",
187
+ };
188
+ const topbarTitle = $("topbarTitle");
189
+
190
+ const setView = (name) => {
191
+ $$(".view").forEach((v) => v.classList.toggle("view--active", v.dataset.view === name));
192
+ $$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
193
+ $$(".bottomnav__item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
194
+ if (topbarTitle) topbarTitle.textContent = VIEW_TITLES[name] || "Dashboard";
195
+ if (history.replaceState) history.replaceState(null, "", `#${name}`);
196
+ window.scrollTo({ top: 0, behavior: "smooth" });
197
+ };
198
+
199
+ $$(".nav-item, .bottomnav__item").forEach((btn) => {
200
+ btn.addEventListener("click", () => setView(btn.dataset.view));
201
+ });
202
+ $$("[data-jump]").forEach((btn) => {
203
+ btn.addEventListener("click", () => setView(btn.dataset.jump));
204
+ });
205
+
206
+ // Initial route from hash
207
+ const initialHash = (location.hash || "").replace("#", "");
208
+ if (VIEW_TITLES[initialHash]) setView(initialHash);
209
+
210
+ // -----------------------------------------------------------
211
+ // STATE
212
+ // -----------------------------------------------------------
213
+ let allUsers = [];
214
+ let allServers = [];
215
+ let lastStatus = null;
216
+ let lastTotal = 0;
217
+
218
+ let userSort = { key: "created_at", dir: "desc" };
219
+ let serverFilter = "all";
220
+
221
+ // -----------------------------------------------------------
222
+ // CONN INDICATORS
223
+ // -----------------------------------------------------------
224
+ const connPill = $("connPill");
225
+ const connLabel = $("connLabel");
226
+ const connPillMini = $("connPillMini");
227
+ const connLabelMini = $("connLabelMini");
228
+ const lastSync = $("lastSync");
229
+
230
+ const setConn = (state, label) => {
231
+ if (connPill) connPill.dataset.state = state;
232
+ if (connPillMini) connPillMini.dataset.state = state;
233
+ if (connLabel) connLabel.textContent = label;
234
+ if (connLabelMini) connLabelMini.textContent = label;
235
+ };
236
+
237
+ // -----------------------------------------------------------
238
+ // ERRORS
239
+ // -----------------------------------------------------------
240
+ const errorBox = $("errorBox");
241
+ const renderErrors = (errors) => {
242
+ if (!errorBox) return;
243
+ if (!errors || Object.keys(errors).length === 0) {
244
+ errorBox.innerHTML = "";
245
+ return;
246
+ }
247
+ errorBox.innerHTML = Object.entries(errors).map(([k, v]) => `
248
+ <div class="error-banner">
249
+ <span class="error-banner__tag">${escapeHtml(k)}</span>
250
+ <span class="error-banner__msg">${escapeHtml(v)}</span>
251
+ </div>
252
+ `).join("");
253
+ };
254
+
255
+ // -----------------------------------------------------------
256
+ // RENDER: KPIs
257
+ // -----------------------------------------------------------
258
+ const renderKpis = (status, total) => {
259
+ $("kpiTotalUsers").textContent = total ?? "—";
260
+
261
+ if (status) {
262
+ const cap = (status.total_servers || 0) * (status.max_per_server || 0);
263
+ const pct = cap > 0 ? Math.round(((total || 0) / cap) * 100) : 0;
264
+
265
+ $("kpiServers").textContent = status.total_servers ?? "—";
266
+ $("kpiServersSub").textContent = `Max ${status.max_per_server ?? "—"} per node`;
267
+
268
+ $("kpiReservations").textContent = status.total_reservations ?? "—";
269
+ $("kpiReservationsSub").textContent = "Pending registrations";
270
+
271
+ $("kpiCapacity").innerHTML = `${pct}<span class="kpi__value-suffix">%</span>`;
272
+ $("kpiCapacitySub").textContent = `${total || 0} / ${cap}`;
273
+ const bar = $("kpiCapacityBar");
274
+ if (bar) bar.style.width = `${Math.min(pct, 100)}%`;
275
+
276
+ // Sidebar counts
277
+ const nServ = $("navServersCount"); if (nServ) nServ.textContent = status.total_servers ?? "—";
278
+ } else {
279
+ $("kpiServers").textContent = "—";
280
+ $("kpiReservations").textContent = "—";
281
+ $("kpiCapacity").innerHTML = `—<span class="kpi__value-suffix"></span>`;
282
+ }
283
+
284
+ const nUsers = $("navUsersCount");
285
+ if (nUsers) nUsers.textContent = total ?? "—";
286
+
287
+ flash($("kpiGrid"));
288
+ };
289
+
290
+ // -----------------------------------------------------------
291
+ // RENDER: LOAD CHART
292
+ // -----------------------------------------------------------
293
+ const renderChart = (servers) => {
294
+ const chart = $("loadChart");
295
+ if (!chart) return;
296
+ if (!servers || !servers.length) {
297
+ chart.innerHTML = `<div class="chart__empty mono">Awaiting data…</div>`;
298
+ return;
299
+ }
300
+ chart.innerHTML = servers.map((s) => {
301
+ const pct = s.max > 0 ? (s.effective / s.max) * 100 : 0;
302
+ const state = pct >= 100 ? "full" : pct >= 75 ? "warn" : "ok";
303
+ const h = Math.max(Math.min(pct, 100), 2);
304
+ return `
305
+ <div class="chart__bar" title="Server ${escapeHtml(String(s.server_num))}: ${s.effective}/${s.max}">
306
+ <div class="chart__bar-track">
307
+ <div class="chart__bar-fill" data-state="${state}" data-value="${s.effective}/${s.max}" style="height:${h}%"></div>
308
+ </div>
309
+ <div class="chart__bar-label">${String(s.server_num).padStart(2, "0")}</div>
310
+ </div>
311
+ `;
312
+ }).join("");
313
+ };
314
+
315
+ // -----------------------------------------------------------
316
+ // RENDER: RECENT USERS + TOP SERVERS
317
+ // -----------------------------------------------------------
318
+ const renderRecent = (users) => {
319
+ const box = $("recentUsers");
320
+ if (!box) return;
321
+ if (!users || !users.length) {
322
+ box.innerHTML = `<div class="recent__empty mono">No users yet.</div>`;
323
+ return;
324
+ }
325
+ const sorted = [...users].sort((a, b) => {
326
+ const da = new Date(a.created_at || a.last_login || 0).getTime();
327
+ const db = new Date(b.created_at || b.last_login || 0).getTime();
328
+ return db - da;
329
+ }).slice(0, 5);
330
+
331
+ box.innerHTML = sorted.map((u) => `
332
+ <div class="recent-row">
333
+ <div class="recent-row__avatar">${escapeHtml(initial(u.username))}</div>
334
+ <div class="recent-row__body">
335
+ <div class="recent-row__name">${escapeHtml(u.username || "—")}</div>
336
+ <div class="recent-row__meta">${escapeHtml(u.telegram_id || "no telegram")} · ${escapeHtml(fmtRelative(u.created_at || u.last_login))}</div>
337
+ </div>
338
+ <span class="recent-row__tag">S${escapeHtml(String(u.server_num ?? "?"))}</span>
339
+ </div>
340
+ `).join("");
341
+ };
342
+
343
+ const renderTopServers = (servers) => {
344
+ const box = $("topServers");
345
+ if (!box) return;
346
+ if (!servers || !servers.length) {
347
+ box.innerHTML = `<div class="recent__empty mono">No data yet.</div>`;
348
+ return;
349
+ }
350
+ const sorted = [...servers].sort((a, b) => {
351
+ const pa = a.max > 0 ? a.effective / a.max : 0;
352
+ const pb = b.max > 0 ? b.effective / b.max : 0;
353
+ return pb - pa;
354
+ }).slice(0, 5);
355
+
356
+ box.innerHTML = sorted.map((s) => {
357
+ const pct = s.max > 0 ? (s.effective / s.max) * 100 : 0;
358
+ const state = pct >= 100 ? "full" : pct >= 75 ? "warn" : "ok";
359
+ return `
360
+ <div class="recent-row recent-row--server">
361
+ <div class="recent-row__avatar">${String(s.server_num).padStart(2, "0")}</div>
362
+ <div class="recent-row__body">
363
+ <div class="recent-row__name">Server ${escapeHtml(String(s.server_num))}</div>
364
+ <div class="recent-row__meta">${s.effective} / ${s.max} · ${Math.round(pct)}%</div>
365
+ <div class="recent-row__bar"><div class="recent-row__bar-fill" data-state="${state}" style="width:${Math.min(pct, 100)}%"></div></div>
366
+ </div>
367
+ <span class="recent-row__tag">${s.available ? "Open" : "Full"}</span>
368
+ </div>
369
+ `;
370
+ }).join("");
371
+ };
372
+
373
+ // -----------------------------------------------------------
374
+ // RENDER: SERVERS GRID
375
+ // -----------------------------------------------------------
376
+ const renderServers = () => {
377
+ const box = $("serversContent");
378
+ const counter = $("serverCount");
379
+ if (!box) return;
380
+
381
+ let list = allServers || [];
382
+ const q = (($("serverSearch")?.value) || "").trim().toLowerCase();
383
+ if (q) {
384
+ list = list.filter((s) =>
385
+ String(s.server_num).includes(q) ||
386
+ (s.url || "").toLowerCase().includes(q) ||
387
+ (s.available ? "open" : "full").includes(q)
388
+ );
389
+ }
390
+ if (serverFilter === "open") list = list.filter((s) => s.available);
391
+ if (serverFilter === "full") list = list.filter((s) => !s.available);
392
+
393
+ if (counter) counter.textContent = `${list.length} node${list.length === 1 ? "" : "s"}`;
394
+
395
+ if (!list.length) {
396
+ box.innerHTML = `<div class="empty mono">No servers match current filter.</div>`;
397
+ return;
398
+ }
399
+
400
+ box.innerHTML = list.map((s) => {
401
+ const pct = s.max > 0 ? (s.effective / s.max) * 100 : 0;
402
+ const state = pct >= 100 ? "full" : pct >= 75 ? "warn" : "ok";
403
+ const pillClass = state === "full" ? "pill--full" : state === "warn" ? "pill--warn" : "pill--ok";
404
+ const pillLabel = state === "full" ? "Full" : state === "warn" ? "High Load" : "Healthy";
405
+
406
+ return `
407
+ <div class="server-card">
408
+ <div class="server-card__head">
409
+ <div>
410
+ <div class="server-card__num">NODE № ${String(s.server_num).padStart(2, "0")}</div>
411
+ <div class="server-card__name">Server ${escapeHtml(String(s.server_num))}</div>
412
+ </div>
413
+ <span class="pill ${pillClass}"><span class="pill__dot"></span>${pillLabel}</span>
414
+ </div>
415
+
416
+ <div class="server-card__metrics">
417
+ <div class="metric">
418
+ <div class="metric__label">Users</div>
419
+ <div class="metric__value">${s.users ?? 0}</div>
420
+ </div>
421
+ <div class="metric">
422
+ <div class="metric__label">Reserved</div>
423
+ <div class="metric__value">${s.reserved ?? 0}</div>
424
+ </div>
425
+ </div>
426
+
427
+ <div class="server-card__cap">
428
+ <div class="server-card__cap-head">
429
+ <span class="server-card__cap-label">Capacity</span>
430
+ <span class="server-card__cap-value">${s.effective ?? 0} / ${s.max ?? 0} · ${Math.round(pct)}%</span>
431
+ </div>
432
+ <div class="server-card__bar">
433
+ <div class="server-card__bar-fill" data-state="${state}" style="width:${Math.min(pct, 100)}%"></div>
434
+ </div>
435
+ </div>
436
+
437
+ <div class="server-card__foot">
438
+ <span class="server-card__url" title="${escapeHtml(s.url || "")}">${escapeHtml(s.url || "—")}</span>
439
+ <span class="pill pill--neutral"><span class="pill__dot"></span>${s.available ? "Open" : "Full"}</span>
440
+ </div>
441
+ </div>
442
+ `;
443
+ }).join("");
444
+ };
445
+
446
+ // Server filter / search bindings
447
+ $("serverSearch")?.addEventListener("input", renderServers);
448
+ $$("#serverFilter .seg__btn").forEach((b) => {
449
+ b.addEventListener("click", () => {
450
+ $$("#serverFilter .seg__btn").forEach((x) => x.classList.toggle("active", x === b));
451
+ serverFilter = b.dataset.filter;
452
+ renderServers();
453
+ });
454
+ });
455
+
456
+ // -----------------------------------------------------------
457
+ // RENDER: USERS TABLE
458
+ // -----------------------------------------------------------
459
+ const sortUsers = (users) => {
460
+ const { key, dir } = userSort;
461
+ const mult = dir === "asc" ? 1 : -1;
462
+ const get = (u) => {
463
+ switch (key) {
464
+ case "username": return (u.username || "").toLowerCase();
465
+ case "telegram_id": return (u.telegram_id || "").toLowerCase();
466
+ case "server_num": return Number(u.server_num) || 0;
467
+ case "tokens_count": return Number(u.tokens_count) || 0;
468
+ case "last_login": return new Date(u.last_login || 0).getTime();
469
+ case "created_at":
470
+ default: return new Date(u.created_at || 0).getTime();
471
+ }
472
+ };
473
+ return [...users].sort((a, b) => {
474
+ const va = get(a), vb = get(b);
475
+ if (va < vb) return -1 * mult;
476
+ if (va > vb) return 1 * mult;
477
+ return 0;
478
+ });
479
+ };
480
+
481
+ const renderUsers = () => {
482
+ const box = $("usersContent");
483
+ const counter = $("userCount");
484
+ if (!box) return;
485
+
486
+ let list = allUsers || [];
487
+ const q = (($("userSearch")?.value) || "").trim().toLowerCase();
488
+ if (q) {
489
+ list = list.filter((u) =>
490
+ (u.username || "").toLowerCase().includes(q) ||
491
+ (u.telegram_id || "").toLowerCase().includes(q) ||
492
+ String(u.server_num || "").includes(q)
493
+ );
494
+ }
495
+ list = sortUsers(list);
496
+
497
+ if (counter) counter.textContent = `${list.length} record${list.length === 1 ? "" : "s"}`;
498
+
499
+ if (!list.length) {
500
+ box.innerHTML = `<div class="empty mono">No users match current filter.</div>`;
501
+ return;
502
+ }
503
+
504
+ const sortInd = (key) => {
505
+ if (userSort.key !== key) return `<span class="sort-ind">↕</span>`;
506
+ return userSort.dir === "asc"
507
+ ? `<span class="sort-ind">↑</span>`
508
+ : `<span class="sort-ind">↓</span>`;
509
+ };
510
+ const sortClass = (key) =>
511
+ userSort.key === key ? (userSort.dir === "asc" ? "sortable sort-asc" : "sortable sort-desc") : "sortable";
512
+
513
+ const rows = list.map((u, i) => {
514
+ const tokens = u.tokens_count || 0;
515
+ const tokensPill = tokens > 0
516
+ ? `<span class="pill pill--ok"><span class="pill__dot"></span>${tokens} token${tokens > 1 ? "s" : ""}</span>`
517
+ : `<span class="pill pill--neutral"><span class="pill__dot"></span>None</span>`;
518
+
519
+ return `
520
+ <tr>
521
+ <td class="cell-idx" data-label="#">${String(i + 1).padStart(3, "0")}</td>
522
+ <td class="cell-user-wrap" data-label="User">
523
+ <div class="cell-user">
524
+ <div class="cell-user__avatar">${escapeHtml(initial(u.username))}</div>
525
+ <div class="cell-user__name">${escapeHtml(u.username || "—")}</div>
526
+ </div>
527
+ </td>
528
+ <td data-label="Telegram"><span class="cell-mono">${escapeHtml(u.telegram_id || "—")}</span></td>
529
+ <td data-label="Server"><span class="pill pill--neutral"><span class="pill__dot"></span>S${escapeHtml(String(u.server_num ?? "?"))}</span></td>
530
+ <td data-label="Tokens">${tokensPill}</td>
531
+ <td data-label="Created"><span class="cell-meta">${escapeHtml(fmtDate(u.created_at))}</span></td>
532
+ <td data-label="Last seen"><span class="cell-meta">${escapeHtml(fmtRelative(u.last_login))}</span></td>
533
+ </tr>
534
+ `;
535
+ }).join("");
536
+
537
+ box.innerHTML = `
538
+ <table class="dataset">
539
+ <thead>
540
+ <tr>
541
+ <th>#</th>
542
+ <th class="${sortClass('username')}" data-sort="username">User ${sortInd('username')}</th>
543
+ <th class="${sortClass('telegram_id')}" data-sort="telegram_id">Telegram ${sortInd('telegram_id')}</th>
544
+ <th class="${sortClass('server_num')}" data-sort="server_num">Server ${sortInd('server_num')}</th>
545
+ <th class="${sortClass('tokens_count')}" data-sort="tokens_count">Tokens ${sortInd('tokens_count')}</th>
546
+ <th class="${sortClass('created_at')}" data-sort="created_at">Created ${sortInd('created_at')}</th>
547
+ <th class="${sortClass('last_login')}" data-sort="last_login">Last seen ${sortInd('last_login')}</th>
548
+ </tr>
549
+ </thead>
550
+ <tbody>${rows}</tbody>
551
+ </table>
552
+ `;
553
+
554
+ // Bind sort header clicks
555
+ $$("th.sortable", box).forEach((th) => {
556
+ th.addEventListener("click", () => {
557
+ const key = th.dataset.sort;
558
+ if (userSort.key === key) {
559
+ userSort.dir = userSort.dir === "asc" ? "desc" : "asc";
560
+ } else {
561
+ userSort.key = key;
562
+ userSort.dir = (key === "username" || key === "telegram_id") ? "asc" : "desc";
563
+ }
564
+ renderUsers();
565
+ });
566
+ });
567
+ };
568
+
569
+ // Search binding
570
+ $("userSearch")?.addEventListener("input", renderUsers);
571
+
572
+ // CSV export
573
+ $("exportBtn")?.addEventListener("click", () => {
574
+ if (!allUsers || !allUsers.length) {
575
+ toast("Nothing to export", "error", 1800);
576
+ return;
577
+ }
578
+ const headers = ["username", "telegram_id", "server_num", "tokens_count", "created_at", "last_login"];
579
+ const escapeCsv = (v) => {
580
+ if (v === null || v === undefined) return "";
581
+ const s = String(v).replace(/"/g, '""');
582
+ return /[",\n]/.test(s) ? `"${s}"` : s;
583
+ };
584
+ const lines = [headers.join(",")];
585
+ allUsers.forEach((u) => {
586
+ lines.push(headers.map((h) => escapeCsv(u[h])).join(","));
587
+ });
588
+ const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" });
589
+ const url = URL.createObjectURL(blob);
590
+ const a = document.createElement("a");
591
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
592
+ a.href = url;
593
+ a.download = `serverclass-users-${stamp}.csv`;
594
+ document.body.appendChild(a);
595
+ a.click();
596
+ document.body.removeChild(a);
597
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
598
+ toast(`Exported ${allUsers.length} users`, "success", 2200);
599
+ });
600
+
601
+ // -----------------------------------------------------------
602
+ // REFRESH BUTTON
603
+ // -----------------------------------------------------------
604
+ $("refreshBtn")?.addEventListener("click", () => {
605
+ if (!socket || !socket.connected) {
606
+ toast("Not connected", "error", 1600);
607
+ return;
608
+ }
609
+ setConn("warn", "REFRESHING");
610
+ socket.emit("refresh");
611
+ });
612
+
613
+ // -----------------------------------------------------------
614
+ // SOCKET.IO
615
+ // -----------------------------------------------------------
616
+ let socket = null;
617
+
618
+ const initSocket = () => {
619
+ if (socket) {
620
+ try { socket.connect(); } catch {}
621
+ return;
622
+ }
623
+ socket = io({ transports: ["websocket", "polling"] });
624
+
625
+ socket.on("connect", () => {
626
+ setConn("on", "LIVE");
627
+ });
628
+
629
+ socket.on("connected", (data) => {
630
+ if (data && data.poll_interval) {
631
+ const el = $("pollInterval");
632
+ if (el) el.textContent = data.poll_interval;
633
+ }
634
+ if (data && data.authed === false) {
635
+ // Session expired upstream — show lock screen
636
+ toast("Session expired", "error", 2200);
637
+ showLock();
638
+ }
639
+ });
640
+
641
+ socket.on("disconnect", () => {
642
+ setConn("off", "OFFLINE");
643
+ });
644
+
645
+ socket.on("connect_error", () => {
646
+ setConn("off", "ERROR");
647
+ });
648
+
649
+ socket.on("error", (data) => {
650
+ const msg = (data && data.message) || "Unknown error";
651
+ renderErrors({ socket: msg });
652
+ toast(msg, "error", 2600);
653
+ });
654
+
655
+ socket.on("data_update", (payload) => {
656
+ if (!payload) return;
657
+ if (payload.errors) renderErrors(payload.errors);
658
+
659
+ allUsers = payload.users || [];
660
+ lastTotal = payload.total ?? allUsers.length;
661
+ lastStatus = payload.status || null;
662
+ allServers = lastStatus ? (lastStatus.servers || []) : [];
663
+
664
+ renderKpis(lastStatus, lastTotal);
665
+ renderChart(allServers);
666
+ renderRecent(allUsers);
667
+ renderTopServers(allServers);
668
+ renderServers();
669
+ renderUsers();
670
+
671
+ if (lastSync) lastSync.textContent = fmtTime(payload.timestamp);
672
+ const tag = payload.source === "auto" ? "LIVE" : payload.source === "manual" ? "SYNCED" : "LIVE";
673
+ setConn("on", tag);
674
+
675
+ if (payload.source === "manual") toast("Refreshed", "success", 1200);
676
+ });
677
+ };
678
+
679
+ // -----------------------------------------------------------
680
+ // BOOT
681
+ // -----------------------------------------------------------
682
+ if (window.__UNLOCKED__ === true) {
683
+ // Already unlocked server-side — connect immediately
684
+ initSocket();
685
+ }
686
+
687
+ // Lock on Ctrl/Cmd + L
688
+ document.addEventListener("keydown", (e) => {
689
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l") {
690
+ if (document.body.dataset.unlocked === "true") {
691
+ e.preventDefault();
692
+ doLock();
693
+ }
694
+ }
695
+ // Quick search "/"
696
+ if (e.key === "/" && document.body.dataset.unlocked === "true") {
697
+ const active = document.activeElement;
698
+ if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) return;
699
+ e.preventDefault();
700
+ const visibleView = document.querySelector(".view--active")?.dataset.view;
701
+ if (visibleView === "users") $("userSearch")?.focus();
702
+ if (visibleView === "servers") $("serverSearch")?.focus();
703
+ }
704
+ });
705
+ })();
templates/index.html ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1">
6
+ <meta name="theme-color" content="#0a0a0f">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <meta name="mobile-web-app-capable" content="yes">
10
+ <title>ServerClass · Admin Console</title>
11
+
12
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M10 12h12M10 16h12M10 20h8' stroke='%23fff' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E">
13
+
14
+ <link rel="preconnect" href="https://fonts.googleapis.com">
15
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
17
+
18
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
19
+ </head>
20
+ <body data-unlocked="{{ 'true' if unlocked else 'false' }}">
21
+
22
+ <!-- ============================================================
23
+ LOCK SCREEN
24
+ ============================================================ -->
25
+ <div id="lockScreen" class="lock {% if unlocked %}lock--hidden{% endif %}">
26
+ <div class="lock__bg"></div>
27
+ <div class="lock__card">
28
+ <div class="lock__logo">
29
+ <div class="lock__logo-mark">
30
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
31
+ <rect x="3" y="11" width="18" height="11" rx="2"/>
32
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
33
+ </svg>
34
+ </div>
35
+ <div class="lock__brand">
36
+ <div class="lock__brand-name">ServerClass</div>
37
+ <div class="lock__brand-sub">Admin Console</div>
38
+ </div>
39
+ </div>
40
+
41
+ <h1 class="lock__title">Welcome back</h1>
42
+ <p class="lock__sub">Enter your master password to unlock the dashboard.</p>
43
+
44
+ <form id="unlockForm" class="lock__form" autocomplete="off">
45
+ <div class="lock__field">
46
+ <input
47
+ type="password"
48
+ id="masterPassword"
49
+ class="lock__input"
50
+ placeholder="Master password"
51
+ autocomplete="current-password"
52
+ spellcheck="false"
53
+ required>
54
+ <button type="button" class="lock__toggle" id="togglePw" aria-label="Show password">
55
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
56
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
57
+ <circle cx="12" cy="12" r="3"/>
58
+ </svg>
59
+ </button>
60
+ </div>
61
+
62
+ <button type="submit" class="lock__submit" id="unlockBtn">
63
+ <span class="lock__submit-label">Unlock</span>
64
+ <svg class="lock__submit-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
65
+ <path d="M5 12h14M12 5l7 7-7 7"/>
66
+ </svg>
67
+ </button>
68
+
69
+ <div id="lockError" class="lock__error"></div>
70
+ </form>
71
+
72
+ <div class="lock__footer">
73
+ <span class="dot"></span>
74
+ <span class="mono">Secure · End-to-end signed session</span>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <!-- ============================================================
80
+ APP SHELL
81
+ ============================================================ -->
82
+ <div id="app" class="app {% if not unlocked %}app--hidden{% endif %}">
83
+
84
+ <!-- Sidebar (desktop) -->
85
+ <aside class="sidebar">
86
+ <div class="sidebar__brand">
87
+ <div class="sidebar__mark">
88
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
89
+ <rect x="3" y="4" width="18" height="6" rx="1.5"/>
90
+ <rect x="3" y="14" width="18" height="6" rx="1.5"/>
91
+ <circle cx="7" cy="7" r=".8" fill="currentColor"/>
92
+ <circle cx="7" cy="17" r=".8" fill="currentColor"/>
93
+ </svg>
94
+ </div>
95
+ <div class="sidebar__name">
96
+ <div class="sidebar__title">ServerClass</div>
97
+ <div class="sidebar__subtitle mono">ADMIN · v2</div>
98
+ </div>
99
+ </div>
100
+
101
+ <nav class="sidebar__nav">
102
+ <button class="nav-item active" data-view="overview">
103
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
104
+ <rect x="3" y="3" width="7" height="9" rx="1.5"/>
105
+ <rect x="14" y="3" width="7" height="5" rx="1.5"/>
106
+ <rect x="14" y="12" width="7" height="9" rx="1.5"/>
107
+ <rect x="3" y="16" width="7" height="5" rx="1.5"/>
108
+ </svg>
109
+ <span>Overview</span>
110
+ </button>
111
+ <button class="nav-item" data-view="servers">
112
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
113
+ <rect x="3" y="4" width="18" height="6" rx="1.5"/>
114
+ <rect x="3" y="14" width="18" height="6" rx="1.5"/>
115
+ <circle cx="7" cy="7" r=".8" fill="currentColor"/>
116
+ <circle cx="7" cy="17" r=".8" fill="currentColor"/>
117
+ </svg>
118
+ <span>Servers</span>
119
+ <span class="nav-item__count" id="navServersCount">—</span>
120
+ </button>
121
+ <button class="nav-item" data-view="users">
122
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
123
+ <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
124
+ <circle cx="9" cy="7" r="4"/>
125
+ <path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
126
+ <path d="M16 3.13a4 4 0 0 1 0 7.75"/>
127
+ </svg>
128
+ <span>Users</span>
129
+ <span class="nav-item__count" id="navUsersCount">—</span>
130
+ </button>
131
+ <button class="nav-item" data-view="settings">
132
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
133
+ <circle cx="12" cy="12" r="3"/>
134
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
135
+ </svg>
136
+ <span>Settings</span>
137
+ </button>
138
+ </nav>
139
+
140
+ <div class="sidebar__footer">
141
+ <div class="conn-pill" id="connPill" data-state="off">
142
+ <span class="conn-pill__dot"></span>
143
+ <span class="conn-pill__label" id="connLabel">Connecting</span>
144
+ </div>
145
+ <button class="sidebar__logout" id="lockBtn" title="Lock dashboard">
146
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
147
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
148
+ <path d="M16 17l5-5-5-5"/>
149
+ <path d="M21 12H9"/>
150
+ </svg>
151
+ </button>
152
+ </div>
153
+ </aside>
154
+
155
+ <!-- Main canvas -->
156
+ <main class="main">
157
+
158
+ <!-- Topbar (mobile) -->
159
+ <header class="topbar">
160
+ <div class="topbar__left">
161
+ <div class="topbar__brand">
162
+ <div class="topbar__mark">
163
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
164
+ <rect x="3" y="4" width="18" height="6" rx="1.5"/>
165
+ <rect x="3" y="14" width="18" height="6" rx="1.5"/>
166
+ </svg>
167
+ </div>
168
+ <div>
169
+ <div class="topbar__title" id="topbarTitle">Overview</div>
170
+ <div class="topbar__sub mono">
171
+ <span class="conn-pill conn-pill--mini" id="connPillMini" data-state="off">
172
+ <span class="conn-pill__dot"></span>
173
+ <span id="connLabelMini">…</span>
174
+ </span>
175
+ · <span id="lastSync">—</span>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ <div class="topbar__right">
181
+ <button class="icon-btn" id="refreshBtn" title="Refresh">
182
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
183
+ <path d="M3 12a9 9 0 0 1 15-6.7L21 8"/>
184
+ <path d="M21 3v5h-5"/>
185
+ <path d="M21 12a9 9 0 0 1-15 6.7L3 16"/>
186
+ <path d="M3 21v-5h5"/>
187
+ </svg>
188
+ </button>
189
+ <button class="icon-btn icon-btn--mobile" id="lockBtnMobile" title="Lock">
190
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
191
+ <rect x="3" y="11" width="18" height="11" rx="2"/>
192
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
193
+ </svg>
194
+ </button>
195
+ </div>
196
+ </header>
197
+
198
+ <!-- Error banner -->
199
+ <div id="errorBox" class="error-stack"></div>
200
+
201
+ <!-- ===== VIEW: OVERVIEW ===== -->
202
+ <section class="view view--active" data-view="overview">
203
+
204
+ <div class="view__head">
205
+ <div>
206
+ <div class="eyebrow mono">DASHBOARD</div>
207
+ <h1 class="view__title">Overview</h1>
208
+ <p class="view__sub">Real-time snapshot of your distributed authentication mesh.</p>
209
+ </div>
210
+ <div class="view__head-meta">
211
+ <div class="live-indicator">
212
+ <span class="live-indicator__pulse"></span>
213
+ <span class="mono">LIVE</span>
214
+ </div>
215
+ </div>
216
+ </div>
217
+
218
+ <!-- KPI cards -->
219
+ <div class="kpi-grid" id="kpiGrid">
220
+ <div class="kpi kpi--accent">
221
+ <div class="kpi__icon">
222
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
223
+ <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
224
+ <circle cx="9" cy="7" r="4"/>
225
+ <path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
226
+ <path d="M16 3.13a4 4 0 0 1 0 7.75"/>
227
+ </svg>
228
+ </div>
229
+ <div class="kpi__label mono">TOTAL USERS</div>
230
+ <div class="kpi__value" id="kpiTotalUsers">—</div>
231
+ <div class="kpi__trend mono"><span id="kpiTotalUsersSub">Registered accounts</span></div>
232
+ </div>
233
+
234
+ <div class="kpi">
235
+ <div class="kpi__icon">
236
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
237
+ <rect x="3" y="4" width="18" height="6" rx="1.5"/>
238
+ <rect x="3" y="14" width="18" height="6" rx="1.5"/>
239
+ <circle cx="7" cy="7" r=".8" fill="currentColor"/>
240
+ <circle cx="7" cy="17" r=".8" fill="currentColor"/>
241
+ </svg>
242
+ </div>
243
+ <div class="kpi__label mono">SERVERS</div>
244
+ <div class="kpi__value" id="kpiServers">—</div>
245
+ <div class="kpi__trend mono"><span id="kpiServersSub">Online nodes</span></div>
246
+ </div>
247
+
248
+ <div class="kpi">
249
+ <div class="kpi__icon">
250
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
251
+ <path d="M12 8v4l3 3"/>
252
+ <circle cx="12" cy="12" r="10"/>
253
+ </svg>
254
+ </div>
255
+ <div class="kpi__label mono">RESERVATIONS</div>
256
+ <div class="kpi__value" id="kpiReservations">—</div>
257
+ <div class="kpi__trend mono"><span id="kpiReservationsSub">Pending registrations</span></div>
258
+ </div>
259
+
260
+ <div class="kpi">
261
+ <div class="kpi__icon">
262
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
263
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
264
+ </svg>
265
+ </div>
266
+ <div class="kpi__label mono">CAPACITY</div>
267
+ <div class="kpi__value" id="kpiCapacity">—<span class="kpi__value-suffix">%</span></div>
268
+ <div class="kpi__bar">
269
+ <div class="kpi__bar-fill" id="kpiCapacityBar" style="width:0%"></div>
270
+ </div>
271
+ <div class="kpi__trend mono"><span id="kpiCapacitySub">— / —</span></div>
272
+ </div>
273
+ </div>
274
+
275
+ <!-- Capacity chart -->
276
+ <div class="card card--chart">
277
+ <div class="card__head">
278
+ <div>
279
+ <div class="card__eyebrow mono">DISTRIBUTION</div>
280
+ <h2 class="card__title">Server Load</h2>
281
+ </div>
282
+ <div class="card__legend">
283
+ <span class="legend"><i class="legend__sw legend__sw--ok"></i>Healthy</span>
284
+ <span class="legend"><i class="legend__sw legend__sw--warn"></i>High</span>
285
+ <span class="legend"><i class="legend__sw legend__sw--full"></i>Full</span>
286
+ </div>
287
+ </div>
288
+ <div class="chart" id="loadChart">
289
+ <div class="chart__empty mono">Awaiting data…</div>
290
+ </div>
291
+ </div>
292
+
293
+ <!-- Recent users + top servers -->
294
+ <div class="split">
295
+ <div class="card">
296
+ <div class="card__head">
297
+ <div>
298
+ <div class="card__eyebrow mono">RECENT</div>
299
+ <h2 class="card__title">Latest Users</h2>
300
+ </div>
301
+ <button class="link-btn" data-jump="users">View all →</button>
302
+ </div>
303
+ <div id="recentUsers" class="recent">
304
+ <div class="recent__empty mono">No users yet.</div>
305
+ </div>
306
+ </div>
307
+
308
+ <div class="card">
309
+ <div class="card__head">
310
+ <div>
311
+ <div class="card__eyebrow mono">TOP LOAD</div>
312
+ <h2 class="card__title">Busiest Servers</h2>
313
+ </div>
314
+ <button class="link-btn" data-jump="servers">View all →</button>
315
+ </div>
316
+ <div id="topServers" class="recent">
317
+ <div class="recent__empty mono">No data yet.</div>
318
+ </div>
319
+ </div>
320
+ </div>
321
+
322
+ </section>
323
+
324
+ <!-- ===== VIEW: SERVERS ===== -->
325
+ <section class="view" data-view="servers">
326
+ <div class="view__head">
327
+ <div>
328
+ <div class="eyebrow mono">INFRASTRUCTURE</div>
329
+ <h1 class="view__title">Servers</h1>
330
+ <p class="view__sub">Live capacity and health across every node.</p>
331
+ </div>
332
+ </div>
333
+
334
+ <div class="toolbar">
335
+ <div class="search">
336
+ <svg class="search__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
337
+ <circle cx="11" cy="11" r="8"/>
338
+ <path d="m21 21-4.35-4.35"/>
339
+ </svg>
340
+ <input type="text" id="serverSearch" class="search__input" placeholder="Filter servers by number, URL, or status…">
341
+ <span class="search__hint mono" id="serverCount">— nodes</span>
342
+ </div>
343
+ <div class="seg" id="serverFilter">
344
+ <button data-filter="all" class="seg__btn active">All</button>
345
+ <button data-filter="open" class="seg__btn">Open</button>
346
+ <button data-filter="full" class="seg__btn">Full</button>
347
+ </div>
348
+ </div>
349
+
350
+ <div id="serversContent" class="cards-grid">
351
+ <div class="empty mono">Loading servers…</div>
352
+ </div>
353
+ </section>
354
+
355
+ <!-- ===== VIEW: USERS ===== -->
356
+ <section class="view" data-view="users">
357
+ <div class="view__head">
358
+ <div>
359
+ <div class="eyebrow mono">DIRECTORY</div>
360
+ <h1 class="view__title">Users</h1>
361
+ <p class="view__sub">Every account, every token, every login.</p>
362
+ </div>
363
+ </div>
364
+
365
+ <div class="toolbar">
366
+ <div class="search">
367
+ <svg class="search__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
368
+ <circle cx="11" cy="11" r="8"/>
369
+ <path d="m21 21-4.35-4.35"/>
370
+ </svg>
371
+ <input type="text" id="userSearch" class="search__input" placeholder="Search username, telegram ID, server…">
372
+ <span class="search__hint mono" id="userCount">— records</span>
373
+ </div>
374
+ <button class="btn btn--ghost" id="exportBtn">
375
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
376
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
377
+ <polyline points="7 10 12 15 17 10"/>
378
+ <line x1="12" y1="15" x2="12" y2="3"/>
379
+ </svg>
380
+ Export CSV
381
+ </button>
382
+ </div>
383
+
384
+ <div id="usersContent" class="users-wrap">
385
+ <div class="empty mono">Loading users…</div>
386
+ </div>
387
+ </section>
388
+
389
+ <!-- ===== VIEW: SETTINGS ===== -->
390
+ <section class="view" data-view="settings">
391
+ <div class="view__head">
392
+ <div>
393
+ <div class="eyebrow mono">CONFIGURATION</div>
394
+ <h1 class="view__title">Settings</h1>
395
+ <p class="view__sub">Session and connection preferences.</p>
396
+ </div>
397
+ </div>
398
+
399
+ <div class="settings">
400
+ <div class="setting">
401
+ <div>
402
+ <div class="setting__label">Auto-refresh interval</div>
403
+ <div class="setting__hint">How often the dashboard pulls fresh data from upstream.</div>
404
+ </div>
405
+ <div class="setting__value mono"><span id="pollInterval">{{ poll_interval }}</span>s</div>
406
+ </div>
407
+
408
+ <div class="setting">
409
+ <div>
410
+ <div class="setting__label">Upstream API</div>
411
+ <div class="setting__hint">The backing ServerClass DB instance.</div>
412
+ </div>
413
+ <div class="setting__value mono">dooratre-db.hf.space</div>
414
+ </div>
415
+
416
+ <div class="setting">
417
+ <div>
418
+ <div class="setting__label">Session</div>
419
+ <div class="setting__hint">You stay signed in for 30 days on this device.</div>
420
+ </div>
421
+ <div class="setting__value">
422
+ <button class="btn btn--danger" id="lockBtnSettings">Lock dashboard</button>
423
+ </div>
424
+ </div>
425
+
426
+ <div class="setting">
427
+ <div>
428
+ <div class="setting__label">Build</div>
429
+ <div class="setting__hint">ServerClass Admin Console</div>
430
+ </div>
431
+ <div class="setting__value mono">v2.0 · MMXXV</div>
432
+ </div>
433
+ </div>
434
+ </section>
435
+
436
+ </main>
437
+
438
+ <!-- Mobile bottom nav -->
439
+ <nav class="bottomnav">
440
+ <button class="bottomnav__item active" data-view="overview">
441
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
442
+ <rect x="3" y="3" width="7" height="9" rx="1.5"/>
443
+ <rect x="14" y="3" width="7" height="5" rx="1.5"/>
444
+ <rect x="14" y="12" width="7" height="9" rx="1.5"/>
445
+ <rect x="3" y="16" width="7" height="5" rx="1.5"/>
446
+ </svg>
447
+ <span>Overview</span>
448
+ </button>
449
+ <button class="bottomnav__item" data-view="servers">
450
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
451
+ <rect x="3" y="4" width="18" height="6" rx="1.5"/>
452
+ <rect x="3" y="14" width="18" height="6" rx="1.5"/>
453
+ <circle cx="7" cy="7" r=".8" fill="currentColor"/>
454
+ <circle cx="7" cy="17" r=".8" fill="currentColor"/>
455
+ </svg>
456
+ <span>Servers</span>
457
+ </button>
458
+ <button class="bottomnav__item" data-view="users">
459
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
460
+ <path d="M16 21v-2a4a4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
461
+ <circle cx="9" cy="7" r="4"/>
462
+ </svg>
463
+ <span>Users</span>
464
+ </button>
465
+ <button class="bottomnav__item" data-view="settings">
466
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
467
+ <circle cx="12" cy="12" r="3"/>
468
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
469
+ </svg>
470
+ <span>Settings</span>
471
+ </button>
472
+ </nav>
473
+
474
+ </div>
475
+
476
+ <!-- Toast container -->
477
+ <div id="toasts" class="toasts" aria-live="polite"></div>
478
+
479
+ <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
480
+ <script>
481
+ window.__POLL_INTERVAL__ = {{ poll_interval }};
482
+ window.__UNLOCKED__ = {{ 'true' if unlocked else 'false' }};
483
+ </script>
484
+ <script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
485
+ </body>
486
+ </html>