remove admin web panel (LichessWebView) from the project
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 43s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 43s
Unused component that also carried several security issues (default admin credentials, hardcoded Flask secret key, debug mode, plaintext passwords). Cleaned up compose files, CI/CD pipeline, and docs accordingly; also dropped the stale IS_PROD config docs in favor of the current .env-based setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
1fe8deb0de
commit
75e62b57a1
15 changed files with 19 additions and 1256 deletions
|
|
@ -33,7 +33,6 @@ jobs:
|
||||||
docker push 192.168.8.174:3000/vrubel/lichess-api:latest
|
docker push 192.168.8.174:3000/vrubel/lichess-api:latest
|
||||||
docker push 192.168.8.174:3000/vrubel/lichess-bot:latest
|
docker push 192.168.8.174:3000/vrubel/lichess-bot:latest
|
||||||
docker push 192.168.8.174:3000/vrubel/lichess-admin:latest
|
docker push 192.168.8.174:3000/vrubel/lichess-admin:latest
|
||||||
docker push 192.168.8.174:3000/vrubel/lichess-web:latest
|
|
||||||
|
|
||||||
- name: Transfer images to prod
|
- name: Transfer images to prod
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -41,7 +40,6 @@ jobs:
|
||||||
192.168.8.174:3000/vrubel/lichess-api:latest \
|
192.168.8.174:3000/vrubel/lichess-api:latest \
|
||||||
192.168.8.174:3000/vrubel/lichess-bot:latest \
|
192.168.8.174:3000/vrubel/lichess-bot:latest \
|
||||||
192.168.8.174:3000/vrubel/lichess-admin:latest \
|
192.168.8.174:3000/vrubel/lichess-admin:latest \
|
||||||
192.168.8.174:3000/vrubel/lichess-web:latest \
|
|
||||||
| ssh -i /data/.ssh/id_ed25519 -o StrictHostKeyChecking=no root@130.17.6.76 docker load
|
| ssh -i /data/.ssh/id_ed25519 -o StrictHostKeyChecking=no root@130.17.6.76 docker load
|
||||||
|
|
||||||
- name: Deploy on prod host
|
- name: Deploy on prod host
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -44,9 +44,6 @@ logs/
|
||||||
.env.local
|
.env.local
|
||||||
.env.backup
|
.env.backup
|
||||||
|
|
||||||
# Auth config (contains passwords)
|
|
||||||
LichessWebView/auth_config.json
|
|
||||||
|
|
||||||
|
|
||||||
# Node modules (если будут использоваться)
|
# Node modules (если будут использоваться)
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
CMD ["python", "app.py"]
|
|
||||||
|
|
||||||
|
|
@ -1,317 +0,0 @@
|
||||||
from flask import Flask, jsonify, render_template, request, session, redirect, url_for
|
|
||||||
from flask_cors import CORS
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, date
|
|
||||||
from functools import wraps
|
|
||||||
import os
|
|
||||||
import auth_config
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
# Secret key для сессий (в продакшене лучше использовать переменную окружения)
|
|
||||||
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'your-secret-key-change-in-production-please')
|
|
||||||
CORS(app)
|
|
||||||
|
|
||||||
# Путь к базе данных бота
|
|
||||||
DB_PATH = "/app/data/lichess_bot.db"
|
|
||||||
|
|
||||||
|
|
||||||
def login_required(f):
|
|
||||||
"""Декоратор для защиты роутов, требующих аутентификации"""
|
|
||||||
@wraps(f)
|
|
||||||
def decorated_function(*args, **kwargs):
|
|
||||||
if 'authenticated' not in session or not session['authenticated']:
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return decorated_function
|
|
||||||
|
|
||||||
def get_message_counters_stats(db_path):
|
|
||||||
"""Get message counters statistics directly from database"""
|
|
||||||
try:
|
|
||||||
with sqlite3.connect(db_path) as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check if table exists
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT name FROM sqlite_master
|
|
||||||
WHERE type='table' AND name='message_counters'
|
|
||||||
''')
|
|
||||||
if not cursor.fetchone():
|
|
||||||
# Table doesn't exist, return empty stats
|
|
||||||
return {
|
|
||||||
'total_all_time': 0,
|
|
||||||
'total_today': 0,
|
|
||||||
'by_command': {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Reset daily counters if needed
|
|
||||||
today = date.today()
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT command, last_reset_date FROM message_counters
|
|
||||||
''')
|
|
||||||
|
|
||||||
for row in cursor.fetchall():
|
|
||||||
cmd, last_reset = row[0], row[1]
|
|
||||||
if last_reset:
|
|
||||||
try:
|
|
||||||
last_reset_date = datetime.strptime(last_reset, '%Y-%m-%d').date()
|
|
||||||
if last_reset_date < today:
|
|
||||||
cursor.execute('''
|
|
||||||
UPDATE message_counters
|
|
||||||
SET today_count = 0, last_reset_date = ?
|
|
||||||
WHERE command = ?
|
|
||||||
''', (today.isoformat(), cmd))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
cursor.execute('''
|
|
||||||
UPDATE message_counters
|
|
||||||
SET today_count = 0, last_reset_date = ?
|
|
||||||
WHERE command = ?
|
|
||||||
''', (today.isoformat(), cmd))
|
|
||||||
else:
|
|
||||||
cursor.execute('''
|
|
||||||
UPDATE message_counters
|
|
||||||
SET last_reset_date = ?
|
|
||||||
WHERE command = ?
|
|
||||||
''', (today.isoformat(), cmd))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# Get all counters
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT command, total_count, today_count
|
|
||||||
FROM message_counters
|
|
||||||
ORDER BY command
|
|
||||||
''')
|
|
||||||
|
|
||||||
stats = {
|
|
||||||
'by_command': {},
|
|
||||||
'total_all_time': 0,
|
|
||||||
'total_today': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for row in cursor.fetchall():
|
|
||||||
cmd, total, today = row[0], row[1], row[2]
|
|
||||||
stats['by_command'][cmd] = {
|
|
||||||
'total': total,
|
|
||||||
'today': today
|
|
||||||
}
|
|
||||||
stats['total_all_time'] += total
|
|
||||||
stats['total_today'] += today
|
|
||||||
|
|
||||||
return {
|
|
||||||
'total_all_time': stats['total_all_time'],
|
|
||||||
'total_today': stats['total_today'],
|
|
||||||
'by_command': stats['by_command']
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error getting message counters: {e}")
|
|
||||||
import traceback
|
|
||||||
print(traceback.format_exc())
|
|
||||||
return {
|
|
||||||
'total_all_time': 0,
|
|
||||||
'total_today': 0,
|
|
||||||
'by_command': {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
|
||||||
def login():
|
|
||||||
"""Страница входа"""
|
|
||||||
if request.method == 'POST':
|
|
||||||
username = request.form.get('username', '').strip()
|
|
||||||
password = request.form.get('password', '')
|
|
||||||
|
|
||||||
if auth_config.validate_credentials(username, password):
|
|
||||||
session['authenticated'] = True
|
|
||||||
session['username'] = username
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
else:
|
|
||||||
error_msg = 'Неверный логин или пароль'
|
|
||||||
return redirect(url_for('login', error=error_msg))
|
|
||||||
|
|
||||||
# GET запрос - показать форму логина
|
|
||||||
error = request.args.get('error', '')
|
|
||||||
# Если уже авторизован, перенаправляем на главную
|
|
||||||
if 'authenticated' in session and session.get('authenticated'):
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
return render_template('login.html', error=error)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/logout')
|
|
||||||
def logout():
|
|
||||||
"""Выход из системы"""
|
|
||||||
session.pop('authenticated', None)
|
|
||||||
session.pop('username', None)
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
@login_required
|
|
||||||
def index():
|
|
||||||
"""Главная страница"""
|
|
||||||
return render_template('index.html')
|
|
||||||
|
|
||||||
@app.route('/api/users')
|
|
||||||
@login_required
|
|
||||||
def get_users():
|
|
||||||
"""Получить всех пользователей"""
|
|
||||||
try:
|
|
||||||
with sqlite3.connect(DB_PATH) as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Получаем всех пользователей
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT
|
|
||||||
tu.user_id,
|
|
||||||
tu.username,
|
|
||||||
tu.first_name,
|
|
||||||
tu.last_name,
|
|
||||||
tu.created_at,
|
|
||||||
tu.bot_language,
|
|
||||||
COUNT(ug.id) as gamer_count,
|
|
||||||
SUM(CASE WHEN ug.period_minutes > 0 THEN 1 ELSE 0 END) as monitored_gamers
|
|
||||||
FROM telegram_users tu
|
|
||||||
LEFT JOIN user_gamers ug ON tu.user_id = ug.user_id
|
|
||||||
GROUP BY tu.user_id, tu.username, tu.first_name, tu.last_name, tu.created_at, tu.bot_language
|
|
||||||
ORDER BY tu.created_at DESC
|
|
||||||
''')
|
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
users = []
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
users.append({
|
|
||||||
'user_id': row[0],
|
|
||||||
'username': row[1] or '-',
|
|
||||||
'first_name': row[2] or '-',
|
|
||||||
'last_name': row[3],
|
|
||||||
'created_at': row[4],
|
|
||||||
'bot_language': row[5] or 'en', # Default to 'en' if not set
|
|
||||||
'gamer_count': row[6],
|
|
||||||
'monitored_gamers': row[7]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Получаем общее количество игроков (уникальных)
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT COUNT(DISTINCT g.id)
|
|
||||||
FROM gamers g
|
|
||||||
''')
|
|
||||||
total_gamers = cursor.fetchone()[0]
|
|
||||||
|
|
||||||
# Count new users today
|
|
||||||
today = date.today().isoformat()
|
|
||||||
cursor.execute("SELECT COUNT(*) FROM telegram_users WHERE DATE(created_at) = ?", (today,))
|
|
||||||
users_today = cursor.fetchone()[0]
|
|
||||||
|
|
||||||
# Count users without gamers
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT COUNT(DISTINCT tu.user_id)
|
|
||||||
FROM telegram_users tu
|
|
||||||
LEFT JOIN user_gamers ug ON tu.user_id = ug.user_id
|
|
||||||
WHERE ug.id IS NULL
|
|
||||||
""")
|
|
||||||
users_without_gamers = cursor.fetchone()[0]
|
|
||||||
|
|
||||||
# Count users with Russian language
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT COUNT(DISTINCT tu.user_id)
|
|
||||||
FROM telegram_users tu
|
|
||||||
WHERE tu.bot_language = 'ru'
|
|
||||||
""")
|
|
||||||
users_with_ru_language = cursor.fetchone()[0]
|
|
||||||
|
|
||||||
# Calculate percentages
|
|
||||||
total_users = len(users)
|
|
||||||
users_without_gamers_percent = round((users_without_gamers / total_users * 100)) if total_users > 0 else 0
|
|
||||||
users_ru_language_percent = round((users_with_ru_language / total_users * 100)) if total_users > 0 else 0
|
|
||||||
|
|
||||||
# Count new gamers today (from user_gamers table)
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT COUNT(DISTINCT g.id)
|
|
||||||
FROM user_gamers ug
|
|
||||||
JOIN gamers g ON ug.gamer_id = g.id
|
|
||||||
WHERE DATE(ug.created_at) = ?
|
|
||||||
""", (today,))
|
|
||||||
gamers_today = cursor.fetchone()[0]
|
|
||||||
|
|
||||||
# Get message counters statistics
|
|
||||||
message_stats = get_message_counters_stats(DB_PATH)
|
|
||||||
|
|
||||||
# Filter out excluded commands (same as in admin_bot)
|
|
||||||
excluded_commands = {'lang', 'resetlang', 'start'}
|
|
||||||
if message_stats.get('by_command'):
|
|
||||||
filtered_by_command = {
|
|
||||||
cmd: data for cmd, data in message_stats['by_command'].items()
|
|
||||||
if cmd not in excluded_commands
|
|
||||||
}
|
|
||||||
message_stats['by_command'] = filtered_by_command
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'users': users,
|
|
||||||
'total_users': total_users,
|
|
||||||
'users_today': users_today,
|
|
||||||
'users_without_gamers': users_without_gamers,
|
|
||||||
'users_without_gamers_percent': users_without_gamers_percent,
|
|
||||||
'users_with_ru_language': users_with_ru_language,
|
|
||||||
'users_ru_language_percent': users_ru_language_percent,
|
|
||||||
'total_gamers': total_gamers,
|
|
||||||
'gamers_today': gamers_today,
|
|
||||||
'message_stats': message_stats
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
@app.route('/api/users/<int:user_id>/gamers')
|
|
||||||
@login_required
|
|
||||||
def get_user_gamers(user_id):
|
|
||||||
"""Получить игроков конкретного пользователя"""
|
|
||||||
try:
|
|
||||||
with sqlite3.connect(DB_PATH) as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Получаем игроков пользователя
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT
|
|
||||||
g.id,
|
|
||||||
g.username,
|
|
||||||
ug.token,
|
|
||||||
ug.period_minutes,
|
|
||||||
ug.created_at
|
|
||||||
FROM user_gamers ug
|
|
||||||
JOIN gamers g ON ug.gamer_id = g.id
|
|
||||||
WHERE ug.user_id = ?
|
|
||||||
ORDER BY g.username
|
|
||||||
''', (user_id,))
|
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
gamers = []
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
gamers.append({
|
|
||||||
'id': row[0],
|
|
||||||
'username': row[1],
|
|
||||||
'has_token': bool(row[2]), # Token from user_gamers, not gamers
|
|
||||||
'period_minutes': row[3],
|
|
||||||
'created_at': row[4]
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'gamers': gamers
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"username": "admin",
|
|
||||||
"password": "change_me_123"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"username": "user2",
|
|
||||||
"password": "another_password"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
"""Модуль для загрузки конфигурации аутентификации"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
AUTH_CONFIG_PATH = "/app/auth_config.json"
|
|
||||||
AUTH_CONFIG_EXAMPLE_PATH = "/app/auth_config.example.json"
|
|
||||||
|
|
||||||
|
|
||||||
def load_auth_config() -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Загружает конфигурацию аутентификации из файла.
|
|
||||||
Возвращает None, если файл не найден.
|
|
||||||
"""
|
|
||||||
config_path = AUTH_CONFIG_PATH
|
|
||||||
|
|
||||||
# Если файл не существует, пробуем example
|
|
||||||
if not os.path.exists(config_path):
|
|
||||||
if os.path.exists(AUTH_CONFIG_EXAMPLE_PATH):
|
|
||||||
print(f"Warning: {config_path} not found, using example config")
|
|
||||||
config_path = AUTH_CONFIG_EXAMPLE_PATH
|
|
||||||
else:
|
|
||||||
print(f"Error: Neither {AUTH_CONFIG_PATH} nor {AUTH_CONFIG_EXAMPLE_PATH} found")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(config_path, 'r', encoding='utf-8') as f:
|
|
||||||
config = json.load(f)
|
|
||||||
return config
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
print(f"Error parsing auth config: {e}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error loading auth config: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_users() -> List[Dict[str, str]]:
|
|
||||||
"""
|
|
||||||
Возвращает список пользователей из конфига.
|
|
||||||
"""
|
|
||||||
config = load_auth_config()
|
|
||||||
if not config:
|
|
||||||
return []
|
|
||||||
|
|
||||||
return config.get('users', [])
|
|
||||||
|
|
||||||
|
|
||||||
def validate_credentials(username: str, password: str) -> bool:
|
|
||||||
"""
|
|
||||||
Проверяет правильность логина и пароля.
|
|
||||||
"""
|
|
||||||
users = get_users()
|
|
||||||
|
|
||||||
for user in users:
|
|
||||||
if user.get('username') == username and user.get('password') == password:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
Flask==3.0.0
|
|
||||||
flask-cors==4.0.0
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,597 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Lichess Bot Users Monitor</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
background: #D2B48C;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 400px 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
height: calc(100vh - 40px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
background: #F5E6D3;
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gamers-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #5C4033;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats {
|
|
||||||
background: #E8D5B7;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #5C4033;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats strong {
|
|
||||||
color: #8B6F47;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
border: 2px solid #e0e0e0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #8B6F47;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-list {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item {
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background: #E8D5B7;
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item:hover {
|
|
||||||
background: #DDC9A3;
|
|
||||||
transform: translateX(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item.active {
|
|
||||||
background: #8B6F47;
|
|
||||||
color: white;
|
|
||||||
border-color: #6B5432;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item.active .user-stats {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5C4033;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item.active .user-info {
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-stats {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8B6F47;
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item.active .user-stats {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-stats span {
|
|
||||||
padding: 3px 8px;
|
|
||||||
background: #F5E6D3;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item.active .user-stats span {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gamers-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gamers-table th {
|
|
||||||
background: #E8D5B7;
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #5C4033;
|
|
||||||
border-bottom: 2px solid #D2B48C;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gamers-table td {
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid #D2B48C;
|
|
||||||
color: #5C4033;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gamers-table tr:hover {
|
|
||||||
background: #E8D5B7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-secondary {
|
|
||||||
background: #e2e3e5;
|
|
||||||
color: #383d41;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-token {
|
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-user-info {
|
|
||||||
background: #E8D5B7;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-user-info h2 {
|
|
||||||
color: #5C4033;
|
|
||||||
font-size: 18px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-user-info p {
|
|
||||||
color: #5C4033;
|
|
||||||
font-size: 14px;
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gamer-link {
|
|
||||||
color: #8B6F47 !important;
|
|
||||||
text-decoration: none !important;
|
|
||||||
transition: color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gamer-link:hover {
|
|
||||||
color: #6B5432 !important;
|
|
||||||
text-decoration: underline !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-link {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-link:hover {
|
|
||||||
color: #6B5432;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item.active .user-link {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item.active .user-link:hover {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.username-link {
|
|
||||||
color: #8B6F47;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.username-link:hover {
|
|
||||||
color: #6B5432;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item.active .username-link {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item.active .username-link:hover {
|
|
||||||
color: rgba(255, 255, 255, 0.95);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Левая панель: Пользователи -->
|
|
||||||
<div class="panel users-panel">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
|
||||||
<h1 style="margin: 0;">👥 Пользователи</h1>
|
|
||||||
<a href="/logout" style="padding: 8px 16px; background: #8B6F47; color: white; text-decoration: none; border-radius: 8px; font-size: 14px; transition: background 0.3s;" onmouseover="this.style.background='#6B5432'" onmouseout="this.style.background='#8B6F47'">Выход</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats">
|
|
||||||
Всего пользователей: <strong id="total-users">0</strong> (сегодня: <strong id="users-today">0</strong>)<br>
|
|
||||||
👤 Пользователей без игроков: <strong id="users-without-gamers">0</strong> (<strong id="users-without-gamers-percent">0</strong>%)<br>
|
|
||||||
🇷🇺 Пользователей с русским языком: <strong id="users-with-ru-language">0</strong> (<strong id="users-ru-language-percent">0</strong>%)<br>
|
|
||||||
Кол-во игроков: <strong id="total-gamers">0</strong> (сегодня: <strong id="gamers-today">0</strong>)
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats" style="margin-top: 15px; padding: 15px; background: #E8D5B7; border-radius: 8px;">
|
|
||||||
<h3 style="margin-top: 0; margin-bottom: 10px; font-size: 16px;">📨 Счетчики сообщений</h3>
|
|
||||||
<div style="margin-bottom: 8px;">
|
|
||||||
Всего отправлено: <strong id="total-messages-all">0</strong>
|
|
||||||
</div>
|
|
||||||
<div style="margin-bottom: 8px;">
|
|
||||||
Сегодня отправлено: <strong id="total-messages-today">0</strong>
|
|
||||||
</div>
|
|
||||||
<div id="message-stats-by-command" style="margin-top: 10px; font-size: 12px; color: #666;">
|
|
||||||
<div style="display: flex; gap: 20px;">
|
|
||||||
<div style="flex: 1;">
|
|
||||||
<div style="margin-bottom: 8px; font-weight: bold;">За все время:</div>
|
|
||||||
<div id="command-stats-all-time"></div>
|
|
||||||
</div>
|
|
||||||
<div style="flex: 1;">
|
|
||||||
<div style="margin-bottom: 8px; font-weight: bold;">За сегодня:</div>
|
|
||||||
<div id="command-stats-today"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="search-box">
|
|
||||||
<input type="text" id="search-input" placeholder="Поиск по имени или никнейму...">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="users-list" id="users-list">
|
|
||||||
<div class="loading">Загрузка...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Правая панель: Игроки -->
|
|
||||||
<div class="panel gamers-panel">
|
|
||||||
<h1>🎮 Отслеживаемые игроки</h1>
|
|
||||||
|
|
||||||
<div id="selected-user-info" style="display: none;">
|
|
||||||
<div class="selected-user-info">
|
|
||||||
<h2 id="selected-user-name">Выберите пользователя</h2>
|
|
||||||
<p id="selected-user-username"></p>
|
|
||||||
<p id="selected-user-date"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="gamers-table-container">
|
|
||||||
<div class="empty">Выберите пользователя для просмотра его игроков</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let users = [];
|
|
||||||
let selectedUserId = null;
|
|
||||||
let filteredUsers = [];
|
|
||||||
|
|
||||||
// Загрузка пользователей
|
|
||||||
async function loadUsers() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/users');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
users = data.users;
|
|
||||||
document.getElementById('total-users').textContent = data.total_users;
|
|
||||||
document.getElementById('users-today').textContent = data.users_today || 0;
|
|
||||||
document.getElementById('users-without-gamers').textContent = data.users_without_gamers || 0;
|
|
||||||
document.getElementById('users-without-gamers-percent').textContent = data.users_without_gamers_percent || 0;
|
|
||||||
document.getElementById('users-with-ru-language').textContent = data.users_with_ru_language || 0;
|
|
||||||
document.getElementById('users-ru-language-percent').textContent = data.users_ru_language_percent || 0;
|
|
||||||
document.getElementById('total-gamers').textContent = data.total_gamers;
|
|
||||||
document.getElementById('gamers-today').textContent = data.gamers_today || 0;
|
|
||||||
|
|
||||||
// Update message counters
|
|
||||||
if (data.message_stats) {
|
|
||||||
document.getElementById('total-messages-all').textContent = data.message_stats.total_all_time || 0;
|
|
||||||
document.getElementById('total-messages-today').textContent = data.message_stats.total_today || 0;
|
|
||||||
|
|
||||||
// Render command stats in two columns
|
|
||||||
const commandStatsAllTime = document.getElementById('command-stats-all-time');
|
|
||||||
const commandStatsToday = document.getElementById('command-stats-today');
|
|
||||||
|
|
||||||
if (data.message_stats.by_command && Object.keys(data.message_stats.by_command).length > 0) {
|
|
||||||
// Filter out excluded commands (already filtered on server, but double-check)
|
|
||||||
const excludedCommands = ['start', 'lang', 'resetlang'];
|
|
||||||
const filteredCommands = Object.entries(data.message_stats.by_command)
|
|
||||||
.filter(([cmd]) => !excludedCommands.includes(cmd))
|
|
||||||
.sort((a, b) => a[0].localeCompare(b[0])); // Sort alphabetically
|
|
||||||
|
|
||||||
if (filteredCommands.length > 0) {
|
|
||||||
// Render "All time" column
|
|
||||||
commandStatsAllTime.innerHTML = filteredCommands
|
|
||||||
.map(([cmd, stats]) => {
|
|
||||||
return `<div style="margin-bottom: 3px;">
|
|
||||||
• ${cmd}: <strong>${stats.total}</strong>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Render "Today" column
|
|
||||||
commandStatsToday.innerHTML = filteredCommands
|
|
||||||
.map(([cmd, stats]) => {
|
|
||||||
return `<div style="margin-bottom: 3px;">
|
|
||||||
• ${cmd}: <strong>${stats.today}</strong>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
} else {
|
|
||||||
commandStatsAllTime.innerHTML = '<div style="color: #999;">Нет данных</div>';
|
|
||||||
commandStatsToday.innerHTML = '<div style="color: #999;">Нет данных</div>';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
commandStatsAllTime.innerHTML = '<div style="color: #999;">Нет данных</div>';
|
|
||||||
commandStatsToday.innerHTML = '<div style="color: #999;">Нет данных</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredUsers = users;
|
|
||||||
renderUsers();
|
|
||||||
|
|
||||||
// Выбираем первого пользователя по умолчанию
|
|
||||||
if (users.length > 0) {
|
|
||||||
selectUser(users[0].user_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading users:', error);
|
|
||||||
document.getElementById('users-list').innerHTML = '<div class="empty">Ошибка загрузки данных</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Рендеринг пользователей
|
|
||||||
function renderUsers() {
|
|
||||||
const usersList = document.getElementById('users-list');
|
|
||||||
|
|
||||||
if (filteredUsers.length === 0) {
|
|
||||||
usersList.innerHTML = '<div class="empty">Пользователи не найдены</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
usersList.innerHTML = filteredUsers.map(user => {
|
|
||||||
const userName = escapeHtml(user.first_name);
|
|
||||||
const username = user.username && user.username !== '-' ? escapeHtml(user.username) : null;
|
|
||||||
const telegramLink = username ? `https://t.me/${username}` : `tg://user?id=${user.user_id}`;
|
|
||||||
const botLanguage = user.bot_language || 'en';
|
|
||||||
const languageBadge = botLanguage === 'ru' ? '🇷🇺 ru' : '🇬🇧 en';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="user-item ${selectedUserId === user.user_id ? 'active' : ''}" onclick="selectUser(${user.user_id})">
|
|
||||||
<div class="user-name">
|
|
||||||
<a href="${telegramLink}" target="_blank" class="user-link" onclick="event.stopPropagation();">
|
|
||||||
${userName}
|
|
||||||
</a>
|
|
||||||
<span style="font-size: 12px; color: #666; margin-left: 5px;">${languageBadge}</span>
|
|
||||||
</div>
|
|
||||||
<div class="user-info">
|
|
||||||
${username ?
|
|
||||||
`<a href="${telegramLink}" target="_blank" class="username-link" onclick="event.stopPropagation();">@${username}</a>` :
|
|
||||||
`<span>ID: ${user.user_id}</span>`
|
|
||||||
} • ID: ${user.user_id}
|
|
||||||
</div>
|
|
||||||
<div class="user-info">Добавлен: ${formatDate(user.created_at)}</div>
|
|
||||||
<div class="user-stats">
|
|
||||||
<span>📊 ${user.gamer_count} игроков</span>
|
|
||||||
<span>⏰ ${user.monitored_gamers} с уведомл.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Выбор пользователя
|
|
||||||
async function selectUser(userId) {
|
|
||||||
selectedUserId = userId;
|
|
||||||
renderUsers();
|
|
||||||
|
|
||||||
// Находим выбранного пользователя
|
|
||||||
const user = users.find(u => u.user_id === userId);
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
document.getElementById('selected-user-info').style.display = 'block';
|
|
||||||
const username = user.username && user.username !== '-' ? user.username : null;
|
|
||||||
const telegramLink = username ? `https://t.me/${username}` : `tg://user?id=${user.user_id}`;
|
|
||||||
|
|
||||||
// Create name with link
|
|
||||||
const nameElement = document.getElementById('selected-user-name');
|
|
||||||
nameElement.innerHTML = `<a href="${telegramLink}" target="_blank" class="user-link" style="color: #5C4033; text-decoration: none;">${escapeHtml(user.first_name)}</a>`;
|
|
||||||
|
|
||||||
// Create username with link
|
|
||||||
const usernameElement = document.getElementById('selected-user-username');
|
|
||||||
const botLanguage = user.bot_language || 'en';
|
|
||||||
const languageBadge = botLanguage === 'ru' ? '🇷🇺 ru' : '🇬🇧 en';
|
|
||||||
if (username) {
|
|
||||||
usernameElement.innerHTML = `<a href="${telegramLink}" target="_blank" class="username-link" style="color: #8B6F47; text-decoration: none;">@${escapeHtml(username)}</a> • ID: ${user.user_id} • ${languageBadge}`;
|
|
||||||
} else {
|
|
||||||
usernameElement.textContent = `ID: ${user.user_id} • ${languageBadge}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('selected-user-date').textContent = `Добавлен: ${formatDate(user.created_at)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузка игроков
|
|
||||||
await loadGamers(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузка игроков пользователя
|
|
||||||
async function loadGamers(userId) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/users/${userId}/gamers`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
renderGamers(data.gamers);
|
|
||||||
} else {
|
|
||||||
document.getElementById('gamers-table-container').innerHTML =
|
|
||||||
'<div class="empty">Ошибка загрузки игроков</div>';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading gamers:', error);
|
|
||||||
document.getElementById('gamers-table-container').innerHTML =
|
|
||||||
'<div class="empty">Ошибка загрузки данных</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Рендеринг игроков
|
|
||||||
function renderGamers(gamers) {
|
|
||||||
const container = document.getElementById('gamers-table-container');
|
|
||||||
|
|
||||||
if (gamers.length === 0) {
|
|
||||||
container.innerHTML = '<div class="empty">Нет отслеживаемых игроков</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const table = `
|
|
||||||
<table class="gamers-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Игрок</th>
|
|
||||||
<th>Токен</th>
|
|
||||||
<th>Период уведомлений</th>
|
|
||||||
<th>Добавлен</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${gamers.map(gamer => `
|
|
||||||
<tr>
|
|
||||||
<td><strong><a href="https://lichess.org/@/${gamer.username}" target="_blank" class="gamer-link">${escapeHtml(gamer.username)}</a></strong> (ID: ${gamer.id})</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge badge-token">
|
|
||||||
${gamer.has_token ? '🔑 Есть' : '❌ Нет'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
${gamer.period_minutes > 0 ?
|
|
||||||
`⏰ ${gamer.period_minutes} мин` :
|
|
||||||
'<span style="color: #999;">—</span>'}
|
|
||||||
</td>
|
|
||||||
<td style="color: #666;">${formatDate(gamer.created_at)}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.innerHTML = table;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Поиск
|
|
||||||
document.getElementById('search-input').addEventListener('input', function(e) {
|
|
||||||
const searchTerm = e.target.value.toLowerCase();
|
|
||||||
|
|
||||||
filteredUsers = users.filter(user => {
|
|
||||||
const name = (user.first_name || '').toLowerCase();
|
|
||||||
const username = (user.username || '').toLowerCase();
|
|
||||||
return name.includes(searchTerm) || username.includes(searchTerm);
|
|
||||||
});
|
|
||||||
|
|
||||||
renderUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Утилиты
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString) {
|
|
||||||
if (!dateString) return '—';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('ru-RU') + ' ' + date.toLocaleTimeString('ru-RU', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Инициализация
|
|
||||||
loadUsers();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Вход в админ-панель</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
background: #D2B48C;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-container {
|
|
||||||
background: #F5E6D3;
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 40px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
|
||||||
max-width: 400px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #5C4033;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
color: #5C4033;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"],
|
|
||||||
input[type="password"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
border: 2px solid #e0e0e0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"]:focus,
|
|
||||||
input[type="password"]:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #8B6F47;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message.show {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px;
|
|
||||||
background: #8B6F47;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background: #6B5432;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="login-container">
|
|
||||||
<h1>🔐 Вход в админ-панель</h1>
|
|
||||||
|
|
||||||
<div class="error-message" id="error-message"></div>
|
|
||||||
|
|
||||||
<form method="POST" action="/login" id="login-form">
|
|
||||||
{% if error %}
|
|
||||||
<div class="error-message show">{{ error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="username">Логин:</label>
|
|
||||||
<input type="text" id="username" name="username" required autofocus>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Пароль:</label>
|
|
||||||
<input type="password" id="password" name="password" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit">Войти</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Показываем ошибку, если она есть в URL
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const error = urlParams.get('error');
|
|
||||||
if (error) {
|
|
||||||
const errorDiv = document.getElementById('error-message');
|
|
||||||
errorDiv.textContent = decodeURIComponent(error);
|
|
||||||
errorDiv.classList.add('show');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработка отправки формы
|
|
||||||
document.getElementById('login-form').addEventListener('submit', function(e) {
|
|
||||||
const username = document.getElementById('username').value.trim();
|
|
||||||
const password = document.getElementById('password').value;
|
|
||||||
|
|
||||||
if (!username || !password) {
|
|
||||||
e.preventDefault();
|
|
||||||
const errorDiv = document.getElementById('error-message');
|
|
||||||
errorDiv.textContent = 'Пожалуйста, заполните все поля';
|
|
||||||
errorDiv.classList.add('show');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
66
README.md
66
README.md
|
|
@ -1,14 +1,13 @@
|
||||||
# Lichess Statistics Ecosystem
|
# Lichess Statistics Ecosystem
|
||||||
|
|
||||||
Полнофункциональная система для отслеживания статистики игроков Lichess.org с Telegram ботом и веб-интерфейсом.
|
Полнофункциональная система для отслеживания статистики игроков Lichess.org с Telegram ботом.
|
||||||
|
|
||||||
## 🎯 Описание проекта
|
## 🎯 Описание проекта
|
||||||
|
|
||||||
Система состоит из трех взаимосвязанных компонентов:
|
Система состоит из двух взаимосвязанных компонентов:
|
||||||
|
|
||||||
1. **LichessWebServices** - REST API для получения статистики игроков Lichess
|
1. **LichessWebServices** - REST API для получения статистики игроков Lichess
|
||||||
2. **LichessClientTG_bot** - Telegram бот для управления подписками и уведомлений
|
2. **LichessClientTG_bot** - Telegram бот для управления подписками и уведомлений
|
||||||
3. **LichessWebView** - Веб-интерфейс для просмотра пользователей и их игроков
|
|
||||||
|
|
||||||
## 🏗️ Архитектура
|
## 🏗️ Архитектура
|
||||||
|
|
||||||
|
|
@ -21,15 +20,13 @@
|
||||||
│ │ (Управление подписками, уведомления) │ │
|
│ │ (Управление подписками, уведомления) │ │
|
||||||
│ └────────┬───────────────────────────────────────────┘ │
|
│ └────────┬───────────────────────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ├──────────────────────┐ │
|
│ ┌────────▼───────────┐ │
|
||||||
│ │ │ │
|
│ │ LichessWebServices │ │
|
||||||
│ ┌────────▼──────────┐ ┌──────▼──────────────┐ │
|
│ │ │ │
|
||||||
│ │ LichessWebServices │ │ LichessWebView │ │
|
│ │ REST API для │ │
|
||||||
│ │ │ │ │ │
|
│ │ получения │ │
|
||||||
│ │ REST API для │ │ Веб-интерфейс │ │
|
│ │ статистики │ │
|
||||||
│ │ получения │ │ для просмотра │ │
|
│ └────────┬────────────┘ │
|
||||||
│ │ статистики │ │ пользователей │ │
|
|
||||||
│ └────────┬───────────┘ └────────────────────┘ │
|
|
||||||
│ │ │
|
│ │ │
|
||||||
└───────────┼──────────────────────────────────────────────┘
|
└───────────┼──────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
|
|
@ -59,7 +56,6 @@ cd LichessStatTgWeb
|
||||||
После запуска доступны:
|
После запуска доступны:
|
||||||
|
|
||||||
- **API документация**: http://localhost:8001/docs
|
- **API документация**: http://localhost:8001/docs
|
||||||
- **Веб-интерфейс**: http://localhost:5000
|
|
||||||
- **Telegram бот**: работает в фоне
|
- **Telegram бот**: работает в фоне
|
||||||
|
|
||||||
## 📦 Структура проекта
|
## 📦 Структура проекта
|
||||||
|
|
@ -79,11 +75,6 @@ LichessStatTgWeb/
|
||||||
│ ├── formatters.py # Форматирование ответов
|
│ ├── formatters.py # Форматирование ответов
|
||||||
│ └── config.py # Конфигурация
|
│ └── config.py # Конфигурация
|
||||||
│
|
│
|
||||||
├── LichessWebView/ # Веб-интерфейс
|
|
||||||
│ ├── app.py # Flask приложение
|
|
||||||
│ └── templates/ # HTML шаблоны
|
|
||||||
│ └── index.html # Главная страница
|
|
||||||
│
|
|
||||||
├── docker-compose.yml # Общая конфигурация контейнеров
|
├── docker-compose.yml # Общая конфигурация контейнеров
|
||||||
├── start.sh # Скрипт запуска всех сервисов
|
├── start.sh # Скрипт запуска всех сервисов
|
||||||
└── README.md # Этот файл
|
└── README.md # Этот файл
|
||||||
|
|
@ -142,19 +133,6 @@ Telegram бот для управления отслеживанием игро
|
||||||
|
|
||||||
**Подробнее о боте:** см. [about.md](about.md)
|
**Подробнее о боте:** см. [about.md](about.md)
|
||||||
|
|
||||||
### 3. LichessWebView (Веб-интерфейс)
|
|
||||||
|
|
||||||
Веб-интерфейс для просмотра пользователей и их игроков.
|
|
||||||
|
|
||||||
**Возможности:**
|
|
||||||
- Просмотр всех пользователей бота
|
|
||||||
- Фильтрация пользователей по имени/никнейму
|
|
||||||
- Отображение игроков каждого пользователя
|
|
||||||
- Статистика по каждому пользователю
|
|
||||||
- Отображение активных игроков и периодов
|
|
||||||
- Отображение языка пользователя (🇷🇺 ru / 🇬🇧 en)
|
|
||||||
- Статистика пользователей с русским языком (количество и процент)
|
|
||||||
|
|
||||||
## 🗄️ База данных
|
## 🗄️ База данных
|
||||||
|
|
||||||
Система использует SQLite базу данных с таблицами:
|
Система использует SQLite базу данных с таблицами:
|
||||||
|
|
@ -187,20 +165,15 @@ docker-compose down
|
||||||
|
|
||||||
### Telegram бот
|
### Telegram бот
|
||||||
|
|
||||||
Токены ботов настраиваются в `LichessClientTG_bot/config.py`:
|
Токены ботов задаются переменными окружения в `.env` (см. `.env.example`):
|
||||||
```python
|
```
|
||||||
IS_PROD = False # True для продакшн, False для тестовых токенов
|
TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN
|
||||||
|
ADMINPANEL_TELEGRAM_BOT_TOKEN=YOUR_ADMIN_BOT_TOKEN
|
||||||
# Production токены (используются при IS_PROD = True)
|
|
||||||
TELEGRAM_BOT_TOKEN_PROD = "YOUR_PROD_TOKEN"
|
|
||||||
ADMINPANEL_TELEGRAM_BOT_TOKEN_PROD = "YOUR_ADMIN_PROD_TOKEN"
|
|
||||||
|
|
||||||
# Test токены (используются при IS_PROD = False)
|
|
||||||
TELEGRAM_BOT_TOKEN_TEST = "YOUR_TEST_TOKEN"
|
|
||||||
ADMINPANEL_TELEGRAM_BOT_TOKEN_TEST = "YOUR_ADMIN_TEST_TOKEN"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Система автоматически выбирает нужные токены в зависимости от значения `IS_PROD`.
|
`LichessClientTG_bot/config.py` читает их через `os.getenv(...)`. Разделение на тестовый и
|
||||||
|
продакшн стенды обеспечивается не флагом в коде, а раздельными `.env` на каждом окружении:
|
||||||
|
на локальном/тестовом стенде — тестовые токены, на проде — свои, боевые.
|
||||||
|
|
||||||
### Lichess API
|
### Lichess API
|
||||||
|
|
||||||
|
|
@ -231,10 +204,6 @@ docker-compose up -d
|
||||||
# Telegram бот
|
# Telegram бот
|
||||||
cd LichessClientTG_bot
|
cd LichessClientTG_bot
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
# Веб-интерфейс
|
|
||||||
cd LichessWebView
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Логи
|
### Логи
|
||||||
|
|
@ -245,9 +214,6 @@ docker logs lichesswebservices_lichess-api_1 -f
|
||||||
|
|
||||||
# Логи бота
|
# Логи бота
|
||||||
docker logs lichess-telegram-bot -f
|
docker logs lichess-telegram-bot -f
|
||||||
|
|
||||||
# Логи веб-интерфейса
|
|
||||||
docker logs lichess-web-view -f
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📝 Лицензия
|
## 📝 Лицензия
|
||||||
|
|
|
||||||
|
|
@ -50,17 +50,6 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- lichess-api
|
- lichess-api
|
||||||
|
|
||||||
web-view:
|
|
||||||
image: 192.168.8.174:3000/vrubel/lichess-web:latest
|
|
||||||
container_name: lichess-web-view
|
|
||||||
ports:
|
|
||||||
- "5000:5000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data:ro
|
|
||||||
restart: always
|
|
||||||
depends_on:
|
|
||||||
- lichess-bot
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: lichess-network
|
name: lichess-network
|
||||||
|
|
|
||||||
|
|
@ -62,20 +62,6 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- lichess-api
|
- lichess-api
|
||||||
|
|
||||||
# Web View Interface
|
|
||||||
web-view:
|
|
||||||
build: ./LichessWebView
|
|
||||||
image: 192.168.8.174:3000/vrubel/lichess-web:latest
|
|
||||||
container_name: lichess-web-view
|
|
||||||
ports:
|
|
||||||
- "5000:5000"
|
|
||||||
volumes:
|
|
||||||
- ./LichessClientTG_bot/data:/app/data:ro
|
|
||||||
- ./LichessWebView:/app
|
|
||||||
restart: always
|
|
||||||
depends_on:
|
|
||||||
- lichess-bot
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: lichess-network
|
name: lichess-network
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@
|
||||||
|
|
||||||
## Обзор проекта
|
## Обзор проекта
|
||||||
|
|
||||||
Проект состоит из трех основных компонентов:
|
Проект состоит из двух основных компонентов:
|
||||||
|
|
||||||
1. **LichessWebServices** - REST API для получения статистики с Lichess
|
1. **LichessWebServices** - REST API для получения статистики с Lichess
|
||||||
2. **LichessClientTG_bot** - Telegram бот для отслеживания игроков
|
2. **LichessClientTG_bot** - Telegram бот для отслеживания игроков
|
||||||
3. **LichessWebView** - Веб-интерфейс для просмотра пользователей и игроков
|
|
||||||
|
|
||||||
## Структура базы данных
|
## Структура базы данных
|
||||||
|
|
||||||
|
|
@ -116,17 +115,15 @@ CREATE TABLE user_gamers (
|
||||||
|
|
||||||
## Docker Compose
|
## Docker Compose
|
||||||
|
|
||||||
Все три сервиса запускаются через единый `docker-compose.yml`:
|
Оба сервиса запускаются через единый `docker-compose.yml`:
|
||||||
|
|
||||||
- **lichess-api**: API на порту 8001
|
- **lichess-api**: API на порту 8001
|
||||||
- **lichess-telegram-bot**: Бот (host network)
|
- **lichess-telegram-bot**: Бот (host network)
|
||||||
- **lichess-web-view**: Веб-интерфейс на порту 5000
|
|
||||||
|
|
||||||
База данных монтируется как bind mount: `./LichessClientTG_bot/data:/app/data`
|
База данных монтируется как bind mount: `./LichessClientTG_bot/data:/app/data`
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- Токены Lichess API хранятся в базе данных и используются только для запросов к Lichess
|
- Токены Lichess API хранятся в базе данных и используются только для запросов к Lichess
|
||||||
- Веб-интерфейс работает **без пароля** - доступен всем кто знает URL
|
|
||||||
- Telegram бот токен хранится в `config.py` (можно вынести в `.env`)
|
- Telegram бот токен хранится в `config.py` (можно вынести в `.env`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,12 @@ chmod +x start.sh
|
||||||
- Остановит существующие контейнеры (если есть)
|
- Остановит существующие контейнеры (если есть)
|
||||||
- Создаст бэкап базы данных
|
- Создаст бэкап базы данных
|
||||||
- Пересоберет Docker образы
|
- Пересоберет Docker образы
|
||||||
- Запустит все три сервиса
|
- Запустит оба сервиса
|
||||||
|
|
||||||
### Шаг 4: Проверка
|
### Шаг 4: Проверка
|
||||||
|
|
||||||
Откройте в браузере:
|
Откройте в браузере:
|
||||||
- **API документация**: http://localhost:8001/docs
|
- **API документация**: http://localhost:8001/docs
|
||||||
- **Веб-интерфейс**: http://localhost:5000
|
|
||||||
|
|
||||||
Проверьте логи:
|
Проверьте логи:
|
||||||
|
|
||||||
|
|
@ -127,12 +126,6 @@ docker logs lichess-telegram-bot -f
|
||||||
docker logs lichess-api -f
|
docker logs lichess-api -f
|
||||||
```
|
```
|
||||||
|
|
||||||
### Только веб-интерфейс
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs lichess-web-view -f
|
|
||||||
```
|
|
||||||
|
|
||||||
## Остановка и удаление
|
## Остановка и удаление
|
||||||
|
|
||||||
### Остановка без удаления данных
|
### Остановка без удаления данных
|
||||||
|
|
@ -179,7 +172,6 @@ chmod +x import_db.sh start.sh
|
||||||
```bash
|
```bash
|
||||||
# Найдите процесс на порту
|
# Найдите процесс на порту
|
||||||
sudo fuser -k 8001/tcp # Для API
|
sudo fuser -k 8001/tcp # Для API
|
||||||
sudo fuser -k 5000/tcp # Для веб-интерфейса
|
|
||||||
|
|
||||||
# Перезапустите
|
# Перезапустите
|
||||||
./start.sh
|
./start.sh
|
||||||
|
|
@ -212,7 +204,6 @@ LichessStatTgWeb/
|
||||||
├── LichessClientTG_bot/ # Telegram бот
|
├── LichessClientTG_bot/ # Telegram бот
|
||||||
│ └── data/
|
│ └── data/
|
||||||
│ └── lichess_bot.db # База данных (НЕ в git!)
|
│ └── lichess_bot.db # База данных (НЕ в git!)
|
||||||
├── LichessWebView/ # Веб-интерфейс
|
|
||||||
├── backups/ # Бэкапы БД (НЕ в git!)
|
├── backups/ # Бэкапы БД (НЕ в git!)
|
||||||
├── docs/ # Документация
|
├── docs/ # Документация
|
||||||
├── docker-compose.yml # Конфигурация Docker
|
├── docker-compose.yml # Конфигурация Docker
|
||||||
|
|
|
||||||
1
start.sh
1
start.sh
|
|
@ -54,7 +54,6 @@ echo "✅ Все сервисы запущены!"
|
||||||
echo ""
|
echo ""
|
||||||
echo "🌐 Доступные сервисы:"
|
echo "🌐 Доступные сервисы:"
|
||||||
echo " - API документация: http://localhost:8002/docs"
|
echo " - API документация: http://localhost:8002/docs"
|
||||||
echo " - Веб-интерфейс: http://localhost:5000"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "📋 Для просмотра логов используйте:"
|
echo "📋 Для просмотра логов используйте:"
|
||||||
echo " ${COMPOSE_CMD_DISPLAY} logs -f"
|
echo " ${COMPOSE_CMD_DISPLAY} logs -f"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue