Skip to content

Commit 21f10c1

Browse files
committed
feat(dashboard): add built-in monitoring dashboard with live metrics and logs
- New dashboard/ module: stats.py (in-memory metrics singleton), auth.py (key + cookie auth), server.py (aiohttp HTTP server), templates/dashboard.html (dark-theme SPA with KPI cards and live log viewer) - bot.py: intercept builtins.print() to feed all output into dashboard rolling log buffer (deque 1000); start dashboard HTTP server in post_init() with graceful degradation (try/except) - clients/x402gate/openrouter.py: record_llm_request() on success, record_llm_error() on ALL error types (TopupError, NonRetriableRequestError, ValueError, empty response, retries exhausted) - clients/x402gate/__init__.py: update_balance() on prepaid balance header update - handlers/pyrogram_handlers.py: record_draft() in 4 draft-setting paths, record_auto_reply(), record_voice_transcription(), record_command() for /disconnect and /status - handlers/bot_handlers.py: record_command() for /start - handlers/settings_handler.py: record_command() for /settings - handlers/styles_handler.py: record_command() for /chats - handlers/poke_handler.py: record_command() for /poke - handlers/connect_handler.py: record_command() for /connect - database/users.py: add get_dashboard_user_stats() for Supabase user counts (total, connected, active 24h) - requirements.txt: add aiohttp>=3.9.0, jinja2>=3.1.0 - .env.example: add DASHBOARD_KEY variable - README.md: add Dashboard section, update Architecture and Tech Stack - tests/test_dashboard.py: 23 tests for stats and auth modules
1 parent d9abf39 commit 21f10c1

File tree

20 files changed

+1353
-2
lines changed

20 files changed

+1353
-2
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ EVM_PRIVATE_KEY= # Приватный ключ кошелька Bas
2626
DEBUG_PRINT=False # True для подробного логирования
2727
LOG_TO_FILE=False # True для записи summary логов OpenRouter в logs/
2828

29+
# ====== DASHBOARD ======
30+
DASHBOARD_KEY= # Секретный ключ доступа к дашборду (/dashboard?key=...)
31+
2932
# ====== RAILWAY (логи продакшена) ======
3033
RAILWAY_TOKEN= # API Token: https://railway.app/account/tokens
3134
RAILWAY_PROJECT_ID= # ID проекта (из railway list --json)

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().d
5151
Optional for debugging:
5252
- `DEBUG_PRINT=true` — verbose console logs (default `false`)
5353
- `LOG_TO_FILE=true` — save full AI requests/responses to `logs/` for local debugging (default `false`)
54+
- `DASHBOARD_KEY` — secret key for dashboard access (see [Dashboard](#dashboard) section)
5455

5556
Important: keep `LOG_TO_FILE` disabled in production — it logs full prompts, chat history, and model responses.
5657

@@ -127,6 +128,29 @@ All voice messages in the chat history — from both sides (yours and the contac
127128

128129
Stickers are processed by emoji — the bot sees the sticker's emoji in the conversation context and generates an appropriate reply.
129130

131+
## Dashboard
132+
133+
DraftGuru includes a built-in monitoring dashboard — a single-page web UI with live metrics and logs.
134+
135+
**Features:**
136+
- KPI cards: users (total / connected / active 24h), prepaid balance, balance spent
137+
- LLM stats: requests, tokens, latency, models, errors
138+
- Draft / auto-reply / voice counters
139+
- Live log viewer with filtering (All / Errors / Warnings) and Copy All
140+
- Auto-refresh every 5 seconds
141+
142+
**Access:**
143+
144+
Set `DASHBOARD_KEY` in `.env`, then open:
145+
146+
```
147+
https://<your-domain>/dashboard?key=YOUR_KEY
148+
```
149+
150+
After the first visit, a cookie is set for 30 days — no need to pass the key again.
151+
152+
The dashboard runs on the same port as the bot (`$PORT`, default `8080`). If `DASHBOARD_KEY` is not set, the dashboard is disabled.
153+
130154
## Deploy on Railway
131155

132156
1. Create a project on [Railway](https://railway.app)
@@ -150,13 +174,14 @@ Tests run automatically on GitHub on push to `main`/`dev` and on PRs (GitHub Act
150174

151175
## Architecture
152176

153-
- **bot.py** — Entry point: handler registration and bot startup
177+
- **bot.py** — Entry point: handler registration, bot startup, dashboard server
154178
- **handlers/** — Bot commands and Pyrogram events
155179
- **config.py** — Constants and environment variables
156180
- **prompts.py** — AI prompts
157181
- **system_messages.py** — System messages with auto-translation
158182
- **clients/** — API clients (x402gate, Pyrogram)
159183
- **logic/** — Reply generation business logic
184+
- **dashboard/** — Monitoring dashboard (aiohttp server, in-memory stats, HTML SPA)
160185
- **database/** — Supabase queries
161186
- **utils/** — Utilities
162187
- **scripts/** — CLI scripts (Railway logs, session generation)
@@ -168,6 +193,7 @@ Tests run automatically on GitHub on push to `main`/`dev` and on PRs (GitHub Act
168193
- **python-telegram-bot** — Telegram Bot API
169194
- **Pyrogram** — Telegram Client API (reading messages, drafts)
170195
- **x402gate.io** → OpenRouter → any model (configured in `config.py`, paid with USDC on Base)
196+
- **aiohttp** — Dashboard HTTP server
171197
- **Supabase** — PostgreSQL (DB)
172198
- **Railway** — hosting
173199

bot.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# bot.py — Telegram-бот DraftGuru (запуск и конфигурация)
22

33
import asyncio
4+
import builtins
45
import os
56
import traceback
67

@@ -47,6 +48,29 @@
4748
PRIVATE_ONLY_FILTER = filters.ChatType.PRIVATE
4849

4950

51+
# ====== DASHBOARD: print() interceptor ======
52+
# Captures all print() output into the dashboard's rolling log buffer
53+
54+
from dashboard import stats as dash_stats # noqa: E402
55+
from dashboard.server import start_dashboard_server # noqa: E402
56+
57+
_original_print = builtins.print
58+
59+
60+
def _dashboard_print(*args, **kwargs):
61+
"""Wrapper around print() that also feeds output to dashboard stats."""
62+
_original_print(*args, **kwargs)
63+
try:
64+
message = " ".join(str(a) for a in args)
65+
if message.strip():
66+
dash_stats.capture_log(message)
67+
except Exception:
68+
pass # Never break the bot due to dashboard
69+
70+
71+
builtins.print = _dashboard_print
72+
73+
5074
# ====== ОБРАБОТЧИК ОШИБОК ======
5175

5276
async def on_error(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -122,8 +146,18 @@ async def post_init(app: Application) -> None:
122146
global _poll_task
123147
_poll_task = asyncio.create_task(_poll_missed_loop())
124148

149+
# Запускаем dashboard HTTP-сервер (необязательная подсистема)
150+
global _dashboard_runner
151+
try:
152+
_dashboard_runner = await start_dashboard_server()
153+
except Exception as e:
154+
print(f"{get_timestamp()} [DASHBOARD] ERROR: failed to start dashboard server: {e}")
155+
print(f"{get_timestamp()} [DASHBOARD] Bot will continue without dashboard.")
156+
_dashboard_runner = None
157+
125158

126159
_poll_task: asyncio.Task | None = None
160+
_dashboard_runner = None
127161

128162

129163
async def _poll_missed_loop() -> None:

clients/x402gate/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
X402GATE_TIMEOUT,
2323
X402GATE_URL,
2424
)
25+
from dashboard import stats as dash_stats
2526
from utils.utils import get_timestamp
2627

2728

@@ -359,6 +360,7 @@ async def request(self, path: str, body: dict, timeout: float | None = None) ->
359360
if prepaid_balance_header is not None:
360361
try:
361362
self._prepaid_balance = float(prepaid_balance_header)
363+
dash_stats.update_balance(self._prepaid_balance)
362364
except (ValueError, TypeError):
363365
pass
364366

clients/x402gate/openrouter.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
RETRY_DELAY,
1919
RETRY_EXPONENTIAL_BASE,
2020
)
21+
from dashboard import stats as dash_stats
2122
from prompts import BOT_PROMPT
2223
from utils.utils import get_timestamp
2324

@@ -138,12 +139,21 @@ async def generate_response(
138139
f"{duration:.2f}s | {token_info}"
139140
)
140141

142+
dash_stats.record_llm_request(
143+
model=model,
144+
latency_s=duration,
145+
tokens_in=input_tokens,
146+
tokens_out=output_tokens,
147+
reasoning_tokens=reasoning_tokens,
148+
)
149+
141150
_log_to_file(payload, text.strip(), model, duration, usage, reasoning_text)
142151

143152
return text.strip()
144153

145154
except Exception as e:
146155
last_error = e
156+
dash_stats.record_llm_error()
147157

148158
# TopupError — ретрай бесполезен
149159
if isinstance(e, (TopupError, NonRetriableRequestError, ValueError)):

dashboard/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# dashboard/__init__.py — Модуль мониторинга и метрик DraftGuru

dashboard/auth.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# dashboard/auth.py — Аутентификация дашборда по ключу
2+
#
3+
# Доступ: /dashboard?key=SECRET → cookie, дальше ключ не нужен.
4+
5+
import os
6+
7+
from aiohttp import web
8+
9+
DASHBOARD_KEY = os.getenv("DASHBOARD_KEY", "")
10+
COOKIE_NAME = "dashboard_key"
11+
COOKIE_MAX_AGE = 86400 * 30 # 30 дней
12+
13+
14+
def check_auth(request: web.Request) -> bool:
15+
"""Проверяет аутентификацию через параметр ?key= или cookie."""
16+
if not DASHBOARD_KEY:
17+
return False
18+
19+
key_param = request.query.get("key", "")
20+
if key_param == DASHBOARD_KEY:
21+
return True
22+
23+
cookie_val = request.cookies.get(COOKIE_NAME, "")
24+
return cookie_val == DASHBOARD_KEY
25+
26+
27+
def set_auth_cookie(response: web.Response) -> None:
28+
"""Устанавливает cookie после успешной аутентификации."""
29+
if DASHBOARD_KEY:
30+
response.set_cookie(
31+
COOKIE_NAME,
32+
DASHBOARD_KEY,
33+
max_age=COOKIE_MAX_AGE,
34+
httponly=True,
35+
samesite="Lax",
36+
)

dashboard/server.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# dashboard/server.py — HTTP-сервер дашборда на aiohttp
2+
#
3+
# Запускается параллельно с Telegram-ботом, использует порт Railway ($PORT).
4+
# Маршруты: /dashboard (HTML), /api/stats, /api/logs, /api/users
5+
6+
from __future__ import annotations
7+
8+
import os
9+
import pathlib
10+
11+
import jinja2
12+
from aiohttp import web
13+
14+
from dashboard import stats
15+
from dashboard.auth import DASHBOARD_KEY, check_auth, set_auth_cookie
16+
from database.users import get_dashboard_user_stats
17+
18+
TEMPLATE_DIR = pathlib.Path(__file__).parent / "templates"
19+
20+
21+
def _require_auth(handler): # type: ignore[type-arg]
22+
"""Декоратор: возвращает 401 если запрос не аутентифицирован."""
23+
24+
async def wrapper(request: web.Request) -> web.StreamResponse:
25+
if not check_auth(request):
26+
return web.json_response({"error": "Unauthorized"}, status=401)
27+
return await handler(request)
28+
29+
return wrapper
30+
31+
32+
# ---------------------------------------------------------------------------
33+
# Обработчики
34+
# ---------------------------------------------------------------------------
35+
36+
37+
async def handle_dashboard(request: web.Request) -> web.Response:
38+
"""Отдаёт HTML-страницу дашборда."""
39+
if not check_auth(request):
40+
return web.Response(
41+
text="401 Unauthorized — append ?key=YOUR_KEY to URL",
42+
status=401,
43+
content_type="text/plain",
44+
)
45+
46+
env = jinja2.Environment(
47+
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
48+
autoescape=False,
49+
)
50+
template = env.get_template("dashboard.html")
51+
html = template.render()
52+
53+
response = web.Response(text=html, content_type="text/html")
54+
set_auth_cookie(response)
55+
return response
56+
57+
58+
@_require_auth
59+
async def handle_stats(request: web.Request) -> web.Response:
60+
"""Возвращает текущие метрики в JSON."""
61+
return web.json_response(stats.get_stats())
62+
63+
64+
@_require_auth
65+
async def handle_logs(request: web.Request) -> web.Response:
66+
"""Возвращает последние записи логов в JSON."""
67+
limit = int(request.query.get("limit", stats.MAX_LOG_ENTRIES))
68+
return web.json_response(stats.get_logs(limit=limit))
69+
70+
71+
@_require_auth
72+
async def handle_users(request: web.Request) -> web.Response:
73+
"""Возвращает статистику пользователей из Supabase."""
74+
user_stats = await get_dashboard_user_stats()
75+
return web.json_response(user_stats)
76+
77+
78+
# ---------------------------------------------------------------------------
79+
# Фабрика приложения
80+
# ---------------------------------------------------------------------------
81+
82+
83+
def create_app() -> web.Application:
84+
"""Создаёт aiohttp-приложение со всеми маршрутами."""
85+
app = web.Application()
86+
app.router.add_get("/dashboard", handle_dashboard)
87+
app.router.add_get("/api/stats", handle_stats)
88+
app.router.add_get("/api/logs", handle_logs)
89+
app.router.add_get("/api/users", handle_users)
90+
return app
91+
92+
93+
async def start_dashboard_server() -> web.AppRunner | None:
94+
"""Запускает HTTP-сервер дашборда на $PORT.
95+
96+
Возвращает runner (для cleanup) или None если DASHBOARD_KEY не задан.
97+
"""
98+
if not DASHBOARD_KEY:
99+
print("⚠️ DASHBOARD_KEY not set — dashboard disabled.")
100+
return None
101+
102+
port = int(os.getenv("PORT", "8080"))
103+
app = create_app()
104+
runner = web.AppRunner(app)
105+
await runner.setup()
106+
site = web.TCPSite(runner, "0.0.0.0", port)
107+
await site.start()
108+
print(f"📊 Dashboard running on port {port} (/dashboard?key=...)")
109+
return runner

0 commit comments

Comments
 (0)