CORVO-AI commited on
Commit
30a02c3
·
verified ·
1 Parent(s): 1088f82

Upload 6 files

Browse files
Files changed (6) hide show
  1. Dockerfile +55 -0
  2. app.py +191 -0
  3. requirements.txt +7 -0
  4. static/css/style.css +609 -0
  5. static/js/dashboard.js +323 -0
  6. templates/index.html +165 -0
Dockerfile ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=10 \
13
+ UPSTREAM_API=https://dooratre-db.hf.space
14
+
15
+ # Minimal build deps (eventlet/greenlet sometimes need them on slim)
16
+ RUN apt-get update && apt-get install -y --no-install-recommends \
17
+ curl \
18
+ build-essential \
19
+ && rm -rf /var/lib/apt/lists/*
20
+
21
+ # ---------------------------------------------------------------
22
+ # Non-root user (HF Spaces friendly)
23
+ # ---------------------------------------------------------------
24
+ RUN useradd -m -u 1000 appuser
25
+ WORKDIR /app
26
+
27
+ # ---------------------------------------------------------------
28
+ # Python deps (cache layer)
29
+ # ---------------------------------------------------------------
30
+ COPY requirements.txt .
31
+ RUN pip install --upgrade pip && pip install -r requirements.txt
32
+
33
+ # ---------------------------------------------------------------
34
+ # App source
35
+ # ---------------------------------------------------------------
36
+ COPY --chown=appuser:appuser . .
37
+
38
+ USER appuser
39
+
40
+ EXPOSE 7860
41
+
42
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
43
+ CMD curl -fsS http://localhost:7860/health || exit 1
44
+
45
+ # ---------------------------------------------------------------
46
+ # Run with gunicorn + eventlet worker for Socket.IO
47
+ # ---------------------------------------------------------------
48
+ CMD ["gunicorn", \
49
+ "-k", "eventlet", \
50
+ "-w", "1", \
51
+ "--bind", "0.0.0.0:7860", \
52
+ "--access-logfile", "-", \
53
+ "--error-logfile", "-", \
54
+ "--timeout", "120", \
55
+ "app:app"]
app.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ServerClass DB Admin Dashboard
3
+ Flask + Socket.IO backend with background polling and real-time broadcasts.
4
+ """
5
+ import os
6
+ import time
7
+ import threading
8
+ from typing import Dict, Any, Optional
9
+
10
+ import requests
11
+ from flask import Flask, render_template, request, jsonify
12
+ from flask_socketio import SocketIO, emit, disconnect
13
+
14
+ # -----------------------------------------------------------------------------
15
+ # Configuration
16
+ # -----------------------------------------------------------------------------
17
+ UPSTREAM_API = os.environ.get("UPSTREAM_API", "https://dooratre-db.hf.space")
18
+ POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "10")) # seconds
19
+ SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", os.urandom(24).hex())
20
+
21
+ app = Flask(__name__)
22
+ app.config["SECRET_KEY"] = SECRET_KEY
23
+ socketio = SocketIO(app, cors_allowed_origins="*", async_mode="eventlet")
24
+
25
+ # -----------------------------------------------------------------------------
26
+ # Per-connection credential store (sid -> {admin_secret, api_key})
27
+ # -----------------------------------------------------------------------------
28
+ _sessions: Dict[str, Dict[str, str]] = {}
29
+ _sessions_lock = threading.Lock()
30
+
31
+
32
+ def get_session(sid: str) -> Optional[Dict[str, str]]:
33
+ with _sessions_lock:
34
+ return _sessions.get(sid)
35
+
36
+
37
+ def set_session(sid: str, creds: Dict[str, str]) -> None:
38
+ with _sessions_lock:
39
+ _sessions[sid] = creds
40
+
41
+
42
+ def drop_session(sid: str) -> None:
43
+ with _sessions_lock:
44
+ _sessions.pop(sid, None)
45
+
46
+
47
+ # -----------------------------------------------------------------------------
48
+ # Upstream API client
49
+ # -----------------------------------------------------------------------------
50
+ def fetch_upstream(creds: Dict[str, str]) -> Dict[str, Any]:
51
+ """Fetch users + server status from upstream API using provided credentials."""
52
+ result: Dict[str, Any] = {
53
+ "ok": True,
54
+ "users": [],
55
+ "total": 0,
56
+ "status": None,
57
+ "errors": {},
58
+ "timestamp": int(time.time()),
59
+ }
60
+
61
+ admin_secret = creds.get("admin_secret", "").strip()
62
+ api_key = creds.get("api_key", "").strip()
63
+
64
+ # Users (admin endpoint)
65
+ try:
66
+ r = requests.get(
67
+ f"{UPSTREAM_API}/admin/users",
68
+ headers={"X-Admin-Secret": admin_secret, "Content-Type": "application/json"},
69
+ timeout=15,
70
+ )
71
+ if r.ok:
72
+ data = r.json()
73
+ result["users"] = data.get("users", [])
74
+ result["total"] = data.get("total", len(result["users"]))
75
+ else:
76
+ result["errors"]["users"] = f"{r.status_code}: {r.text[:200]}"
77
+ except Exception as e:
78
+ result["errors"]["users"] = str(e)
79
+
80
+ # Server status (API key)
81
+ try:
82
+ r = requests.get(
83
+ f"{UPSTREAM_API}/api/server-status",
84
+ headers={"X-API-Key": api_key, "Content-Type": "application/json"},
85
+ timeout=15,
86
+ )
87
+ if r.ok:
88
+ result["status"] = r.json()
89
+ else:
90
+ result["errors"]["status"] = f"{r.status_code}: {r.text[:200]}"
91
+ except Exception as e:
92
+ result["errors"]["status"] = str(e)
93
+
94
+ if result["errors"] and not result["users"] and not result["status"]:
95
+ result["ok"] = False
96
+
97
+ return result
98
+
99
+
100
+ # -----------------------------------------------------------------------------
101
+ # Background poller
102
+ # -----------------------------------------------------------------------------
103
+ def background_poller():
104
+ """Periodically fetches data for each connected session and pushes updates."""
105
+ while True:
106
+ socketio.sleep(POLL_INTERVAL)
107
+ with _sessions_lock:
108
+ sessions_snapshot = dict(_sessions)
109
+
110
+ for sid, creds in sessions_snapshot.items():
111
+ if not creds.get("admin_secret") and not creds.get("api_key"):
112
+ continue
113
+ try:
114
+ payload = fetch_upstream(creds)
115
+ payload["source"] = "auto"
116
+ socketio.emit("data_update", payload, to=sid)
117
+ except Exception as e:
118
+ socketio.emit("error", {"message": str(e)}, to=sid)
119
+
120
+
121
+ # -----------------------------------------------------------------------------
122
+ # Routes
123
+ # -----------------------------------------------------------------------------
124
+ @app.route("/")
125
+ def index():
126
+ return render_template("index.html", poll_interval=POLL_INTERVAL)
127
+
128
+
129
+ @app.route("/health")
130
+ def health():
131
+ return jsonify({"status": "ok", "sessions": len(_sessions)})
132
+
133
+
134
+ # -----------------------------------------------------------------------------
135
+ # Socket.IO events
136
+ # -----------------------------------------------------------------------------
137
+ @socketio.on("connect")
138
+ def on_connect():
139
+ set_session(request.sid, {"admin_secret": "", "api_key": ""})
140
+ emit("connected", {"sid": request.sid, "poll_interval": POLL_INTERVAL})
141
+
142
+
143
+ @socketio.on("disconnect")
144
+ def on_disconnect():
145
+ drop_session(request.sid)
146
+
147
+
148
+ @socketio.on("authenticate")
149
+ def on_authenticate(data):
150
+ """Client sends credentials; server stores them and triggers immediate fetch."""
151
+ creds = {
152
+ "admin_secret": (data or {}).get("admin_secret", "").strip(),
153
+ "api_key": (data or {}).get("api_key", "").strip(),
154
+ }
155
+ set_session(request.sid, creds)
156
+
157
+ payload = fetch_upstream(creds)
158
+ payload["source"] = "manual"
159
+ emit("data_update", payload)
160
+
161
+
162
+ @socketio.on("refresh")
163
+ def on_refresh():
164
+ """Manual refresh request."""
165
+ creds = get_session(request.sid) or {}
166
+ payload = fetch_upstream(creds)
167
+ payload["source"] = "manual"
168
+ emit("data_update", payload)
169
+
170
+
171
+ # -----------------------------------------------------------------------------
172
+ # Start background poller at import time (gunicorn-friendly)
173
+ # -----------------------------------------------------------------------------
174
+ _poller_started = False
175
+ _poller_lock = threading.Lock()
176
+
177
+ def _ensure_poller():
178
+ global _poller_started
179
+ with _poller_lock:
180
+ if not _poller_started:
181
+ socketio.start_background_task(background_poller)
182
+ _poller_started = True
183
+
184
+ _ensure_poller()
185
+
186
+ # -----------------------------------------------------------------------------
187
+ # Entry point (only used for local `python app.py`)
188
+ # -----------------------------------------------------------------------------
189
+ if __name__ == "__main__":
190
+ port = int(os.environ.get("PORT", 7860))
191
+ 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,609 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =============================================================
2
+ ServerClass Admin — Minimalist Monochrome
3
+ ============================================================= */
4
+
5
+ :root {
6
+ --background: #FFFFFF;
7
+ --foreground: #000000;
8
+ --muted: #F5F5F5;
9
+ --muted-fg: #525252;
10
+ --border: #000000;
11
+ --border-light: #E5E5E5;
12
+ }
13
+
14
+ * { box-sizing: border-box; margin: 0; padding: 0; }
15
+
16
+ html, body {
17
+ background: var(--background);
18
+ color: var(--foreground);
19
+ font-family: "Source Serif 4", Georgia, serif;
20
+ font-size: 17px;
21
+ line-height: 1.6;
22
+ -webkit-font-smoothing: antialiased;
23
+ }
24
+
25
+ /* Global paper texture */
26
+ body {
27
+ position: relative;
28
+ }
29
+ body::before {
30
+ content: "";
31
+ position: fixed;
32
+ inset: 0;
33
+ pointer-events: none;
34
+ z-index: 1;
35
+ background-image: repeating-linear-gradient(
36
+ 0deg, transparent, transparent 1px, #000 1px, #000 2px
37
+ );
38
+ background-size: 100% 4px;
39
+ opacity: 0.015;
40
+ }
41
+ body::after {
42
+ content: "";
43
+ position: fixed;
44
+ inset: 0;
45
+ pointer-events: none;
46
+ z-index: 1;
47
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
48
+ opacity: 0.025;
49
+ }
50
+
51
+ /* Layer real content above textures */
52
+ .topbar, .hero, .section, .divider, .footer { position: relative; z-index: 2; }
53
+
54
+ /* Utility */
55
+ .mono {
56
+ font-family: "JetBrains Mono", monospace;
57
+ font-size: 0.75rem;
58
+ letter-spacing: 0.1em;
59
+ text-transform: uppercase;
60
+ font-weight: 500;
61
+ }
62
+ .italic { font-family: "Playfair Display", Georgia, serif; font-style: italic; font-weight: 400; }
63
+
64
+ .skip-link {
65
+ position: absolute;
66
+ left: -9999px;
67
+ top: 0;
68
+ background: #000;
69
+ color: #fff;
70
+ padding: 12px 20px;
71
+ text-decoration: none;
72
+ z-index: 999;
73
+ }
74
+ .skip-link:focus { left: 0; }
75
+
76
+ /* =============================================================
77
+ TOPBAR
78
+ ============================================================= */
79
+ .topbar {
80
+ border-bottom: 1px solid var(--foreground);
81
+ background: var(--background);
82
+ }
83
+ .topbar__inner {
84
+ max-width: 1280px;
85
+ margin: 0 auto;
86
+ padding: 18px 32px;
87
+ display: flex;
88
+ justify-content: space-between;
89
+ align-items: center;
90
+ gap: 16px;
91
+ flex-wrap: wrap;
92
+ }
93
+ .topbar__brand {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 14px;
97
+ font-family: "Playfair Display", Georgia, serif;
98
+ font-size: 1.125rem;
99
+ letter-spacing: -0.01em;
100
+ }
101
+ .topbar__mark {
102
+ width: 14px;
103
+ height: 14px;
104
+ background: #000;
105
+ display: inline-block;
106
+ }
107
+ .topbar__name em { font-style: italic; padding: 0 4px; }
108
+ .topbar__meta {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 12px;
112
+ color: var(--muted-fg);
113
+ }
114
+ .topbar__sep { color: var(--border-light); }
115
+ .status-dot {
116
+ width: 10px;
117
+ height: 10px;
118
+ display: inline-block;
119
+ border: 1.5px solid #000;
120
+ background: #fff;
121
+ }
122
+ .status-dot[data-state="on"] { background: #000; }
123
+ .status-dot[data-state="warn"]{ background: repeating-linear-gradient(45deg,#000,#000 2px,#fff 2px,#fff 4px); }
124
+
125
+ /* =============================================================
126
+ HERO
127
+ ============================================================= */
128
+ .hero {
129
+ padding: 120px 32px 80px;
130
+ border-bottom: 1px solid var(--border-light);
131
+ }
132
+ .hero__inner {
133
+ max-width: 1280px;
134
+ margin: 0 auto;
135
+ }
136
+ .hero__eyebrow {
137
+ color: var(--muted-fg);
138
+ margin-bottom: 40px;
139
+ }
140
+ .hero__title {
141
+ font-family: "Playfair Display", Georgia, serif;
142
+ font-weight: 900;
143
+ font-size: clamp(4rem, 14vw, 11rem);
144
+ line-height: 0.9;
145
+ letter-spacing: -0.05em;
146
+ margin-bottom: 56px;
147
+ }
148
+ .hero__title .italic {
149
+ font-weight: 400;
150
+ color: #000;
151
+ }
152
+ .hero__rule {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 0;
156
+ margin-bottom: 40px;
157
+ }
158
+ .hero__rule-line {
159
+ flex: 1;
160
+ height: 4px;
161
+ background: #000;
162
+ }
163
+ .hero__rule-square {
164
+ width: 18px;
165
+ height: 18px;
166
+ border: 2px solid #000;
167
+ background: #fff;
168
+ margin: 0 14px;
169
+ }
170
+ .hero__lede {
171
+ font-family: "Playfair Display", Georgia, serif;
172
+ font-style: italic;
173
+ font-size: clamp(1.25rem, 2vw, 1.75rem);
174
+ line-height: 1.4;
175
+ max-width: 640px;
176
+ color: #000;
177
+ }
178
+
179
+ /* =============================================================
180
+ DIVIDERS
181
+ ============================================================= */
182
+ .divider { background: #000; }
183
+ .divider--thick { height: 4px; }
184
+ .divider--ultra { height: 8px; }
185
+
186
+ /* =============================================================
187
+ SECTIONS
188
+ ============================================================= */
189
+ .section {
190
+ padding: 96px 32px;
191
+ }
192
+ .section__inner {
193
+ max-width: 1280px;
194
+ margin: 0 auto;
195
+ }
196
+ .section__head {
197
+ display: flex;
198
+ align-items: baseline;
199
+ gap: 24px;
200
+ margin-bottom: 48px;
201
+ padding-bottom: 20px;
202
+ border-bottom: 1px solid var(--border-light);
203
+ }
204
+ .section__num {
205
+ color: var(--muted-fg);
206
+ flex-shrink: 0;
207
+ }
208
+ .section__title {
209
+ font-family: "Playfair Display", Georgia, serif;
210
+ font-weight: 700;
211
+ font-size: clamp(2rem, 5vw, 3.5rem);
212
+ letter-spacing: -0.03em;
213
+ line-height: 1;
214
+ }
215
+
216
+ /* Inverted stats section */
217
+ .section--stats {
218
+ background: #000;
219
+ color: #fff;
220
+ position: relative;
221
+ overflow: hidden;
222
+ }
223
+ .section--stats::before {
224
+ content: "";
225
+ position: absolute;
226
+ inset: 0;
227
+ background-image: repeating-linear-gradient(
228
+ 90deg, transparent, transparent 1px, #fff 1px, #fff 2px
229
+ );
230
+ background-size: 4px 100%;
231
+ opacity: 0.03;
232
+ pointer-events: none;
233
+ }
234
+ .section__head--invert {
235
+ border-bottom-color: rgba(255,255,255,0.2);
236
+ }
237
+ .section--stats .section__num { color: rgba(255,255,255,0.5); }
238
+
239
+ /* =============================================================
240
+ AUTH FORM
241
+ ============================================================= */
242
+ .auth {
243
+ display: grid;
244
+ grid-template-columns: 1fr 1fr;
245
+ gap: 32px;
246
+ align-items: end;
247
+ }
248
+ .field {
249
+ display: block;
250
+ }
251
+ .field__label {
252
+ display: block;
253
+ color: var(--muted-fg);
254
+ margin-bottom: 12px;
255
+ }
256
+ .field__input {
257
+ width: 100%;
258
+ padding: 12px 0;
259
+ background: transparent;
260
+ border: none;
261
+ border-bottom: 2px solid #000;
262
+ font-family: "JetBrains Mono", monospace;
263
+ font-size: 1rem;
264
+ color: #000;
265
+ border-radius: 0;
266
+ }
267
+ .field__input::placeholder {
268
+ color: var(--muted-fg);
269
+ font-style: italic;
270
+ }
271
+ .field__input:focus {
272
+ outline: none;
273
+ border-bottom-width: 4px;
274
+ }
275
+ .auth__actions {
276
+ grid-column: 1 / -1;
277
+ display: flex;
278
+ gap: 16px;
279
+ flex-wrap: wrap;
280
+ margin-top: 24px;
281
+ }
282
+
283
+ /* =============================================================
284
+ BUTTONS
285
+ ============================================================= */
286
+ .btn {
287
+ display: inline-flex;
288
+ align-items: center;
289
+ gap: 12px;
290
+ padding: 16px 32px;
291
+ font-family: "JetBrains Mono", monospace;
292
+ font-size: 0.8125rem;
293
+ font-weight: 500;
294
+ letter-spacing: 0.15em;
295
+ text-transform: uppercase;
296
+ border-radius: 0;
297
+ cursor: pointer;
298
+ transition: all 100ms linear;
299
+ border: 2px solid #000;
300
+ }
301
+ .btn:focus-visible {
302
+ outline: 3px solid #000;
303
+ outline-offset: 3px;
304
+ }
305
+ .btn--primary {
306
+ background: #000;
307
+ color: #fff;
308
+ }
309
+ .btn--primary:hover {
310
+ background: #fff;
311
+ color: #000;
312
+ }
313
+ .btn--ghost {
314
+ background: transparent;
315
+ color: #000;
316
+ border-color: transparent;
317
+ border-bottom: 2px solid #000;
318
+ padding: 16px 8px;
319
+ }
320
+ .btn--ghost:hover {
321
+ border-bottom-width: 4px;
322
+ padding-bottom: 14px;
323
+ }
324
+ .btn__arrow {
325
+ display: inline-block;
326
+ transition: transform 100ms linear;
327
+ }
328
+ .btn:hover .btn__arrow {
329
+ transform: translateX(4px);
330
+ }
331
+
332
+ /* =============================================================
333
+ ERRORS
334
+ ============================================================= */
335
+ .errors { margin-top: 32px; }
336
+ .errors:empty { display: none; }
337
+ .error {
338
+ border: 2px solid #000;
339
+ padding: 20px 24px;
340
+ margin-bottom: 12px;
341
+ font-family: "Source Serif 4", Georgia, serif;
342
+ display: flex;
343
+ gap: 16px;
344
+ align-items: flex-start;
345
+ }
346
+ .error__tag {
347
+ font-family: "JetBrains Mono", monospace;
348
+ font-size: 0.7rem;
349
+ letter-spacing: 0.15em;
350
+ text-transform: uppercase;
351
+ background: #000;
352
+ color: #fff;
353
+ padding: 4px 10px;
354
+ flex-shrink: 0;
355
+ }
356
+ .error__msg {
357
+ font-style: italic;
358
+ line-height: 1.5;
359
+ word-break: break-word;
360
+ }
361
+
362
+ /* =============================================================
363
+ STATS GRID
364
+ ============================================================= */
365
+ .stats {
366
+ display: grid;
367
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
368
+ gap: 0;
369
+ border-top: 1px solid rgba(255,255,255,0.2);
370
+ border-left: 1px solid rgba(255,255,255,0.2);
371
+ }
372
+ .stat {
373
+ padding: 40px 32px;
374
+ border-right: 1px solid rgba(255,255,255,0.2);
375
+ border-bottom: 1px solid rgba(255,255,255,0.2);
376
+ transition: background 100ms linear;
377
+ }
378
+ .stat:hover { background: rgba(255,255,255,0.04); }
379
+ .stat__label {
380
+ color: rgba(255,255,255,0.6);
381
+ margin-bottom: 24px;
382
+ }
383
+ .stat__value {
384
+ font-family: "Playfair Display", Georgia, serif;
385
+ font-weight: 700;
386
+ font-size: clamp(3rem, 6vw, 5rem);
387
+ line-height: 0.9;
388
+ letter-spacing: -0.04em;
389
+ margin-bottom: 12px;
390
+ }
391
+ .stat__sub {
392
+ color: rgba(255,255,255,0.5);
393
+ font-size: 0.7rem;
394
+ }
395
+ .stat--placeholder .stat__value {
396
+ color: rgba(255,255,255,0.3);
397
+ }
398
+
399
+ /* =============================================================
400
+ PLACEHOLDER
401
+ ============================================================= */
402
+ .placeholder {
403
+ padding: 80px 0;
404
+ text-align: center;
405
+ color: var(--muted-fg);
406
+ border-top: 1px solid var(--border-light);
407
+ border-bottom: 1px solid var(--border-light);
408
+ }
409
+
410
+ /* =============================================================
411
+ TABLES
412
+ ============================================================= */
413
+ .table-wrap {
414
+ overflow-x: auto;
415
+ border-top: 2px solid #000;
416
+ border-bottom: 2px solid #000;
417
+ }
418
+ table.dataset {
419
+ width: 100%;
420
+ border-collapse: collapse;
421
+ font-family: "Source Serif 4", Georgia, serif;
422
+ }
423
+ table.dataset thead th {
424
+ text-align: left;
425
+ padding: 18px 16px;
426
+ font-family: "JetBrains Mono", monospace;
427
+ font-size: 0.7rem;
428
+ letter-spacing: 0.15em;
429
+ text-transform: uppercase;
430
+ font-weight: 500;
431
+ color: var(--muted-fg);
432
+ border-bottom: 1px solid #000;
433
+ background: #fff;
434
+ white-space: nowrap;
435
+ }
436
+ table.dataset tbody td {
437
+ padding: 18px 16px;
438
+ border-bottom: 1px solid var(--border-light);
439
+ font-size: 0.95rem;
440
+ vertical-align: middle;
441
+ }
442
+ table.dataset tbody tr {
443
+ transition: background 100ms linear, color 100ms linear;
444
+ }
445
+ table.dataset tbody tr:hover {
446
+ background: #000;
447
+ color: #fff;
448
+ }
449
+ table.dataset tbody tr:hover .mono,
450
+ table.dataset tbody tr:hover .meta {
451
+ color: rgba(255,255,255,0.7);
452
+ }
453
+ table.dataset tbody tr:hover .badge {
454
+ background: #fff;
455
+ color: #000;
456
+ }
457
+ table.dataset tbody tr:hover .bar {
458
+ background: rgba(255,255,255,0.2);
459
+ }
460
+ table.dataset tbody tr:hover .bar__fill {
461
+ background: #fff;
462
+ }
463
+
464
+ .idx { color: var(--muted-fg); width: 48px; font-family: "JetBrains Mono", monospace; font-size: 0.8rem; }
465
+ .headline { font-family: "Playfair Display", Georgia, serif; font-size: 1.1rem; font-weight: 700; letter-spacing: -0.01em; }
466
+ .meta { color: var(--muted-fg); font-size: 0.8rem; font-family: "JetBrains Mono", monospace; }
467
+
468
+ /* Badges (line-based, not filled) */
469
+ .badge {
470
+ display: inline-block;
471
+ padding: 4px 10px;
472
+ border: 1px solid #000;
473
+ background: #fff;
474
+ color: #000;
475
+ font-family: "JetBrains Mono", monospace;
476
+ font-size: 0.7rem;
477
+ letter-spacing: 0.1em;
478
+ text-transform: uppercase;
479
+ font-weight: 500;
480
+ }
481
+ .badge--solid { background: #000; color: #fff; }
482
+ .badge--empty { border-color: var(--border-light); color: var(--muted-fg); }
483
+
484
+ /* Capacity bar */
485
+ .bar {
486
+ position: relative;
487
+ width: 100%;
488
+ height: 6px;
489
+ background: var(--border-light);
490
+ margin-top: 8px;
491
+ }
492
+ .bar__fill {
493
+ position: absolute;
494
+ inset: 0 auto 0 0;
495
+ background: #000;
496
+ transition: width 200ms linear, background 100ms linear;
497
+ }
498
+ .bar__fill[data-state="warn"] {
499
+ background: repeating-linear-gradient(45deg, #000 0 4px, #fff 4px 6px);
500
+ }
501
+ .bar__fill[data-state="full"] {
502
+ background: repeating-linear-gradient(90deg, #000 0 2px, #fff 2px 4px);
503
+ }
504
+
505
+ .capacity-text {
506
+ font-family: "JetBrains Mono", monospace;
507
+ font-size: 0.85rem;
508
+ letter-spacing: 0.05em;
509
+ }
510
+
511
+ .url {
512
+ font-family: "JetBrains Mono", monospace;
513
+ font-size: 0.7rem;
514
+ color: var(--muted-fg);
515
+ max-width: 240px;
516
+ overflow: hidden;
517
+ text-overflow: ellipsis;
518
+ white-space: nowrap;
519
+ display: inline-block;
520
+ }
521
+
522
+ /* =============================================================
523
+ SEARCH
524
+ ============================================================= */
525
+ .search {
526
+ display: flex;
527
+ align-items: center;
528
+ gap: 24px;
529
+ border-bottom: 2px solid #000;
530
+ margin-bottom: 32px;
531
+ padding-bottom: 8px;
532
+ }
533
+ .search__input {
534
+ flex: 1;
535
+ border: none;
536
+ background: transparent;
537
+ padding: 12px 0;
538
+ font-family: "Source Serif 4", Georgia, serif;
539
+ font-style: italic;
540
+ font-size: 1.125rem;
541
+ color: #000;
542
+ border-radius: 0;
543
+ }
544
+ .search__input:focus { outline: none; }
545
+ .search__input::placeholder { color: var(--muted-fg); }
546
+ .search__hint { color: var(--muted-fg); flex-shrink: 0; }
547
+
548
+ /* =============================================================
549
+ FOOTER
550
+ ============================================================= */
551
+ .footer {
552
+ background: #fff;
553
+ border-top: 1px solid #000;
554
+ padding: 32px;
555
+ }
556
+ .footer__inner {
557
+ max-width: 1280px;
558
+ margin: 0 auto;
559
+ display: flex;
560
+ justify-content: space-between;
561
+ flex-wrap: wrap;
562
+ gap: 16px;
563
+ color: var(--muted-fg);
564
+ }
565
+
566
+ /* =============================================================
567
+ PULSE (live update flash)
568
+ ============================================================= */
569
+ @keyframes pulse-bg {
570
+ 0% { background: #000; color: #fff; }
571
+ 100% { background: transparent; color: #000; }
572
+ }
573
+ .pulse {
574
+ animation: pulse-bg 600ms linear;
575
+ }
576
+
577
+ /* =============================================================
578
+ RESPONSIVE
579
+ ============================================================= */
580
+ @media (max-width: 720px) {
581
+ .topbar__inner { padding: 16px 20px; }
582
+ .hero { padding: 64px 20px 48px; }
583
+ .section { padding: 64px 20px; }
584
+ .footer { padding: 24px 20px; }
585
+ .auth { grid-template-columns: 1fr; gap: 24px; }
586
+ .section__head { flex-direction: column; gap: 8px; margin-bottom: 32px; }
587
+ .stat { padding: 32px 20px; }
588
+ table.dataset thead { display: none; }
589
+ table.dataset, table.dataset tbody, table.dataset tr, table.dataset td { display: block; width: 100%; }
590
+ table.dataset tbody tr {
591
+ border-bottom: 2px solid #000;
592
+ padding: 16px 0;
593
+ }
594
+ table.dataset tbody td {
595
+ border: none;
596
+ padding: 6px 0;
597
+ display: flex;
598
+ justify-content: space-between;
599
+ gap: 16px;
600
+ }
601
+ table.dataset tbody td::before {
602
+ content: attr(data-label);
603
+ font-family: "JetBrains Mono", monospace;
604
+ font-size: 0.7rem;
605
+ letter-spacing: 0.1em;
606
+ text-transform: uppercase;
607
+ color: var(--muted-fg);
608
+ }
609
+ }
static/js/dashboard.js ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =============================================================
2
+ ServerClass Admin — Dashboard Client
3
+ Socket.IO driven; no client-side polling.
4
+ ============================================================= */
5
+
6
+ (() => {
7
+ const socket = io({ transports: ["websocket", "polling"] });
8
+
9
+ // State
10
+ let allUsers = [];
11
+ let lastStatus = null;
12
+ let lastTotal = 0;
13
+
14
+ // DOM refs
15
+ const $ = (id) => document.getElementById(id);
16
+ const connDot = $("connDot");
17
+ const connLabel = $("connLabel");
18
+ const lastSync = $("lastSync");
19
+ const errorBox = $("errorBox");
20
+ const statsGrid = $("statsGrid");
21
+ const serversC = $("serversContent");
22
+ const usersC = $("usersContent");
23
+ const userCount = $("userCount");
24
+
25
+ // -----------------------------------------------------------
26
+ // Helpers
27
+ // -----------------------------------------------------------
28
+ const escapeHtml = (s) => {
29
+ if (s === null || s === undefined) return "";
30
+ return String(s)
31
+ .replace(/&/g, "&")
32
+ .replace(/</g, "&lt;")
33
+ .replace(/>/g, "&gt;")
34
+ .replace(/"/g, "&quot;")
35
+ .replace(/'/g, "&#039;");
36
+ };
37
+
38
+ const fmtDate = (iso) => {
39
+ if (!iso) return "—";
40
+ try {
41
+ const d = new Date(iso);
42
+ if (isNaN(d)) return "—";
43
+ return d.toLocaleString(undefined, {
44
+ year: "numeric", month: "short", day: "2-digit",
45
+ hour: "2-digit", minute: "2-digit"
46
+ });
47
+ } catch { return "—"; }
48
+ };
49
+
50
+ const fmtTime = (ts) => {
51
+ const d = ts ? new Date(ts * 1000) : new Date();
52
+ return d.toLocaleTimeString(undefined, { hour12: false });
53
+ };
54
+
55
+ const setConn = (state, label) => {
56
+ connDot.dataset.state = state;
57
+ connLabel.textContent = label;
58
+ };
59
+
60
+ const pulse = (el) => {
61
+ if (!el) return;
62
+ el.classList.remove("pulse");
63
+ // force reflow to restart animation
64
+ void el.offsetWidth;
65
+ el.classList.add("pulse");
66
+ };
67
+
68
+ // -----------------------------------------------------------
69
+ // Render: errors
70
+ // -----------------------------------------------------------
71
+ const renderErrors = (errors) => {
72
+ if (!errors || Object.keys(errors).length === 0) {
73
+ errorBox.innerHTML = "";
74
+ return;
75
+ }
76
+ errorBox.innerHTML = Object.entries(errors).map(([k, v]) => `
77
+ <div class="error">
78
+ <span class="error__tag">${escapeHtml(k)}</span>
79
+ <span class="error__msg">${escapeHtml(v)}</span>
80
+ </div>
81
+ `).join("");
82
+ };
83
+
84
+ // -----------------------------------------------------------
85
+ // Render: stats
86
+ // -----------------------------------------------------------
87
+ const renderStats = (status, total) => {
88
+ const cards = [];
89
+
90
+ cards.push(`
91
+ <div class="stat">
92
+ <div class="stat__label mono">Total Users</div>
93
+ <div class="stat__value">${total ?? "—"}</div>
94
+ <div class="stat__sub mono">Registered accounts</div>
95
+ </div>
96
+ `);
97
+
98
+ if (status) {
99
+ const cap = status.total_servers * status.max_per_server;
100
+ const pct = cap > 0 ? Math.round((total / cap) * 100) : 0;
101
+ cards.push(`
102
+ <div class="stat">
103
+ <div class="stat__label mono">Servers</div>
104
+ <div class="stat__value">${status.total_servers}</div>
105
+ <div class="stat__sub mono">Max ${status.max_per_server} ea.</div>
106
+ </div>
107
+ <div class="stat">
108
+ <div class="stat__label mono">Reservations</div>
109
+ <div class="stat__value">${status.total_reservations}</div>
110
+ <div class="stat__sub mono">Pending registrations</div>
111
+ </div>
112
+ <div class="stat">
113
+ <div class="stat__label mono">Capacity</div>
114
+ <div class="stat__value">${pct}<span style="font-size:.5em">%</span></div>
115
+ <div class="stat__sub mono">${total} / ${cap}</div>
116
+ </div>
117
+ `);
118
+ } else {
119
+ cards.push(`
120
+ <div class="stat stat--placeholder">
121
+ <div class="stat__label mono">Servers</div>
122
+ <div class="stat__value">—</div>
123
+ <div class="stat__sub mono">Awaiting API key</div>
124
+ </div>
125
+ <div class="stat stat--placeholder">
126
+ <div class="stat__label mono">Reservations</div>
127
+ <div class="stat__value">—</div>
128
+ <div class="stat__sub mono">Awaiting API key</div>
129
+ </div>
130
+ <div class="stat stat--placeholder">
131
+ <div class="stat__label mono">Capacity</div>
132
+ <div class="stat__value">—</div>
133
+ <div class="stat__sub mono">Awaiting API key</div>
134
+ </div>
135
+ `);
136
+ }
137
+
138
+ statsGrid.innerHTML = cards.join("");
139
+ pulse(statsGrid);
140
+ };
141
+
142
+ // -----------------------------------------------------------
143
+ // Render: servers
144
+ // -----------------------------------------------------------
145
+ const renderServers = (servers) => {
146
+ if (!servers || !servers.length) {
147
+ serversC.innerHTML = `<div class="placeholder mono">No server data available.</div>`;
148
+ return;
149
+ }
150
+
151
+ const rows = servers.map((s) => {
152
+ const pct = s.max > 0 ? (s.effective / s.max) * 100 : 0;
153
+ const state = pct >= 100 ? "full" : pct >= 75 ? "warn" : "ok";
154
+ const statusBadge = s.available
155
+ ? `<span class="badge">Open</span>`
156
+ : `<span class="badge badge--solid">Full</span>`;
157
+
158
+ return `
159
+ <tr>
160
+ <td data-label="No." class="idx">№ ${escapeHtml(String(s.server_num).padStart(2, "0"))}</td>
161
+ <td data-label="Users"><span class="capacity-text">${s.users}</span></td>
162
+ <td data-label="Reserved"><span class="capacity-text">${s.reserved}</span></td>
163
+ <td data-label="Capacity">
164
+ <span class="capacity-text">${s.effective} / ${s.max}</span>
165
+ <div class="bar"><div class="bar__fill" data-state="${state}" style="width:${Math.min(pct, 100)}%"></div></div>
166
+ </td>
167
+ <td data-label="Status">${statusBadge}</td>
168
+ <td data-label="URL"><span class="url" title="${escapeHtml(s.url || "")}">${escapeHtml(s.url || "—")}</span></td>
169
+ </tr>
170
+ `;
171
+ }).join("");
172
+
173
+ serversC.innerHTML = `
174
+ <div class="table-wrap">
175
+ <table class="dataset">
176
+ <thead>
177
+ <tr>
178
+ <th>№</th>
179
+ <th>Users</th>
180
+ <th>Reserved</th>
181
+ <th>Capacity</th>
182
+ <th>Status</th>
183
+ <th>Endpoint</th>
184
+ </tr>
185
+ </thead>
186
+ <tbody>${rows}</tbody>
187
+ </table>
188
+ </div>
189
+ `;
190
+ pulse(serversC);
191
+ };
192
+
193
+ // -----------------------------------------------------------
194
+ // Render: users
195
+ // -----------------------------------------------------------
196
+ const renderUsers = (users) => {
197
+ userCount.textContent = `${users.length} record${users.length === 1 ? "" : "s"}`;
198
+
199
+ if (!users.length) {
200
+ usersC.innerHTML = `<div class="placeholder mono">No users match the current filter.</div>`;
201
+ return;
202
+ }
203
+
204
+ const rows = users.map((u, i) => {
205
+ const tokens = u.tokens_count || 0;
206
+ const tokensBadge = tokens > 0
207
+ ? `<span class="badge badge--solid">${tokens} token${tokens > 1 ? "s" : ""}</span>`
208
+ : `<span class="badge badge--empty">none</span>`;
209
+
210
+ return `
211
+ <tr>
212
+ <td data-label="#" class="idx">${String(i + 1).padStart(3, "0")}</td>
213
+ <td data-label="Username"><span class="headline">${escapeHtml(u.username || "—")}</span></td>
214
+ <td data-label="Telegram ID"><span class="meta">${escapeHtml(u.telegram_id || "—")}</span></td>
215
+ <td data-label="Server"><span class="badge">Server ${escapeHtml(String(u.server_num ?? "?"))}</span></td>
216
+ <td data-label="Tokens">${tokensBadge}</td>
217
+ <td data-label="Created"><span class="meta">${escapeHtml(fmtDate(u.created_at))}</span></td>
218
+ <td data-label="Last login"><span class="meta">${escapeHtml(fmtDate(u.last_login))}</span></td>
219
+ </tr>
220
+ `;
221
+ }).join("");
222
+
223
+ usersC.innerHTML = `
224
+ <div class="table-wrap">
225
+ <table class="dataset">
226
+ <thead>
227
+ <tr>
228
+ <th>#</th>
229
+ <th>Username</th>
230
+ <th>Telegram ID</th>
231
+ <th>Server</th>
232
+ <th>Tokens</th>
233
+ <th>Created</th>
234
+ <th>Last login</th>
235
+ </tr>
236
+ </thead>
237
+ <tbody>${rows}</tbody>
238
+ </table>
239
+ </div>
240
+ `;
241
+ pulse(usersC);
242
+ };
243
+
244
+ // -----------------------------------------------------------
245
+ // Filter
246
+ // -----------------------------------------------------------
247
+ window.filterUsers = () => {
248
+ const q = ($("userSearch").value || "").trim().toLowerCase();
249
+ if (!q) return renderUsers(allUsers);
250
+ const filtered = allUsers.filter((u) =>
251
+ (u.username || "").toLowerCase().includes(q) ||
252
+ (u.telegram_id || "").toLowerCase().includes(q) ||
253
+ String(u.server_num || "").includes(q)
254
+ );
255
+ renderUsers(filtered);
256
+ };
257
+
258
+ // -----------------------------------------------------------
259
+ // Form events
260
+ // -----------------------------------------------------------
261
+ $("authForm").addEventListener("submit", (e) => {
262
+ e.preventDefault();
263
+ const admin_secret = $("adminSecret").value.trim();
264
+ const api_key = $("apiKey").value.trim();
265
+ if (!admin_secret && !api_key) {
266
+ renderErrors({ auth: "Provide at least an Admin Secret or API Key." });
267
+ return;
268
+ }
269
+ setConn("warn", "AUTHENTICATING…");
270
+ socket.emit("authenticate", { admin_secret, api_key });
271
+ });
272
+
273
+ $("refreshBtn").addEventListener("click", () => {
274
+ setConn("warn", "REFRESHING…");
275
+ socket.emit("refresh");
276
+ });
277
+
278
+ // -----------------------------------------------------------
279
+ // Socket.IO lifecycle
280
+ // -----------------------------------------------------------
281
+ socket.on("connect", () => {
282
+ setConn("on", "LIVE");
283
+ });
284
+
285
+ socket.on("connected", (data) => {
286
+ if (data && data.poll_interval) {
287
+ const el = $("pollInterval");
288
+ if (el) el.textContent = data.poll_interval;
289
+ }
290
+ });
291
+
292
+ socket.on("disconnect", () => {
293
+ setConn("off", "DISCONNECTED");
294
+ });
295
+
296
+ socket.on("connect_error", () => {
297
+ setConn("off", "CONNECTION ERROR");
298
+ });
299
+
300
+ socket.on("error", (data) => {
301
+ renderErrors({ socket: (data && data.message) || "Unknown error" });
302
+ });
303
+
304
+ // -----------------------------------------------------------
305
+ // Main payload handler
306
+ // -----------------------------------------------------------
307
+ socket.on("data_update", (payload) => {
308
+ if (!payload) return;
309
+
310
+ if (payload.errors) renderErrors(payload.errors);
311
+
312
+ allUsers = payload.users || [];
313
+ lastTotal = payload.total ?? allUsers.length;
314
+ lastStatus = payload.status || null;
315
+
316
+ renderStats(lastStatus, lastTotal);
317
+ renderServers(lastStatus ? lastStatus.servers : null);
318
+ renderUsers(allUsers);
319
+
320
+ lastSync.textContent = fmtTime(payload.timestamp);
321
+ setConn("on", payload.source === "auto" ? "LIVE · AUTO" : "LIVE");
322
+ });
323
+ })();
templates/index.html ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">
6
+ <title>ServerClass — Admin</title>
7
+
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400;1,700&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
11
+
12
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
13
+ </head>
14
+ <body>
15
+
16
+ <a href="#main" class="skip-link">Skip to content</a>
17
+
18
+ <!-- Top bar -->
19
+ <header class="topbar">
20
+ <div class="topbar__inner">
21
+ <div class="topbar__brand">
22
+ <span class="topbar__mark"></span>
23
+ <span class="topbar__name">ServerClass <em>—</em> Admin</span>
24
+ </div>
25
+ <div class="topbar__meta">
26
+ <span class="status-dot" id="connDot" data-state="off"></span>
27
+ <span class="mono" id="connLabel">DISCONNECTED</span>
28
+ <span class="topbar__sep">/</span>
29
+ <span class="mono" id="lastSync">— : — : —</span>
30
+ </div>
31
+ </div>
32
+ </header>
33
+
34
+ <!-- Hero -->
35
+ <section class="hero">
36
+ <div class="hero__inner">
37
+ <div class="hero__eyebrow mono">VOL. 01 — LIVE DASHBOARD</div>
38
+ <h1 class="hero__title">
39
+ Server<br>
40
+ <span class="italic">Class.</span>
41
+ </h1>
42
+ <div class="hero__rule">
43
+ <span class="hero__rule-line"></span>
44
+ <span class="hero__rule-square"></span>
45
+ <span class="hero__rule-line"></span>
46
+ </div>
47
+ <p class="hero__lede">
48
+ A real-time observatory for distributed authentication.
49
+ Twelve servers, finite capacity, infinite attention to detail.
50
+ </p>
51
+ </div>
52
+ </section>
53
+
54
+ <div class="divider divider--thick"></div>
55
+
56
+ <!-- Authentication -->
57
+ <main id="main">
58
+ <section class="section section--auth">
59
+ <div class="section__inner">
60
+ <div class="section__head">
61
+ <span class="section__num mono">§ 01</span>
62
+ <h2 class="section__title">Credentials</h2>
63
+ </div>
64
+
65
+ <form id="authForm" class="auth" autocomplete="off">
66
+ <label class="field">
67
+ <span class="field__label mono">Admin Secret</span>
68
+ <input type="password" id="adminSecret" class="field__input" placeholder="X-Admin-Secret" spellcheck="false">
69
+ </label>
70
+
71
+ <label class="field">
72
+ <span class="field__label mono">API Key</span>
73
+ <input type="password" id="apiKey" class="field__input" placeholder="X-API-Key" spellcheck="false">
74
+ </label>
75
+
76
+ <div class="auth__actions">
77
+ <button type="submit" class="btn btn--primary">
78
+ Authenticate <span class="btn__arrow">→</span>
79
+ </button>
80
+ <button type="button" id="refreshBtn" class="btn btn--ghost">
81
+ Refresh now
82
+ </button>
83
+ </div>
84
+ </form>
85
+
86
+ <div id="errorBox" class="errors" aria-live="polite"></div>
87
+ </div>
88
+ </section>
89
+ </main>
90
+
91
+ <div class="divider divider--thick"></div>
92
+
93
+ <!-- Stats (inverted section) -->
94
+ <section class="section section--stats" aria-label="Statistics">
95
+ <div class="section__inner">
96
+ <div class="section__head section__head--invert">
97
+ <span class="section__num mono">§ 02</span>
98
+ <h2 class="section__title">Figures</h2>
99
+ </div>
100
+
101
+ <div class="stats" id="statsGrid">
102
+ <div class="stat stat--placeholder">
103
+ <div class="stat__label mono">Awaiting</div>
104
+ <div class="stat__value">—</div>
105
+ <div class="stat__sub mono">Authenticate to populate</div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </section>
110
+
111
+ <div class="divider divider--thick"></div>
112
+
113
+ <!-- Servers -->
114
+ <section class="section">
115
+ <div class="section__inner">
116
+ <div class="section__head">
117
+ <span class="section__num mono">§ 03</span>
118
+ <h2 class="section__title">Servers <span class="italic">— Status</span></h2>
119
+ </div>
120
+ <div id="serversContent" class="placeholder mono">
121
+ Enter credentials to load server topology.
122
+ </div>
123
+ </div>
124
+ </section>
125
+
126
+ <div class="divider divider--thick"></div>
127
+
128
+ <!-- Users -->
129
+ <section class="section">
130
+ <div class="section__inner">
131
+ <div class="section__head">
132
+ <span class="section__num mono">§ 04</span>
133
+ <h2 class="section__title">Users <span class="italic">— Index</span></h2>
134
+ </div>
135
+
136
+ <div class="search">
137
+ <input type="text" id="userSearch" class="search__input"
138
+ placeholder="Filter by username, telegram ID, or server number…"
139
+ oninput="filterUsers()">
140
+ <span class="search__hint mono" id="userCount">— records</span>
141
+ </div>
142
+
143
+ <div id="usersContent" class="placeholder mono">
144
+ Enter credentials to load user registry.
145
+ </div>
146
+ </div>
147
+ </section>
148
+
149
+ <div class="divider divider--ultra"></div>
150
+
151
+ <footer class="footer">
152
+ <div class="footer__inner">
153
+ <div class="mono footer__line">ServerClass / Admin Console</div>
154
+ <div class="mono footer__line">Auto-refresh every <span id="pollInterval">{{ poll_interval }}</span>s · Socket.IO</div>
155
+ <div class="mono footer__line">MMXXV</div>
156
+ </div>
157
+ </footer>
158
+
159
+ <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
160
+ <script>
161
+ window.__POLL_INTERVAL__ = {{ poll_interval }};
162
+ </script>
163
+ <script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
164
+ </body>
165
+ </html>