2150 lines
84 KiB
Python
2150 lines
84 KiB
Python
|
|
# ----------------------------------------
|
||
|
|
# UrNetwork Stats Dashboard - Enhanced Multi-Account Edition
|
||
|
|
# ----------------------------------------
|
||
|
|
# Enhanced with modern design, multi-account support, and combined statistics
|
||
|
|
# ----------------------------------------
|
||
|
|
|
||
|
|
import os
|
||
|
|
import time
|
||
|
|
import datetime
|
||
|
|
import requests
|
||
|
|
import logging
|
||
|
|
import json
|
||
|
|
from functools import wraps
|
||
|
|
from flask import (
|
||
|
|
Flask, request, render_template_string,
|
||
|
|
redirect, url_for, flash, session, g, jsonify
|
||
|
|
)
|
||
|
|
from flask_sqlalchemy import SQLAlchemy
|
||
|
|
from flask_apscheduler import APScheduler
|
||
|
|
from dateutil.parser import isoparse
|
||
|
|
import secrets
|
||
|
|
from string import Template
|
||
|
|
|
||
|
|
# --- Application Setup & Configuration ---
|
||
|
|
|
||
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||
|
|
|
||
|
|
ENV_FILE = ".env"
|
||
|
|
|
||
|
|
def load_env():
|
||
|
|
"""Load environment variables from .env file."""
|
||
|
|
if os.path.exists(ENV_FILE):
|
||
|
|
with open(ENV_FILE, 'r') as f:
|
||
|
|
for line in f:
|
||
|
|
if '=' in line and not line.strip().startswith('#'):
|
||
|
|
key, value = line.strip().split('=', 1)
|
||
|
|
os.environ[key] = value
|
||
|
|
|
||
|
|
load_env()
|
||
|
|
|
||
|
|
class Config:
|
||
|
|
"""Flask configuration."""
|
||
|
|
SCHEDULER_API_ENABLED = True
|
||
|
|
# Use absolute path for database
|
||
|
|
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||
|
|
INSTANCE_DIR = os.path.join(BASE_DIR, 'instance')
|
||
|
|
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", f"sqlite:///{os.path.join(INSTANCE_DIR, 'transfer_stats.db')}")
|
||
|
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||
|
|
SECRET_KEY = os.getenv("SECRET_KEY", "default-secret-key-for-initial-setup")
|
||
|
|
UR_API_BASE = "https://api.bringyour.com"
|
||
|
|
FORCE_HTTPS = os.getenv("FORCE_HTTPS", "False").lower() in ('true', '1', 't')
|
||
|
|
ENABLE_ACCOUNT_STATS = os.getenv("ENABLE_ACCOUNT_STATS", "True").lower() in ('true', '1', 't')
|
||
|
|
ENABLE_LEADERBOARD = os.getenv("ENABLE_LEADERBOARD", "True").lower() in ('true', '1', 't')
|
||
|
|
ENABLE_DEVICE_STATS = os.getenv("ENABLE_DEVICE_STATS", "True").lower() in ('true', '1', 't')
|
||
|
|
|
||
|
|
# --- Flask App Initialization ---
|
||
|
|
|
||
|
|
# Ensure instance folder exists BEFORE creating the app
|
||
|
|
instance_path = Config.INSTANCE_DIR
|
||
|
|
if not os.path.exists(instance_path):
|
||
|
|
os.makedirs(instance_path)
|
||
|
|
logging.info(f"Created instance directory: {instance_path}")
|
||
|
|
|
||
|
|
app = Flask(__name__)
|
||
|
|
app.config.from_object(Config)
|
||
|
|
|
||
|
|
db = SQLAlchemy(app)
|
||
|
|
scheduler = APScheduler()
|
||
|
|
scheduler.init_app(app)
|
||
|
|
|
||
|
|
# --- Database Models ---
|
||
|
|
|
||
|
|
class Account(db.Model):
|
||
|
|
"""Represents a UrNetwork account."""
|
||
|
|
__tablename__ = 'accounts'
|
||
|
|
id = db.Column(db.Integer, primary_key=True)
|
||
|
|
username = db.Column(db.String(100), unique=True, nullable=False)
|
||
|
|
password = db.Column(db.String(200), nullable=False)
|
||
|
|
nickname = db.Column(db.String(100), nullable=True)
|
||
|
|
is_active = db.Column(db.Boolean, default=True)
|
||
|
|
created_at = db.Column(db.DateTime, server_default=db.func.now())
|
||
|
|
|
||
|
|
class Stats(db.Model):
|
||
|
|
"""Represents a snapshot of paid vs unpaid bytes at a given timestamp."""
|
||
|
|
__tablename__ = 'stats'
|
||
|
|
id = db.Column(db.Integer, primary_key=True)
|
||
|
|
account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=False)
|
||
|
|
timestamp = db.Column(db.DateTime, server_default=db.func.now())
|
||
|
|
paid_bytes = db.Column(db.BigInteger, nullable=False)
|
||
|
|
paid_gb = db.Column(db.Float, nullable=False)
|
||
|
|
unpaid_bytes = db.Column(db.BigInteger, nullable=False)
|
||
|
|
unpaid_gb = db.Column(db.Float, nullable=False)
|
||
|
|
account = db.relationship('Account', backref='stats')
|
||
|
|
|
||
|
|
class Webhook(db.Model):
|
||
|
|
"""Represents a webhook URL with an optional custom payload."""
|
||
|
|
__tablename__ = 'webhook'
|
||
|
|
id = db.Column(db.Integer, primary_key=True)
|
||
|
|
url = db.Column(db.String, unique=True, nullable=False)
|
||
|
|
payload = db.Column(db.Text, nullable=True)
|
||
|
|
|
||
|
|
class Setting(db.Model):
|
||
|
|
"""Represents a key-value setting for the application."""
|
||
|
|
__tablename__ = 'settings'
|
||
|
|
key = db.Column(db.String(50), primary_key=True)
|
||
|
|
value = db.Column(db.String(100), nullable=False)
|
||
|
|
|
||
|
|
# --- Helper Functions ---
|
||
|
|
|
||
|
|
def get_setting(key, default=None):
|
||
|
|
"""Gets a setting value from the database."""
|
||
|
|
setting = Setting.query.get(key)
|
||
|
|
return setting.value if setting else default
|
||
|
|
|
||
|
|
def get_boolean_setting(key):
|
||
|
|
"""Gets a boolean setting from the database."""
|
||
|
|
value = get_setting(key, 'False')
|
||
|
|
return value.lower() in ('true', '1', 't')
|
||
|
|
|
||
|
|
def is_installed():
|
||
|
|
"""Check if the application has been configured."""
|
||
|
|
return os.getenv("SECRET_KEY") and os.getenv("SECRET_KEY") != "default-secret-key-for-initial-setup"
|
||
|
|
|
||
|
|
def save_env_file(config_data):
|
||
|
|
"""Save configuration data to the .env file."""
|
||
|
|
try:
|
||
|
|
existing_env = {}
|
||
|
|
if os.path.exists(ENV_FILE):
|
||
|
|
with open(ENV_FILE, 'r') as f:
|
||
|
|
for line in f:
|
||
|
|
if '=' in line and not line.strip().startswith('#'):
|
||
|
|
key, value = line.strip().split('=', 1)
|
||
|
|
existing_env[key] = value
|
||
|
|
|
||
|
|
existing_env.update(config_data)
|
||
|
|
|
||
|
|
with open(ENV_FILE, "w") as f:
|
||
|
|
for key, value in existing_env.items():
|
||
|
|
f.write(f"{key}={value}\n")
|
||
|
|
if "ENABLE_ACCOUNT_STATS" not in existing_env:
|
||
|
|
f.write("\n# Feature Flags\n")
|
||
|
|
f.write("ENABLE_ACCOUNT_STATS=True\n")
|
||
|
|
f.write("ENABLE_LEADERBOARD=True\n")
|
||
|
|
f.write("ENABLE_DEVICE_STATS=True\n")
|
||
|
|
if "FORCE_HTTPS" not in existing_env:
|
||
|
|
f.write("\n# Security Settings\n")
|
||
|
|
f.write("FORCE_HTTPS=False\n")
|
||
|
|
load_env()
|
||
|
|
return True
|
||
|
|
except IOError as e:
|
||
|
|
logging.error(f"Failed to write to .env file: {e}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
def request_with_retry(method, url, retries=3, backoff=5, timeout=30, **kwargs):
|
||
|
|
"""Issue an HTTP request with retries."""
|
||
|
|
last_exc = None
|
||
|
|
for attempt in range(1, retries + 1):
|
||
|
|
try:
|
||
|
|
resp = requests.request(method, url, timeout=timeout, **kwargs)
|
||
|
|
resp.raise_for_status()
|
||
|
|
return resp
|
||
|
|
except requests.exceptions.RequestException as e:
|
||
|
|
last_exc = e
|
||
|
|
logging.warning(f"[{method.upper()} {url}] attempt {attempt}/{retries} failed: {e}")
|
||
|
|
if attempt < retries:
|
||
|
|
time.sleep(backoff)
|
||
|
|
logging.error(f"All {retries} attempts to {method.upper()} {url} failed: {last_exc}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
def get_jwt_from_credentials(user, password):
|
||
|
|
"""Fetch a new JWT token using username and password."""
|
||
|
|
try:
|
||
|
|
resp = request_with_retry(
|
||
|
|
"post",
|
||
|
|
f"{app.config['UR_API_BASE']}/auth/login-with-password",
|
||
|
|
headers={"Content-Type": "application/json"},
|
||
|
|
json={"user_auth": user, "password": password},
|
||
|
|
)
|
||
|
|
if not resp:
|
||
|
|
raise RuntimeError("API request failed after multiple retries.")
|
||
|
|
data = resp.json()
|
||
|
|
token = data.get("network", {}).get("by_jwt")
|
||
|
|
if not token:
|
||
|
|
err = data.get("message") or data.get("error") or str(data)
|
||
|
|
raise RuntimeError(f"Login failed: {err}")
|
||
|
|
return token
|
||
|
|
except Exception as e:
|
||
|
|
logging.error(f"Could not get JWT from credentials: {e}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
def get_valid_jwt(account):
|
||
|
|
"""Gets a valid JWT for API calls for a specific account."""
|
||
|
|
if not account:
|
||
|
|
return None
|
||
|
|
return get_jwt_from_credentials(account.username, account.password)
|
||
|
|
|
||
|
|
# --- API Fetch Functions ---
|
||
|
|
|
||
|
|
def fetch_transfer_stats(jwt_token):
|
||
|
|
"""Retrieve transfer statistics using the provided JWT."""
|
||
|
|
if not jwt_token: return None
|
||
|
|
resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/transfer/stats", headers={"Authorization": f"Bearer {jwt_token}"})
|
||
|
|
if not resp: return None
|
||
|
|
data = resp.json()
|
||
|
|
paid = data.get("paid_bytes_provided", 0)
|
||
|
|
unpaid = data.get("unpaid_bytes_provided", 0)
|
||
|
|
return {
|
||
|
|
"paid_bytes": paid,
|
||
|
|
"paid_gb": paid / 1e9,
|
||
|
|
"unpaid_bytes": unpaid,
|
||
|
|
"unpaid_gb": unpaid / 1e9
|
||
|
|
}
|
||
|
|
|
||
|
|
def fetch_payment_stats(jwt_token):
|
||
|
|
"""Retrieve account payment statistics using the provided JWT."""
|
||
|
|
if not jwt_token: return []
|
||
|
|
resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/account/payments", headers={"Authorization": f"Bearer {jwt_token}"})
|
||
|
|
if not resp: return []
|
||
|
|
return resp.json().get("account_payments", [])
|
||
|
|
|
||
|
|
def fetch_account_details(jwt_token):
|
||
|
|
"""Fetches various account details like points and referrals."""
|
||
|
|
if not jwt_token: return {}
|
||
|
|
headers = {"Authorization": f"Bearer {jwt_token}"}
|
||
|
|
details = {}
|
||
|
|
|
||
|
|
points_resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/account/points", headers=headers)
|
||
|
|
if points_resp:
|
||
|
|
points_data = points_resp.json().get("network_points", [])
|
||
|
|
details['points'] = sum(p.get('point_value', 0) for p in points_data)
|
||
|
|
|
||
|
|
referral_resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/account/referral-code", headers=headers)
|
||
|
|
if referral_resp:
|
||
|
|
details['referrals'] = referral_resp.json()
|
||
|
|
|
||
|
|
ranking_resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/network/ranking", headers=headers)
|
||
|
|
if ranking_resp:
|
||
|
|
details['ranking'] = ranking_resp.json().get('network_ranking', {})
|
||
|
|
|
||
|
|
return details
|
||
|
|
|
||
|
|
def fetch_leaderboard(jwt_token):
|
||
|
|
"""Fetches the global leaderboard."""
|
||
|
|
if not jwt_token: return []
|
||
|
|
headers = {"Authorization": f"Bearer {jwt_token}"}
|
||
|
|
resp = request_with_retry("post", f"{app.config['UR_API_BASE']}/stats/leaderboard", headers=headers, json={})
|
||
|
|
if not resp: return []
|
||
|
|
return resp.json().get("earners", [])
|
||
|
|
|
||
|
|
def fetch_devices(jwt_token):
|
||
|
|
"""Fetches the status of all network clients/devices."""
|
||
|
|
if not jwt_token: return []
|
||
|
|
headers = {"Authorization": f"Bearer {jwt_token}"}
|
||
|
|
resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/network/clients", headers=headers)
|
||
|
|
if not resp: return []
|
||
|
|
devices = resp.json().get("clients", [])
|
||
|
|
provide_mode_map = {-1: "Default", 0: "None", 1: "Network", 2: "Friends & Family", 3: "Public", 4: "Stream"}
|
||
|
|
for device in devices:
|
||
|
|
device['provide_mode_str'] = provide_mode_map.get(device.get('provide_mode'), 'Unknown')
|
||
|
|
return devices
|
||
|
|
|
||
|
|
def remove_device(jwt_token, client_id):
|
||
|
|
"""Removes a device from the network."""
|
||
|
|
if not jwt_token: return False, "Authentication token not available."
|
||
|
|
headers = {"Authorization": f"Bearer {jwt_token}"}
|
||
|
|
payload = {"client_id": client_id}
|
||
|
|
resp = request_with_retry("post", f"{app.config['UR_API_BASE']}/network/remove-client", headers=headers, json=payload)
|
||
|
|
if resp and resp.status_code == 200:
|
||
|
|
data = resp.json()
|
||
|
|
if data.get("error"):
|
||
|
|
return False, data["error"].get("message", "An unknown error occurred.")
|
||
|
|
return True, "Device removed successfully."
|
||
|
|
elif resp:
|
||
|
|
return False, f"API returned status {resp.status_code}."
|
||
|
|
else:
|
||
|
|
return False, "Failed to communicate with API."
|
||
|
|
|
||
|
|
def calculate_earnings(payments):
|
||
|
|
"""Calculate total and monthly earnings from a list of payments."""
|
||
|
|
total_earnings = 0
|
||
|
|
monthly_earnings = 0
|
||
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||
|
|
one_month_ago = now - datetime.timedelta(days=30)
|
||
|
|
|
||
|
|
if not payments:
|
||
|
|
return 0, 0
|
||
|
|
|
||
|
|
for payment in payments:
|
||
|
|
if payment.get("completed"):
|
||
|
|
amount = payment.get("token_amount", 0)
|
||
|
|
total_earnings += amount
|
||
|
|
payment_time_str = payment.get("payment_time")
|
||
|
|
if payment_time_str:
|
||
|
|
try:
|
||
|
|
payment_time = isoparse(payment_time_str)
|
||
|
|
if payment_time > one_month_ago:
|
||
|
|
monthly_earnings += amount
|
||
|
|
except (ValueError, TypeError):
|
||
|
|
logging.warning(f"Could not parse payment_time: {payment_time_str}")
|
||
|
|
|
||
|
|
return total_earnings, monthly_earnings
|
||
|
|
|
||
|
|
def fetch_provider_locations():
|
||
|
|
"""Retrieve provider locations from the public API."""
|
||
|
|
resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/network/provider-locations")
|
||
|
|
return resp.json() if resp else None
|
||
|
|
|
||
|
|
def send_webhook_notification(stats_data, account_nickname=None):
|
||
|
|
"""Sends a notification to all configured webhooks."""
|
||
|
|
with app.app_context():
|
||
|
|
webhooks = Webhook.query.all()
|
||
|
|
if not webhooks:
|
||
|
|
return
|
||
|
|
|
||
|
|
for webhook in webhooks:
|
||
|
|
payload_to_send = None
|
||
|
|
try:
|
||
|
|
if webhook.payload:
|
||
|
|
template = Template(webhook.payload)
|
||
|
|
payload_str = template.safe_substitute(
|
||
|
|
account=account_nickname or "Unknown",
|
||
|
|
paid_gb=f"{stats_data['paid_gb']:.3f}",
|
||
|
|
unpaid_gb=f"{stats_data['unpaid_gb']:.3f}",
|
||
|
|
total_gb=f"{(stats_data['paid_gb'] + stats_data['unpaid_gb']):.3f}",
|
||
|
|
update_time=datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
|
|
)
|
||
|
|
payload_to_send = json.loads(payload_str)
|
||
|
|
else:
|
||
|
|
payload_to_send = {
|
||
|
|
"embeds": [{
|
||
|
|
"title": f"UrNetwork Stats Update - {account_nickname or 'Account'}",
|
||
|
|
"description": "New data has been synced from the UrNetwork API.",
|
||
|
|
"color": 5814783,
|
||
|
|
"fields": [
|
||
|
|
{"name": "Account", "value": account_nickname or "Unknown", "inline": False},
|
||
|
|
{"name": "Total Paid Data", "value": f"{stats_data['paid_gb']:.3f} GB", "inline": True},
|
||
|
|
{"name": "Total Unpaid Data", "value": f"{stats_data['unpaid_gb']:.3f} GB", "inline": True},
|
||
|
|
{"name": "Total Data Provided", "value": f"{(stats_data['paid_gb'] + stats_data['unpaid_gb']):.3f} GB", "inline": True},
|
||
|
|
],
|
||
|
|
"footer": {"text": f"Update Time: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"}
|
||
|
|
}]
|
||
|
|
}
|
||
|
|
|
||
|
|
requests.post(webhook.url, json=payload_to_send, timeout=10)
|
||
|
|
logging.info(f"Sent webhook notification to {webhook.url}")
|
||
|
|
except (json.JSONDecodeError, TypeError) as e:
|
||
|
|
logging.error(f"Failed to parse or substitute custom payload for webhook {webhook.url}: {e}")
|
||
|
|
except requests.exceptions.RequestException as e:
|
||
|
|
logging.error(f"Failed to send webhook to {webhook.url}: {e}")
|
||
|
|
|
||
|
|
# --- Authentication Decorator ---
|
||
|
|
def login_required(f):
|
||
|
|
"""Decorator to ensure a user is logged in."""
|
||
|
|
@wraps(f)
|
||
|
|
def decorated_function(*args, **kwargs):
|
||
|
|
if not session.get('logged_in'):
|
||
|
|
flash(g.t['error_login_required'], "error")
|
||
|
|
next_url = request.url
|
||
|
|
if app.config['FORCE_HTTPS']:
|
||
|
|
next_url = next_url.replace('http://', 'https://', 1)
|
||
|
|
return redirect(url_for('login', next=next_url))
|
||
|
|
return f(*args, **kwargs)
|
||
|
|
return decorated_function
|
||
|
|
|
||
|
|
# --- Scheduled Jobs ---
|
||
|
|
|
||
|
|
@scheduler.task(id="log_stats_job", trigger="cron", minute="0,15,30,45")
|
||
|
|
def log_stats_job():
|
||
|
|
"""Scheduled job to fetch and store stats every 15 minutes for all active accounts."""
|
||
|
|
with app.app_context():
|
||
|
|
if not is_installed():
|
||
|
|
logging.warning("log_stats_job skipped: Application is not installed.")
|
||
|
|
return
|
||
|
|
|
||
|
|
logging.info("Running scheduled stats fetch for all accounts...")
|
||
|
|
accounts = Account.query.filter_by(is_active=True).all()
|
||
|
|
|
||
|
|
for account in accounts:
|
||
|
|
try:
|
||
|
|
jwt = get_valid_jwt(account)
|
||
|
|
if not jwt:
|
||
|
|
logging.warning(f"Could not authenticate account {account.username}")
|
||
|
|
continue
|
||
|
|
|
||
|
|
stats_data = fetch_transfer_stats(jwt)
|
||
|
|
if not stats_data:
|
||
|
|
logging.warning(f"Could not fetch stats for account {account.username}")
|
||
|
|
continue
|
||
|
|
|
||
|
|
entry = Stats(
|
||
|
|
account_id=account.id,
|
||
|
|
paid_bytes=stats_data["paid_bytes"],
|
||
|
|
paid_gb=stats_data["paid_gb"],
|
||
|
|
unpaid_bytes=stats_data["unpaid_bytes"],
|
||
|
|
unpaid_gb=stats_data["unpaid_gb"]
|
||
|
|
)
|
||
|
|
db.session.add(entry)
|
||
|
|
db.session.commit()
|
||
|
|
logging.info(f"Logged stats for account {account.nickname or account.username} at {entry.timestamp}")
|
||
|
|
send_webhook_notification(stats_data, account.nickname or account.username)
|
||
|
|
except Exception as e:
|
||
|
|
logging.error(f"Failed to fetch stats for account {account.username}: {e}")
|
||
|
|
|
||
|
|
@scheduler.task(id="cleanup_old_stats_job", trigger="cron", day_of_week="sun", hour="0")
|
||
|
|
def cleanup_old_stats_job():
|
||
|
|
"""Scheduled job to delete stats data older than 7 days."""
|
||
|
|
with app.app_context():
|
||
|
|
if not is_installed():
|
||
|
|
logging.warning("cleanup_old_stats_job skipped: Application is not installed.")
|
||
|
|
return
|
||
|
|
|
||
|
|
logging.info("Running weekly stats cleanup job...")
|
||
|
|
try:
|
||
|
|
cutoff_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7)
|
||
|
|
num_rows_deleted = db.session.query(Stats).filter(Stats.timestamp < cutoff_date).delete(synchronize_session=False)
|
||
|
|
db.session.commit()
|
||
|
|
|
||
|
|
if num_rows_deleted > 0:
|
||
|
|
logging.info(f"Successfully deleted {num_rows_deleted} stats records older than 7 days.")
|
||
|
|
else:
|
||
|
|
logging.info("No old stats records found to delete.")
|
||
|
|
except Exception as e:
|
||
|
|
logging.error(f"Scheduled job 'cleanup_old_stats_job' failed: {e}")
|
||
|
|
db.session.rollback()
|
||
|
|
|
||
|
|
# --- Internationalization (i18n) ---
|
||
|
|
|
||
|
|
TEXTS = {
|
||
|
|
'cs': {
|
||
|
|
'nav_public': 'Veřejný pohled',
|
||
|
|
'nav_owner_dashboard': 'Panel vlastníka',
|
||
|
|
'nav_overview': 'Přehled',
|
||
|
|
'nav_account': 'Účet & Žebříček',
|
||
|
|
'nav_devices': 'Zařízení',
|
||
|
|
'nav_settings': 'Nastavení',
|
||
|
|
'nav_accounts': 'Správa účtů',
|
||
|
|
'nav_logout': 'Odhlásit se',
|
||
|
|
'title_public_dashboard': 'Veřejný panel',
|
||
|
|
'title_owner_dashboard': 'Panel vlastníka',
|
||
|
|
'title_account_page': 'Účet & Žebříček',
|
||
|
|
'title_devices_page': 'Správa zařízení',
|
||
|
|
'title_settings': 'Nastavení',
|
||
|
|
'title_accounts_page': 'Správa účtů',
|
||
|
|
'title_login': 'Přihlásit se',
|
||
|
|
'title_setup': 'Počáteční nastavení',
|
||
|
|
'card_paid_data': 'Celkem placených dat',
|
||
|
|
'card_unpaid_data': 'Celkem neplacených dat',
|
||
|
|
'card_earnings_30d': 'Výdělky (30 dní)',
|
||
|
|
'card_total_earnings': 'Celkové výdělky',
|
||
|
|
'card_last_update': 'Poslední aktualizace',
|
||
|
|
'card_account_points': 'Body účtu',
|
||
|
|
'card_your_rank': 'Vaše pozice',
|
||
|
|
'card_total_referrals': 'Celkem doporučení',
|
||
|
|
'card_active_accounts': 'Aktivní účty',
|
||
|
|
'card_total_devices': 'Celkem zařízení',
|
||
|
|
'chart_total_data_gb': 'Celková poskytnutá data (GB)',
|
||
|
|
'chart_paid_vs_unpaid': 'Placená vs. neplacená data (GB)',
|
||
|
|
'chart_delta': 'Změna dat za interval (GB)',
|
||
|
|
'chart_paid': 'Placená (GB)',
|
||
|
|
'chart_unpaid': 'Neplacená (GB)',
|
||
|
|
'chart_no_data': 'Nedostatek dat pro zobrazení grafu.',
|
||
|
|
'history_title': 'Detailní historie',
|
||
|
|
'history_timestamp': 'Časová značka',
|
||
|
|
'history_change': 'Změna (GB)',
|
||
|
|
'btn_fetch_now': 'Načíst nyní',
|
||
|
|
'btn_clear_data': 'Vymazat všechna data',
|
||
|
|
'btn_add_account': 'Přidat účet',
|
||
|
|
'confirm_clear_data': 'Jste si jisti? Tímto smažete všechna historická data statistik.',
|
||
|
|
'next_fetch': 'Další automatické načtení:',
|
||
|
|
'login_header': 'Přihlaste se do svého panelu',
|
||
|
|
'login_username': 'Administrátorské heslo',
|
||
|
|
'login_password': 'Heslo',
|
||
|
|
'login_button': 'Přihlásit se',
|
||
|
|
'error_login_required': 'Pro zobrazení této stránky se musíte přihlásit.',
|
||
|
|
'error_invalid_credentials': 'Neplatné heslo.',
|
||
|
|
'flash_logged_in': 'Byli jste úspěšně přihlášeni.',
|
||
|
|
'flash_logged_out': 'Byli jste odhlášeni.',
|
||
|
|
'flash_settings_saved': 'Nastavení bylo úspěšně uloženo.',
|
||
|
|
'flash_fetch_success': 'Nejnovější statistiky byly úspěšně načteny a uloženy.',
|
||
|
|
'flash_fetch_fail': 'Nepodařilo se načíst statistiky: ',
|
||
|
|
'flash_clear_success': 'Úspěšně smazáno {count} záznamů z databáze.',
|
||
|
|
'flash_clear_fail': 'Při mazání databáze došlo k chybě.',
|
||
|
|
'flash_account_added': 'Účet byl úspěšně přidán.',
|
||
|
|
'flash_account_removed': 'Účet byl úspěšně odstraněn.',
|
||
|
|
'flash_account_updated': 'Účet byl úspěšně aktualizován.',
|
||
|
|
'webhook_title': 'Správa webhooků',
|
||
|
|
'webhook_desc': 'Přidejte webhooky pro zasílání oznámení.',
|
||
|
|
'webhook_url_label': 'URL adresa webhooku',
|
||
|
|
'webhook_payload_label': 'Vlastní JSON Payload (volitelné)',
|
||
|
|
'webhook_payload_placeholder': 'Např.: {"content": "Účet: ${account}, Data: ${total_gb} GB"}',
|
||
|
|
'webhook_add_btn': 'Přidat Webhook',
|
||
|
|
'webhook_current': 'Aktuální Webhooky',
|
||
|
|
'webhook_delete_btn': 'Smazat',
|
||
|
|
'webhook_flash_added': 'Webhook úspěšně přidán.',
|
||
|
|
'webhook_flash_deleted': 'Webhook úspěšně smazán.',
|
||
|
|
'webhook_flash_invalid_url': 'Je vyžadována platná URL adresa webhooku.',
|
||
|
|
'webhook_flash_exists': 'Tato URL adresa webhooku je již zaregistrována.',
|
||
|
|
'accounts_username': 'Uživatelské jméno',
|
||
|
|
'accounts_nickname': 'Přezdívka',
|
||
|
|
'accounts_status': 'Stav',
|
||
|
|
'accounts_actions': 'Akce',
|
||
|
|
'accounts_active': 'Aktivní',
|
||
|
|
'accounts_inactive': 'Neaktivní',
|
||
|
|
'accounts_toggle': 'Přepnout',
|
||
|
|
'accounts_remove': 'Odebrat',
|
||
|
|
'accounts_add_title': 'Přidat nový účet',
|
||
|
|
'accounts_add_username': 'UrNetwork uživatelské jméno',
|
||
|
|
'accounts_add_password': 'UrNetwork heslo',
|
||
|
|
'accounts_add_nickname': 'Přezdívka (volitelné)',
|
||
|
|
'combined_stats': 'Kombinovaná statistika',
|
||
|
|
'individual_accounts': 'Jednotlivé účty',
|
||
|
|
'view_combined': 'Zobrazit kombinovanou statistiku',
|
||
|
|
'view_individual': 'Zobrazit jednotlivé účty',
|
||
|
|
'map_title': 'Lokace poskytovatelů',
|
||
|
|
'map_legend_title': 'Počet poskytovatelů',
|
||
|
|
'map_legend_hover': 'Přejeďte myší přes zemi',
|
||
|
|
'leaderboard_title': 'Žebříček',
|
||
|
|
'leaderboard_rank': 'Pořadí',
|
||
|
|
'leaderboard_name': 'Jméno sítě',
|
||
|
|
'leaderboard_data': 'Poskytnutá data (MiB)',
|
||
|
|
'devices_title': 'Stav a správa zařízení',
|
||
|
|
'devices_status': 'Stav',
|
||
|
|
'devices_name': 'Jméno zařízení',
|
||
|
|
'devices_id': 'ID klienta',
|
||
|
|
'devices_mode': 'Režim poskytování',
|
||
|
|
'devices_account': 'Účet',
|
||
|
|
'devices_remove': 'Odebrat',
|
||
|
|
'devices_online': 'Online',
|
||
|
|
'devices_offline': 'Offline',
|
||
|
|
'devices_confirm_remove': 'Opravdu chcete odebrat toto zařízení?',
|
||
|
|
'flash_device_removed': 'Zařízení úspěšně odebráno.',
|
||
|
|
'flash_device_remove_fail': 'Nepodařilo se odebrat zařízení: '
|
||
|
|
},
|
||
|
|
'en': {
|
||
|
|
'nav_public': 'Public View',
|
||
|
|
'nav_owner_dashboard': 'Owner Dashboard',
|
||
|
|
'nav_overview': 'Overview',
|
||
|
|
'nav_account': 'Account & Leaderboard',
|
||
|
|
'nav_devices': 'Devices',
|
||
|
|
'nav_settings': 'Settings',
|
||
|
|
'nav_accounts': 'Account Management',
|
||
|
|
'nav_logout': 'Logout',
|
||
|
|
'title_public_dashboard': 'Public Dashboard',
|
||
|
|
'title_owner_dashboard': 'Owner Dashboard',
|
||
|
|
'title_account_page': 'Account & Leaderboard',
|
||
|
|
'title_devices_page': 'Device Management',
|
||
|
|
'title_settings': 'Settings',
|
||
|
|
'title_accounts_page': 'Account Management',
|
||
|
|
'title_login': 'Login',
|
||
|
|
'title_setup': 'Initial Setup',
|
||
|
|
'card_paid_data': 'Total Paid Data',
|
||
|
|
'card_unpaid_data': 'Total Unpaid Data',
|
||
|
|
'card_earnings_30d': 'Earnings (30 Days)',
|
||
|
|
'card_total_earnings': 'Total Earnings',
|
||
|
|
'card_last_update': 'Last Update',
|
||
|
|
'card_account_points': 'Account Points',
|
||
|
|
'card_your_rank': 'Your Rank',
|
||
|
|
'card_total_referrals': 'Total Referrals',
|
||
|
|
'card_active_accounts': 'Active Accounts',
|
||
|
|
'card_total_devices': 'Total Devices',
|
||
|
|
'chart_total_data_gb': 'Total Data Provided (GB)',
|
||
|
|
'chart_paid_vs_unpaid': 'Paid vs. Unpaid Data (GB)',
|
||
|
|
'chart_delta': 'Data Change per Interval (GB)',
|
||
|
|
'chart_paid': 'Paid (GB)',
|
||
|
|
'chart_unpaid': 'Unpaid (GB)',
|
||
|
|
'chart_no_data': 'Not enough data to display a chart.',
|
||
|
|
'history_title': 'Detailed History',
|
||
|
|
'history_timestamp': 'Timestamp',
|
||
|
|
'history_change': 'Change (GB)',
|
||
|
|
'btn_fetch_now': 'Fetch Now',
|
||
|
|
'btn_clear_data': 'Clear All Data',
|
||
|
|
'btn_add_account': 'Add Account',
|
||
|
|
'confirm_clear_data': 'Are you sure? This will delete all historical stats data.',
|
||
|
|
'next_fetch': 'Next automatic fetch:',
|
||
|
|
'login_header': 'Sign in to your dashboard',
|
||
|
|
'login_username': 'Admin Password',
|
||
|
|
'login_password': 'Password',
|
||
|
|
'login_button': 'Sign in',
|
||
|
|
'error_login_required': 'You must be logged in to view this page.',
|
||
|
|
'error_invalid_credentials': 'Invalid password.',
|
||
|
|
'flash_logged_in': 'You have been logged in successfully.',
|
||
|
|
'flash_logged_out': 'You have been logged out.',
|
||
|
|
'flash_settings_saved': 'Settings saved successfully.',
|
||
|
|
'flash_fetch_success': 'Successfully fetched and saved latest stats.',
|
||
|
|
'flash_fetch_fail': 'Failed to fetch stats: ',
|
||
|
|
'flash_clear_success': 'Successfully cleared {count} records from the database.',
|
||
|
|
'flash_clear_fail': 'An error occurred while clearing the database.',
|
||
|
|
'flash_account_added': 'Account added successfully.',
|
||
|
|
'flash_account_removed': 'Account removed successfully.',
|
||
|
|
'flash_account_updated': 'Account updated successfully.',
|
||
|
|
'webhook_title': 'Webhook Management',
|
||
|
|
'webhook_desc': 'Add webhooks to send notifications.',
|
||
|
|
'webhook_url_label': 'Webhook URL',
|
||
|
|
'webhook_payload_label': 'Custom JSON Payload (optional)',
|
||
|
|
'webhook_payload_placeholder': 'E.g.: {"content": "Account: ${account}, Data: ${total_gb} GB"}',
|
||
|
|
'webhook_add_btn': 'Add Webhook',
|
||
|
|
'webhook_current': 'Current Webhooks',
|
||
|
|
'webhook_delete_btn': 'Delete',
|
||
|
|
'webhook_flash_added': 'Webhook added successfully.',
|
||
|
|
'webhook_flash_deleted': 'Webhook deleted successfully.',
|
||
|
|
'webhook_flash_invalid_url': 'A valid Webhook URL is required.',
|
||
|
|
'webhook_flash_exists': 'This webhook URL is already registered.',
|
||
|
|
'accounts_username': 'Username',
|
||
|
|
'accounts_nickname': 'Nickname',
|
||
|
|
'accounts_status': 'Status',
|
||
|
|
'accounts_actions': 'Actions',
|
||
|
|
'accounts_active': 'Active',
|
||
|
|
'accounts_inactive': 'Inactive',
|
||
|
|
'accounts_toggle': 'Toggle',
|
||
|
|
'accounts_remove': 'Remove',
|
||
|
|
'accounts_add_title': 'Add New Account',
|
||
|
|
'accounts_add_username': 'UrNetwork Username',
|
||
|
|
'accounts_add_password': 'UrNetwork Password',
|
||
|
|
'accounts_add_nickname': 'Nickname (optional)',
|
||
|
|
'combined_stats': 'Combined Statistics',
|
||
|
|
'individual_accounts': 'Individual Accounts',
|
||
|
|
'view_combined': 'View Combined Stats',
|
||
|
|
'view_individual': 'View Individual Accounts',
|
||
|
|
'map_title': 'Provider Locations',
|
||
|
|
'map_legend_title': 'Provider Count',
|
||
|
|
'map_legend_hover': 'Hover over a country',
|
||
|
|
'leaderboard_title': 'Leaderboard',
|
||
|
|
'leaderboard_rank': 'Rank',
|
||
|
|
'leaderboard_name': 'Network Name',
|
||
|
|
'leaderboard_data': 'Data Provided (MiB)',
|
||
|
|
'devices_title': 'Device Status & Management',
|
||
|
|
'devices_status': 'Status',
|
||
|
|
'devices_name': 'Device Name',
|
||
|
|
'devices_id': 'Client ID',
|
||
|
|
'devices_mode': 'Provide Mode',
|
||
|
|
'devices_account': 'Account',
|
||
|
|
'devices_remove': 'Remove',
|
||
|
|
'devices_online': 'Online',
|
||
|
|
'devices_offline': 'Offline',
|
||
|
|
'devices_confirm_remove': 'Are you sure you want to remove this device?',
|
||
|
|
'flash_device_removed': 'Device removed successfully.',
|
||
|
|
'flash_device_remove_fail': 'Failed to remove device: '
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
def get_locale():
|
||
|
|
"""Detects the best language match from request headers."""
|
||
|
|
return request.accept_languages.best_match(TEXTS.keys()) or 'en'
|
||
|
|
|
||
|
|
# --- HTML Templates ---
|
||
|
|
|
||
|
|
LAYOUT_TEMPLATE = """
|
||
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="{{ g.locale }}">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>{{ title }}</title>
|
||
|
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📊</text></svg>">
|
||
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
||
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||
|
|
<style>
|
||
|
|
:root {
|
||
|
|
--gradient-start: #0f172a;
|
||
|
|
--gradient-end: #1e293b;
|
||
|
|
--container-bg: rgba(30, 41, 59, 0.8);
|
||
|
|
--card-bg: rgba(15, 23, 42, 0.9);
|
||
|
|
--border-color: rgba(71, 85, 105, 0.4);
|
||
|
|
--text-color: #e2e8f0;
|
||
|
|
--text-muted: #94a3b8;
|
||
|
|
--primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
|
|
--primary-hover: linear-gradient(135deg, #5568d3 0%, #64408a 100%);
|
||
|
|
--success: #10b981;
|
||
|
|
--danger: #ef4444;
|
||
|
|
--warning: #f59e0b;
|
||
|
|
--info: #3b82f6;
|
||
|
|
}
|
||
|
|
|
||
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
|
|
||
|
|
body {
|
||
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
|
|
background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
|
||
|
|
background-attachment: fixed;
|
||
|
|
color: var(--text-color);
|
||
|
|
min-height: 100vh;
|
||
|
|
line-height: 1.6;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Glassmorphism effect */
|
||
|
|
.glass {
|
||
|
|
background: var(--container-bg);
|
||
|
|
backdrop-filter: blur(10px);
|
||
|
|
-webkit-backdrop-filter: blur(10px);
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||
|
|
}
|
||
|
|
|
||
|
|
.main-wrapper {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
min-height: 100vh;
|
||
|
|
}
|
||
|
|
|
||
|
|
header {
|
||
|
|
background: var(--container-bg);
|
||
|
|
backdrop-filter: blur(10px);
|
||
|
|
border-bottom: 1px solid var(--border-color);
|
||
|
|
padding: 0 30px;
|
||
|
|
height: 70px;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
position: sticky;
|
||
|
|
top: 0;
|
||
|
|
z-index: 1000;
|
||
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-content {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
width: 100%;
|
||
|
|
max-width: 1600px;
|
||
|
|
margin: 0 auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-logo {
|
||
|
|
font-size: 1.5em;
|
||
|
|
font-weight: 700;
|
||
|
|
background: var(--primary);
|
||
|
|
-webkit-background-clip: text;
|
||
|
|
-webkit-text-fill-color: transparent;
|
||
|
|
background-clip: text;
|
||
|
|
letter-spacing: -0.5px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-nav {
|
||
|
|
display: flex;
|
||
|
|
gap: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-nav a, .header-nav button {
|
||
|
|
color: var(--text-color);
|
||
|
|
text-decoration: none;
|
||
|
|
padding: 10px 16px;
|
||
|
|
border-radius: 8px;
|
||
|
|
transition: all 0.3s ease;
|
||
|
|
background: none;
|
||
|
|
border: none;
|
||
|
|
font-family: inherit;
|
||
|
|
font-size: 0.95em;
|
||
|
|
font-weight: 500;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-nav a:hover, .header-nav button:hover {
|
||
|
|
background: rgba(255, 255, 255, 0.1);
|
||
|
|
transform: translateY(-2px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-nav a.active {
|
||
|
|
background: var(--primary);
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
main {
|
||
|
|
flex-grow: 1;
|
||
|
|
width: 100%;
|
||
|
|
padding: 40px 30px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.main-container {
|
||
|
|
max-width: 1600px;
|
||
|
|
margin: 0 auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.content-box {
|
||
|
|
background: var(--card-bg);
|
||
|
|
backdrop-filter: blur(10px);
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
border-radius: 16px;
|
||
|
|
padding: 30px;
|
||
|
|
margin-bottom: 30px;
|
||
|
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||
|
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.content-box:hover {
|
||
|
|
transform: translateY(-4px);
|
||
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||
|
|
}
|
||
|
|
|
||
|
|
h1, h2, h3 {
|
||
|
|
color: white;
|
||
|
|
margin-bottom: 20px;
|
||
|
|
font-weight: 700;
|
||
|
|
}
|
||
|
|
|
||
|
|
h1 {
|
||
|
|
font-size: 2.5em;
|
||
|
|
background: var(--primary);
|
||
|
|
-webkit-background-clip: text;
|
||
|
|
-webkit-text-fill-color: transparent;
|
||
|
|
background-clip: text;
|
||
|
|
margin-bottom: 30px;
|
||
|
|
}
|
||
|
|
|
||
|
|
h2 {
|
||
|
|
font-size: 1.8em;
|
||
|
|
padding-bottom: 15px;
|
||
|
|
border-bottom: 2px solid var(--border-color);
|
||
|
|
}
|
||
|
|
|
||
|
|
h3 {
|
||
|
|
font-size: 1.3em;
|
||
|
|
color: var(--text-muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.grid {
|
||
|
|
display: grid;
|
||
|
|
gap: 25px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||
|
|
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||
|
|
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||
|
|
.grid-cols-1 { grid-template-columns: 1fr; }
|
||
|
|
|
||
|
|
.stat-card {
|
||
|
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||
|
|
padding: 25px;
|
||
|
|
border-radius: 12px;
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
transition: all 0.3s ease;
|
||
|
|
position: relative;
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-card::before {
|
||
|
|
content: '';
|
||
|
|
position: absolute;
|
||
|
|
top: 0;
|
||
|
|
left: 0;
|
||
|
|
right: 0;
|
||
|
|
height: 3px;
|
||
|
|
background: var(--primary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-card:hover {
|
||
|
|
transform: translateY(-5px);
|
||
|
|
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-card dt {
|
||
|
|
font-size: 0.9em;
|
||
|
|
color: var(--text-muted);
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 1px;
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stat-card dd {
|
||
|
|
font-size: 2.2em;
|
||
|
|
font-weight: 700;
|
||
|
|
color: white;
|
||
|
|
margin-top: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.account-badge {
|
||
|
|
display: inline-block;
|
||
|
|
padding: 4px 12px;
|
||
|
|
border-radius: 20px;
|
||
|
|
font-size: 0.85em;
|
||
|
|
font-weight: 600;
|
||
|
|
background: var(--primary);
|
||
|
|
color: white;
|
||
|
|
margin-right: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
table {
|
||
|
|
width: 100%;
|
||
|
|
border-collapse: collapse;
|
||
|
|
background: rgba(15, 23, 42, 0.5);
|
||
|
|
border-radius: 12px;
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
th, td {
|
||
|
|
padding: 16px;
|
||
|
|
text-align: left;
|
||
|
|
border-bottom: 1px solid var(--border-color);
|
||
|
|
}
|
||
|
|
|
||
|
|
thead {
|
||
|
|
background: rgba(102, 126, 234, 0.1);
|
||
|
|
}
|
||
|
|
|
||
|
|
thead th {
|
||
|
|
color: white;
|
||
|
|
font-size: 0.9em;
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 1px;
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody tr {
|
||
|
|
transition: all 0.2s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody tr:hover {
|
||
|
|
background: rgba(102, 126, 234, 0.1);
|
||
|
|
}
|
||
|
|
|
||
|
|
.text-green { color: var(--success); }
|
||
|
|
.text-red { color: var(--danger); }
|
||
|
|
.text-yellow { color: var(--warning); }
|
||
|
|
|
||
|
|
form {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
label {
|
||
|
|
font-weight: 600;
|
||
|
|
font-size: 0.95em;
|
||
|
|
color: var(--text-color);
|
||
|
|
margin-bottom: -12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
input, textarea, select {
|
||
|
|
padding: 14px 16px;
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
border-radius: 8px;
|
||
|
|
background: rgba(15, 23, 42, 0.8);
|
||
|
|
color: var(--text-color);
|
||
|
|
font-family: 'Inter', sans-serif;
|
||
|
|
font-size: 1em;
|
||
|
|
transition: all 0.3s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
input:focus, textarea:focus, select:focus {
|
||
|
|
outline: none;
|
||
|
|
border-color: #667eea;
|
||
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||
|
|
background: rgba(15, 23, 42, 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
textarea {
|
||
|
|
min-height: 120px;
|
||
|
|
resize: vertical;
|
||
|
|
font-family: 'Courier New', monospace;
|
||
|
|
}
|
||
|
|
|
||
|
|
button, input[type=submit] {
|
||
|
|
padding: 14px 28px;
|
||
|
|
border: none;
|
||
|
|
border-radius: 8px;
|
||
|
|
background: var(--primary);
|
||
|
|
color: white;
|
||
|
|
font-weight: 600;
|
||
|
|
font-size: 1em;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all 0.3s ease;
|
||
|
|
font-family: inherit;
|
||
|
|
}
|
||
|
|
|
||
|
|
button:hover, input[type=submit]:hover {
|
||
|
|
background: var(--primary-hover);
|
||
|
|
transform: translateY(-2px);
|
||
|
|
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4);
|
||
|
|
}
|
||
|
|
|
||
|
|
button.secondary {
|
||
|
|
background: rgba(148, 163, 184, 0.2);
|
||
|
|
}
|
||
|
|
|
||
|
|
button.secondary:hover {
|
||
|
|
background: rgba(148, 163, 184, 0.3);
|
||
|
|
}
|
||
|
|
|
||
|
|
button.danger {
|
||
|
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||
|
|
}
|
||
|
|
|
||
|
|
button.danger:hover {
|
||
|
|
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
||
|
|
}
|
||
|
|
|
||
|
|
button.success {
|
||
|
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||
|
|
}
|
||
|
|
|
||
|
|
.flash-msg {
|
||
|
|
padding: 16px 20px;
|
||
|
|
margin-bottom: 25px;
|
||
|
|
border-radius: 10px;
|
||
|
|
border-left: 4px solid;
|
||
|
|
backdrop-filter: blur(10px);
|
||
|
|
animation: slideIn 0.3s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes slideIn {
|
||
|
|
from { transform: translateX(-20px); opacity: 0; }
|
||
|
|
to { transform: translateX(0); opacity: 1; }
|
||
|
|
}
|
||
|
|
|
||
|
|
.flash-msg.error {
|
||
|
|
background: rgba(239, 68, 68, 0.15);
|
||
|
|
border-color: var(--danger);
|
||
|
|
color: #fca5a5;
|
||
|
|
}
|
||
|
|
|
||
|
|
.flash-msg.info {
|
||
|
|
background: rgba(59, 130, 246, 0.15);
|
||
|
|
border-color: var(--info);
|
||
|
|
color: #93c5fd;
|
||
|
|
}
|
||
|
|
|
||
|
|
#map {
|
||
|
|
height: 500px;
|
||
|
|
width: 100%;
|
||
|
|
border-radius: 12px;
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
.leaflet-tile-pane {
|
||
|
|
filter: invert(1) hue-rotate(180deg) brightness(0.95) contrast(0.9);
|
||
|
|
}
|
||
|
|
|
||
|
|
.map-legend {
|
||
|
|
padding: 12px 16px;
|
||
|
|
background: rgba(15, 23, 42, 0.95);
|
||
|
|
backdrop-filter: blur(10px);
|
||
|
|
color: var(--text-color);
|
||
|
|
border-radius: 8px;
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
|
|
}
|
||
|
|
|
||
|
|
.map-legend h4 {
|
||
|
|
margin: 0 0 8px 0;
|
||
|
|
font-weight: 600;
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.toggle-switch {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.switch {
|
||
|
|
position: relative;
|
||
|
|
display: inline-block;
|
||
|
|
width: 50px;
|
||
|
|
height: 26px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.switch input {
|
||
|
|
opacity: 0;
|
||
|
|
width: 0;
|
||
|
|
height: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.slider {
|
||
|
|
position: absolute;
|
||
|
|
cursor: pointer;
|
||
|
|
top: 0;
|
||
|
|
left: 0;
|
||
|
|
right: 0;
|
||
|
|
bottom: 0;
|
||
|
|
background-color: rgba(148, 163, 184, 0.3);
|
||
|
|
transition: .4s;
|
||
|
|
border-radius: 26px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.slider:before {
|
||
|
|
position: absolute;
|
||
|
|
content: "";
|
||
|
|
height: 18px;
|
||
|
|
width: 18px;
|
||
|
|
left: 4px;
|
||
|
|
bottom: 4px;
|
||
|
|
background-color: white;
|
||
|
|
transition: .4s;
|
||
|
|
border-radius: 50%;
|
||
|
|
}
|
||
|
|
|
||
|
|
input:checked + .slider {
|
||
|
|
background: var(--primary);
|
||
|
|
}
|
||
|
|
|
||
|
|
input:checked + .slider:before {
|
||
|
|
transform: translateX(24px);
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 1200px) {
|
||
|
|
.grid-cols-4 { grid-template-columns: repeat(2, 1fr); }
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 768px) {
|
||
|
|
.grid-cols-4, .grid-cols-3, .grid-cols-2 {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
h1 { font-size: 2em; }
|
||
|
|
|
||
|
|
.header-nav {
|
||
|
|
flex-wrap: wrap;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div class="main-wrapper">
|
||
|
|
<header>
|
||
|
|
<div class="header-content">
|
||
|
|
<div class="header-logo">⚡ UrNetwork Stats</div>
|
||
|
|
<nav class="header-nav">
|
||
|
|
<a href="{{ url_for('public_dashboard') }}" class="{{ 'active' if request.endpoint == 'public_dashboard' else '' }}">{{ t.nav_public }}</a>
|
||
|
|
{% if session.logged_in %}
|
||
|
|
<a href="{{ url_for('private_dashboard') }}" class="{{ 'active' if request.endpoint.startswith('private_') or request.endpoint.startswith('api_') else '' }}">{{ t.nav_owner_dashboard }}</a>
|
||
|
|
<a href="{{ url_for('accounts_manage') }}" class="{{ 'active' if request.endpoint == 'accounts_manage' else '' }}">{{ t.nav_accounts }}</a>
|
||
|
|
<a href="{{ url_for('settings') }}" class="{{ 'active' if request.endpoint == 'settings' else '' }}">{{ t.nav_settings }}</a>
|
||
|
|
<a href="{{ url_for('logout') }}">{{ t.nav_logout }}</a>
|
||
|
|
{% endif %}
|
||
|
|
</nav>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
<main>
|
||
|
|
<div class="main-container">
|
||
|
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||
|
|
{% if messages %}
|
||
|
|
{% for category, message in messages %}
|
||
|
|
<div class="flash-msg {{ category }}">{{ message }}</div>
|
||
|
|
{% endfor %}
|
||
|
|
{% endif %}
|
||
|
|
{% endwith %}
|
||
|
|
{{ content | safe }}
|
||
|
|
</div>
|
||
|
|
</main>
|
||
|
|
</div>
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
"""
|
||
|
|
|
||
|
|
INSTALL_TEMPLATE = """
|
||
|
|
<div class="content-box" style="max-width: 600px; margin: 80px auto;">
|
||
|
|
<h1>{{ title }}</h1>
|
||
|
|
<p style="text-align:center; margin-bottom: 30px; color: var(--text-muted);">Nastavte administrátorské heslo pro váš dashboard.</p>
|
||
|
|
<form action="{{ url_for('install') }}" method="POST">
|
||
|
|
<input id="admin_pass" name="admin_pass" type="password" placeholder="Administrátorské heslo" required autocomplete="new-password">
|
||
|
|
<input id="admin_pass_confirm" name="admin_pass_confirm" type="password" placeholder="Potvrďte heslo" required autocomplete="new-password">
|
||
|
|
<button type="submit">Nainstalovat Dashboard</button>
|
||
|
|
</form>
|
||
|
|
<p style="margin-top: 20px; font-size: 0.9em; color: var(--text-muted);">Poté budete moci přidat své UrNetwork účty v nastavení.</p>
|
||
|
|
</div>
|
||
|
|
"""
|
||
|
|
|
||
|
|
LOGIN_TEMPLATE = """
|
||
|
|
<div class="content-box" style="max-width: 500px; margin: 80px auto;">
|
||
|
|
<h1>{{ title }}</h1>
|
||
|
|
<h2 style="border: none; padding-bottom: 0;">{{ t.login_header }}</h2>
|
||
|
|
<form action="{{ url_for('login') }}{% if request.args.next %}?next={{ request.args.next }}{% endif %}" method="POST" style="margin-top: 30px;">
|
||
|
|
<input id="password" name="password" type="password" autocomplete="current-password" placeholder="{{ t.login_username }}" required>
|
||
|
|
<button type="submit">{{ t.login_button }}</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
"""
|
||
|
|
|
||
|
|
PUBLIC_DASHBOARD_TEMPLATE = """
|
||
|
|
<h1>{{ title }}</h1>
|
||
|
|
<div class="space-y-8">
|
||
|
|
<div class="content-box">
|
||
|
|
<div class="grid grid-cols-4">
|
||
|
|
<div class="stat-card"><dt>{{ t.card_paid_data }}</dt><dd>{{ "%.3f"|format(combined.paid_gb) }} GB</dd></div>
|
||
|
|
<div class="stat-card"><dt>{{ t.card_unpaid_data }}</dt><dd>{{ "%.3f"|format(combined.unpaid_gb) }} GB</dd></div>
|
||
|
|
<div class="stat-card"><dt>{{ t.card_active_accounts }}</dt><dd>{{ active_accounts }}</dd></div>
|
||
|
|
<div class="stat-card"><dt>{{ t.card_earnings_30d }}</dt><dd>${{ "%.2f"|format(monthly_earnings) }}</dd></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="grid grid-cols-1">
|
||
|
|
<div class="content-box">
|
||
|
|
<h3>{{ t.chart_total_data_gb }} - {{ t.combined_stats }}</h3>
|
||
|
|
<div style="height: 400px;">
|
||
|
|
{% if chart_data.labels %}<canvas id="publicStatsChart"></canvas>{% else %}<p>{{ t.chart_no_data }}</p>{% endif %}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{% for account, acc_data in account_charts.items() %}
|
||
|
|
<div class="content-box">
|
||
|
|
<h3><span class="account-badge">{{ account }}</span>{{ t.chart_total_data_gb }}</h3>
|
||
|
|
<div style="height: 300px;">
|
||
|
|
{% if acc_data.labels %}<canvas id="accountChart{{ loop.index }}"></canvas>{% else %}<p>{{ t.chart_no_data }}</p>{% endif %}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{% endfor %}
|
||
|
|
|
||
|
|
<div class="content-box">
|
||
|
|
<h3>{{ t.map_title }}</h3>
|
||
|
|
<div id="map"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<script>
|
||
|
|
document.addEventListener('DOMContentLoaded', function() {
|
||
|
|
function get_css_var(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||
|
|
|
||
|
|
const chartOptions = {
|
||
|
|
responsive: true,
|
||
|
|
maintainAspectRatio: false,
|
||
|
|
scales: {
|
||
|
|
y: {
|
||
|
|
ticks: { color: 'var(--text-muted)' },
|
||
|
|
grid: { color: 'rgba(71, 85, 105, 0.2)' }
|
||
|
|
},
|
||
|
|
x: {
|
||
|
|
ticks: { color: 'var(--text-muted)' },
|
||
|
|
grid: { display: false }
|
||
|
|
}
|
||
|
|
},
|
||
|
|
plugins: {
|
||
|
|
legend: { labels: { color: 'var(--text-color)' } }
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (document.getElementById('publicStatsChart')) {
|
||
|
|
const ctx = document.getElementById('publicStatsChart').getContext('2d');
|
||
|
|
const chartData = {{ chart_data | tojson }};
|
||
|
|
new Chart(ctx, {
|
||
|
|
type: 'line',
|
||
|
|
data: {
|
||
|
|
labels: chartData.labels,
|
||
|
|
datasets: [{
|
||
|
|
label: 'Total Data (GB)',
|
||
|
|
data: chartData.data,
|
||
|
|
fill: true,
|
||
|
|
borderColor: '#667eea',
|
||
|
|
backgroundColor: 'rgba(102, 126, 234, 0.2)',
|
||
|
|
tension: 0.4,
|
||
|
|
pointBackgroundColor: '#667eea',
|
||
|
|
pointBorderColor: '#fff',
|
||
|
|
pointHoverRadius: 6,
|
||
|
|
pointRadius: 3
|
||
|
|
}]
|
||
|
|
},
|
||
|
|
options: chartOptions
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
{% for account, acc_data in account_charts.items() %}
|
||
|
|
if (document.getElementById('accountChart{{ loop.index }}')) {
|
||
|
|
const ctx{{ loop.index }} = document.getElementById('accountChart{{ loop.index }}').getContext('2d');
|
||
|
|
const accData{{ loop.index }} = {{ acc_data | tojson }};
|
||
|
|
new Chart(ctx{{ loop.index }}, {
|
||
|
|
type: 'line',
|
||
|
|
data: {
|
||
|
|
labels: accData{{ loop.index }}.labels,
|
||
|
|
datasets: [{
|
||
|
|
label: '{{ account }}',
|
||
|
|
data: accData{{ loop.index }}.data,
|
||
|
|
fill: true,
|
||
|
|
borderColor: accData{{ loop.index }}.color,
|
||
|
|
backgroundColor: accData{{ loop.index }}.bgColor,
|
||
|
|
tension: 0.4,
|
||
|
|
pointRadius: 2
|
||
|
|
}]
|
||
|
|
},
|
||
|
|
options: chartOptions
|
||
|
|
});
|
||
|
|
}
|
||
|
|
{% endfor %}
|
||
|
|
|
||
|
|
// Map initialization
|
||
|
|
const mapElement = document.getElementById('map');
|
||
|
|
if (mapElement) {
|
||
|
|
const map = L.map(mapElement, { center: [20, 10], zoom: 2, minZoom: 2, maxZoom: 6 });
|
||
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', {
|
||
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||
|
|
}).addTo(map);
|
||
|
|
|
||
|
|
const mapInfo = L.control({position: 'bottomright'});
|
||
|
|
mapInfo.onAdd = function() { this._div = L.DomUtil.create('div', 'map-legend'); this.update(); return this._div; };
|
||
|
|
mapInfo.update = function(props) {
|
||
|
|
this._div.innerHTML = `<h4>{{ t.map_legend_title }}</h4>` +
|
||
|
|
(props ? `<b>${props.name}</b><br />${props.density || 0} providers` : '{{ t.map_legend_hover }}');
|
||
|
|
};
|
||
|
|
mapInfo.addTo(map);
|
||
|
|
|
||
|
|
const worldGeoData = {{ world_map_data | tojson }};
|
||
|
|
let geojsonLayer;
|
||
|
|
|
||
|
|
function updateLocationData(providerData) {
|
||
|
|
if (!providerData || !providerData.locations || !worldGeoData) return;
|
||
|
|
const providerCounts = providerData.locations.reduce((acc, loc) => {
|
||
|
|
if (loc.country_code) acc[loc.country_code.toUpperCase()] = loc.provider_count;
|
||
|
|
return acc;
|
||
|
|
}, {});
|
||
|
|
|
||
|
|
worldGeoData.features.forEach(feature => {
|
||
|
|
feature.properties.density = providerCounts[feature.properties.iso_a2] || 0;
|
||
|
|
});
|
||
|
|
|
||
|
|
if (geojsonLayer) { map.removeLayer(geojsonLayer); }
|
||
|
|
|
||
|
|
geojsonLayer = L.geoJson(worldGeoData, {
|
||
|
|
style: feature => ({
|
||
|
|
fillColor: (d => d > 100 ? '#667eea' : d > 25 ? '#764ba2' : d > 10 ? '#5568d3' : d > 0 ? '#64408a' : 'rgba(71, 85, 105, 0.5)')(feature.properties.density),
|
||
|
|
weight: 1, opacity: 1, color: '#0f172a', fillOpacity: 0.7
|
||
|
|
}),
|
||
|
|
onEachFeature: (feature, layer) => layer.on({
|
||
|
|
mouseover: e => {
|
||
|
|
const l = e.target;
|
||
|
|
l.setStyle({ weight: 2, color: '#a5b4fc', fillOpacity: 0.9 });
|
||
|
|
l.bringToFront();
|
||
|
|
mapInfo.update(l.feature.properties);
|
||
|
|
},
|
||
|
|
mouseout: e => { geojsonLayer.resetStyle(e.target); mapInfo.update(); }
|
||
|
|
})
|
||
|
|
}).addTo(map);
|
||
|
|
}
|
||
|
|
|
||
|
|
fetch('{{ url_for("get_locations") }}')
|
||
|
|
.then(res => res.json())
|
||
|
|
.then(updateLocationData)
|
||
|
|
.catch(error => console.error('Error fetching location data:', error));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
"""
|
||
|
|
|
||
|
|
ACCOUNTS_MANAGE_TEMPLATE = """
|
||
|
|
<h1>{{ title }}</h1>
|
||
|
|
<div class="grid grid-cols-1">
|
||
|
|
<div class="content-box">
|
||
|
|
<h2>{{ t.accounts_add_title }}</h2>
|
||
|
|
<form method="post" action="{{ url_for('accounts_add') }}">
|
||
|
|
<label for="username">{{ t.accounts_add_username }}</label>
|
||
|
|
<input type="text" name="username" id="username" required>
|
||
|
|
|
||
|
|
<label for="password">{{ t.accounts_add_password }}</label>
|
||
|
|
<input type="password" name="password" id="password" required>
|
||
|
|
|
||
|
|
<label for="nickname">{{ t.accounts_add_nickname }}</label>
|
||
|
|
<input type="text" name="nickname" id="nickname" placeholder="např. Domácí účet">
|
||
|
|
|
||
|
|
<button type="submit" class="success">{{ t.btn_add_account }}</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="content-box">
|
||
|
|
<h2>{{ t.individual_accounts }}</h2>
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>{{ t.accounts_nickname }}</th>
|
||
|
|
<th>{{ t.accounts_username }}</th>
|
||
|
|
<th>{{ t.accounts_status }}</th>
|
||
|
|
<th>{{ t.accounts_actions }}</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{% for account in accounts %}
|
||
|
|
<tr>
|
||
|
|
<td><span class="account-badge">{{ account.nickname or account.username }}</span></td>
|
||
|
|
<td>{{ account.username }}</td>
|
||
|
|
<td>
|
||
|
|
<span class="{{ 'text-green' if account.is_active else 'text-red' }}">
|
||
|
|
{{ t.accounts_active if account.is_active else t.accounts_inactive }}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<form method="post" action="{{ url_for('accounts_toggle', account_id=account.id) }}" style="display: inline;">
|
||
|
|
<button type="submit" class="secondary">{{ t.accounts_toggle }}</button>
|
||
|
|
</form>
|
||
|
|
<form method="post" action="{{ url_for('accounts_remove', account_id=account.id) }}" style="display: inline;" onsubmit="return confirm('Opravdu chcete odebrat tento účet?');">
|
||
|
|
<button type="submit" class="danger">{{ t.accounts_remove }}</button>
|
||
|
|
</form>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{% else %}
|
||
|
|
<tr>
|
||
|
|
<td colspan="4" style="text-align: center; color: var(--text-muted);">Zatím nemáte přidané žádné účty.</td>
|
||
|
|
</tr>
|
||
|
|
{% endfor %}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
"""
|
||
|
|
|
||
|
|
PRIVATE_DASHBOARD_REACT_TEMPLATE = """
|
||
|
|
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||
|
|
|
||
|
|
<div id="dashboard-root"></div>
|
||
|
|
|
||
|
|
<script type="text/babel">
|
||
|
|
const REACT_PROPS = {{ react_props|tojson|safe }};
|
||
|
|
{% raw %}
|
||
|
|
const { useState, useEffect, useMemo } = React;
|
||
|
|
const t = REACT_PROPS.translations;
|
||
|
|
|
||
|
|
const StatCard = ({ title, value, unit }) => (
|
||
|
|
<div className="stat-card">
|
||
|
|
<dt>{title}</dt>
|
||
|
|
<dd>{value} {unit}</dd>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
const ChartComponent = ({ chartId, type, data, options }) => {
|
||
|
|
useEffect(() => {
|
||
|
|
const ctx = document.getElementById(chartId);
|
||
|
|
if (!ctx) return;
|
||
|
|
const chartInstance = new Chart(ctx, { type, data, options });
|
||
|
|
return () => chartInstance.destroy();
|
||
|
|
}, [data]);
|
||
|
|
|
||
|
|
return <div style={{ height: '400px' }}><canvas id={chartId}></canvas></div>;
|
||
|
|
};
|
||
|
|
|
||
|
|
function Overview() {
|
||
|
|
const [data, setData] = useState(null);
|
||
|
|
const [viewMode, setViewMode] = useState('combined');
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetch('/api/dashboard/overview')
|
||
|
|
.then(res => res.json())
|
||
|
|
.then(setData);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const chartOptions = useMemo(() => ({
|
||
|
|
responsive: true, maintainAspectRatio: false,
|
||
|
|
scales: {
|
||
|
|
y: { ticks: { color: 'var(--text-muted)' }, grid: { color: 'rgba(71, 85, 105, 0.2)' } },
|
||
|
|
x: { ticks: { color: 'var(--text-muted)' }, grid: { display: false } }
|
||
|
|
},
|
||
|
|
plugins: { legend: { labels: { color: 'var(--text-color)' } } }
|
||
|
|
}), []);
|
||
|
|
|
||
|
|
if (!data) return <p>Loading...</p>;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<div className="content-box">
|
||
|
|
<div className="grid grid-cols-4">
|
||
|
|
<StatCard title={t.card_paid_data} value={data.combined.paid_gb.toFixed(3)} unit="GB" />
|
||
|
|
<StatCard title={t.card_unpaid_data} value={data.combined.unpaid_gb.toFixed(3)} unit="GB" />
|
||
|
|
<StatCard title={t.card_total_earnings} value={'$' + data.total_earnings.toFixed(2)} />
|
||
|
|
<StatCard title={t.card_active_accounts} value={data.active_accounts} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="content-box">
|
||
|
|
<div style={{display: 'flex', gap: '10px', marginBottom: '20px'}}>
|
||
|
|
<button
|
||
|
|
className={viewMode === 'combined' ? '' : 'secondary'}
|
||
|
|
onClick={() => setViewMode('combined')}
|
||
|
|
>
|
||
|
|
{t.view_combined}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
className={viewMode === 'individual' ? '' : 'secondary'}
|
||
|
|
onClick={() => setViewMode('individual')}
|
||
|
|
>
|
||
|
|
{t.view_individual}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{viewMode === 'combined' && (
|
||
|
|
<div>
|
||
|
|
<h3>{t.chart_paid_vs_unpaid} - {t.combined_stats}</h3>
|
||
|
|
<ChartComponent chartId="combinedChart" type="line" data={{
|
||
|
|
labels: data.combined_chart.labels,
|
||
|
|
datasets: [
|
||
|
|
{
|
||
|
|
label: t.chart_paid,
|
||
|
|
data: data.combined_chart.paid_gb,
|
||
|
|
borderColor: '#10b981',
|
||
|
|
backgroundColor: 'rgba(16, 185, 129, 0.2)',
|
||
|
|
fill: true,
|
||
|
|
tension: 0.4
|
||
|
|
},
|
||
|
|
{
|
||
|
|
label: t.chart_unpaid,
|
||
|
|
data: data.combined_chart.unpaid_gb,
|
||
|
|
borderColor: '#f59e0b',
|
||
|
|
backgroundColor: 'rgba(245, 158, 11, 0.2)',
|
||
|
|
fill: true,
|
||
|
|
tension: 0.4
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}} options={chartOptions} />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{viewMode === 'individual' && (
|
||
|
|
<div className="grid grid-cols-2">
|
||
|
|
{Object.entries(data.account_charts).map(([accountName, chartData], index) => (
|
||
|
|
<div key={accountName} style={{marginBottom: '30px'}}>
|
||
|
|
<h3><span className="account-badge">{accountName}</span></h3>
|
||
|
|
<ChartComponent
|
||
|
|
chartId={`accountDetailChart${index}`}
|
||
|
|
type="line"
|
||
|
|
data={{
|
||
|
|
labels: chartData.labels,
|
||
|
|
datasets: [{
|
||
|
|
label: accountName,
|
||
|
|
data: chartData.data,
|
||
|
|
borderColor: chartData.color,
|
||
|
|
backgroundColor: chartData.bgColor,
|
||
|
|
fill: true,
|
||
|
|
tension: 0.4
|
||
|
|
}]
|
||
|
|
}}
|
||
|
|
options={chartOptions}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function Account() {
|
||
|
|
const [data, setData] = useState(null);
|
||
|
|
const [selectedAccount, setSelectedAccount] = useState('all');
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetch(`/api/dashboard/account?account_id=${selectedAccount}`)
|
||
|
|
.then(res => res.json())
|
||
|
|
.then(setData);
|
||
|
|
}, [selectedAccount]);
|
||
|
|
|
||
|
|
if (!data) return <p>Loading...</p>;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<div className="content-box">
|
||
|
|
<select
|
||
|
|
value={selectedAccount}
|
||
|
|
onChange={(e) => setSelectedAccount(e.target.value)}
|
||
|
|
style={{marginBottom: '20px', width: '300px'}}
|
||
|
|
>
|
||
|
|
<option value="all">Všechny účty</option>
|
||
|
|
{data.accounts && data.accounts.map(acc => (
|
||
|
|
<option key={acc.id} value={acc.id}>{acc.nickname || acc.username}</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
|
||
|
|
{selectedAccount !== 'all' && data.account_details && (
|
||
|
|
<div className="grid grid-cols-3">
|
||
|
|
<StatCard title={t.card_account_points} value={data.account_details.points || 'N/A'} />
|
||
|
|
<StatCard title={t.card_your_rank} value={'#' + (data.account_details.ranking?.leaderboard_rank || 'N/A')} />
|
||
|
|
<StatCard title={t.card_total_referrals} value={data.account_details.referrals?.total_referrals || 'N/A'} />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{data.leaderboard && data.leaderboard.length > 0 && (
|
||
|
|
<div className="content-box">
|
||
|
|
<h3>{t.leaderboard_title}</h3>
|
||
|
|
<div style={{ overflowX: 'auto' }}>
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>{t.leaderboard_rank}</th>
|
||
|
|
<th>{t.leaderboard_name}</th>
|
||
|
|
<th>{t.leaderboard_data}</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{data.leaderboard.map((earner, index) => (
|
||
|
|
<tr key={index}>
|
||
|
|
<td>{index + 1}</td>
|
||
|
|
<td>{earner.is_public && !earner.contains_profanity ? earner.network_name : '[private]'}</td>
|
||
|
|
<td>{earner.net_mib_count.toFixed(2)}</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function Devices() {
|
||
|
|
const [devices, setDevices] = useState(null);
|
||
|
|
const [refresh, setRefresh] = useState(0);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetch('/api/dashboard/devices')
|
||
|
|
.then(res => res.json())
|
||
|
|
.then(data => setDevices(data.devices));
|
||
|
|
}, [refresh]);
|
||
|
|
|
||
|
|
const handleRemove = (accountId, clientId) => {
|
||
|
|
if (confirm(t.devices_confirm_remove)) {
|
||
|
|
fetch(`/api/dashboard/devices/remove/${accountId}/${clientId}`, { method: 'POST' })
|
||
|
|
.then(res => res.json())
|
||
|
|
.then(result => {
|
||
|
|
alert(result.message);
|
||
|
|
setRefresh(r => r + 1);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (!devices) return <p>Loading...</p>;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="content-box">
|
||
|
|
<h3>{t.devices_title}</h3>
|
||
|
|
<div style={{ overflowX: 'auto' }}>
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>{t.devices_account}</th>
|
||
|
|
<th>{t.devices_status}</th>
|
||
|
|
<th>{t.devices_name}</th>
|
||
|
|
<th>{t.devices_id}</th>
|
||
|
|
<th>{t.devices_mode}</th>
|
||
|
|
<th> </th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{devices.map(device => (
|
||
|
|
<tr key={`${device.account_id}-${device.client_id}`}>
|
||
|
|
<td><span className="account-badge">{device.account_nickname}</span></td>
|
||
|
|
<td>
|
||
|
|
<span className={device.connections ? 'text-green' : 'text-red'}>
|
||
|
|
{device.connections ? t.devices_online : t.devices_offline}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td>{device.device_name || 'Unnamed Device'}</td>
|
||
|
|
<td><code>{device.client_id}</code></td>
|
||
|
|
<td>{device.provide_mode_str}</td>
|
||
|
|
<td>
|
||
|
|
<button
|
||
|
|
className="danger"
|
||
|
|
onClick={() => handleRemove(device.account_id, device.client_id)}
|
||
|
|
>
|
||
|
|
{t.devices_remove}
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function App() {
|
||
|
|
const [page, setPage] = useState(REACT_PROPS.initial_page);
|
||
|
|
|
||
|
|
const renderPage = () => {
|
||
|
|
switch (page) {
|
||
|
|
case 'account': return <Account />;
|
||
|
|
case 'devices': return <Devices />;
|
||
|
|
default: return <Overview />;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<h1>{t.title_owner_dashboard}</h1>
|
||
|
|
<div className="content-box" style={{padding: '10px'}}>
|
||
|
|
<nav className="header-nav">
|
||
|
|
<a href="#" onClick={(e) => {e.preventDefault(); setPage('overview')}} className={page === 'overview' ? 'active' : ''}>{t.nav_overview}</a>
|
||
|
|
<a href="#" onClick={(e) => {e.preventDefault(); setPage('account')}} className={page === 'account' ? 'active' : ''}>{t.nav_account}</a>
|
||
|
|
<a href="#" onClick={(e) => {e.preventDefault(); setPage('devices')}} className={page === 'devices' ? 'active' : ''}>{t.nav_devices}</a>
|
||
|
|
</nav>
|
||
|
|
</div>
|
||
|
|
{renderPage()}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
ReactDOM.render(<App />, document.getElementById('dashboard-root'));
|
||
|
|
{% endraw %}
|
||
|
|
</script>
|
||
|
|
"""
|
||
|
|
|
||
|
|
SETTINGS_TEMPLATE = """
|
||
|
|
<h1>{{ title }}</h1>
|
||
|
|
<div class="grid grid-cols-1">
|
||
|
|
<div class="content-box">
|
||
|
|
<h2>{{ t.webhook_title }}</h2>
|
||
|
|
<p style="color: var(--text-muted); margin-bottom: 20px;">{{ t.webhook_desc }}</p>
|
||
|
|
<form method="post" action="{{ url_for('add_webhook') }}">
|
||
|
|
<label for="webhook_url">{{ t.webhook_url_label }}</label>
|
||
|
|
<input type="url" name="webhook_url" id="webhook_url" required>
|
||
|
|
<label for="payload">{{ t.webhook_payload_label }}</label>
|
||
|
|
<textarea name="payload" id="payload" placeholder='{{ t.webhook_payload_placeholder }}'></textarea>
|
||
|
|
<button type="submit">{{ t.webhook_add_btn }}</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
<div class="content-box">
|
||
|
|
<h3>{{ t.webhook_current }}</h3>
|
||
|
|
{% for webhook in webhooks %}
|
||
|
|
<div style="border: 1px solid var(--border-color); border-radius: 12px; padding: 20px; margin-bottom: 20px; background: var(--card-bg);">
|
||
|
|
<p style="word-break: break-all; font-weight: 500; color: white;">{{ webhook.url }}</p>
|
||
|
|
<pre style="background: #000; padding: 15px; border-radius: 8px; margin-top: 15px; max-height: 150px; overflow-y: auto; font-size: 0.85em; color: var(--text-muted);">{{ webhook.payload or 'Default Discord Payload' }}</pre>
|
||
|
|
<form method="post" action="{{ url_for('delete_webhook', webhook_id=webhook.id) }}" style="margin-top: 15px;">
|
||
|
|
<button type="submit" class="danger" style="width: 100%;">{{ t.webhook_delete_btn }}</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
{% else %}
|
||
|
|
<p style="color: var(--text-muted);">Zatím nemáte nakonfigurované žádné webhooky.</p>
|
||
|
|
{% endfor %}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
"""
|
||
|
|
|
||
|
|
# World map data
|
||
|
|
WORLD_MAP_DATA = None
|
||
|
|
try:
|
||
|
|
map_url = "https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_110m_admin_0_countries.geojson"
|
||
|
|
response = requests.get(map_url, timeout=15)
|
||
|
|
response.raise_for_status()
|
||
|
|
WORLD_MAP_DATA = response.json()
|
||
|
|
logging.info("Successfully loaded world map GeoJSON data.")
|
||
|
|
except Exception as e:
|
||
|
|
logging.error(f"Could not download world map data: {e}")
|
||
|
|
|
||
|
|
def render_page(template_content, **context):
|
||
|
|
"""Renders a page by injecting its content into the main layout."""
|
||
|
|
context['world_map_data'] = WORLD_MAP_DATA
|
||
|
|
content = render_template_string(template_content, t=g.t, **context)
|
||
|
|
return render_template_string(LAYOUT_TEMPLATE, content=content, t=g.t, **context)
|
||
|
|
|
||
|
|
# --- Middleware and Before Request Handlers ---
|
||
|
|
|
||
|
|
@app.before_request
|
||
|
|
def force_secure():
|
||
|
|
"""Redirect non-secure requests to HTTPS if FORCE_HTTPS is enabled."""
|
||
|
|
if app.config['FORCE_HTTPS'] and not request.is_secure:
|
||
|
|
if request.headers.get('X-Forwarded-Proto', '').lower() != 'https':
|
||
|
|
secure_url = request.url.replace('http://', 'https://', 1)
|
||
|
|
return redirect(secure_url, code=301)
|
||
|
|
|
||
|
|
@app.before_request
|
||
|
|
def check_installation_and_init():
|
||
|
|
"""Before each request, check if app is installed and set up globals."""
|
||
|
|
g.locale = get_locale()
|
||
|
|
g.t = TEXTS[g.locale]
|
||
|
|
|
||
|
|
if request.endpoint not in ['install', 'static'] and not is_installed():
|
||
|
|
return redirect(url_for('install'))
|
||
|
|
|
||
|
|
if is_installed() and request.endpoint not in ['static']:
|
||
|
|
with app.app_context():
|
||
|
|
if not Setting.query.first():
|
||
|
|
logging.info("First run: Initializing settings.")
|
||
|
|
settings_to_add = [
|
||
|
|
Setting(key='ENABLE_ACCOUNT_STATS', value=str(app.config['ENABLE_ACCOUNT_STATS'])),
|
||
|
|
Setting(key='ENABLE_LEADERBOARD', value=str(app.config['ENABLE_LEADERBOARD'])),
|
||
|
|
Setting(key='ENABLE_DEVICE_STATS', value=str(app.config['ENABLE_DEVICE_STATS'])),
|
||
|
|
]
|
||
|
|
db.session.bulk_save_objects(settings_to_add)
|
||
|
|
db.session.commit()
|
||
|
|
|
||
|
|
g.now = datetime.datetime.utcnow()
|
||
|
|
|
||
|
|
@app.context_processor
|
||
|
|
def inject_layout_vars():
|
||
|
|
"""Inject variables into all templates."""
|
||
|
|
return {"now": g.now, "t": g.t}
|
||
|
|
|
||
|
|
# --- Routes ---
|
||
|
|
|
||
|
|
@app.route('/install', methods=['GET', 'POST'])
|
||
|
|
def install():
|
||
|
|
if is_installed():
|
||
|
|
flash("Aplikace je již nainstalována.", "info")
|
||
|
|
return redirect(url_for('public_dashboard'))
|
||
|
|
|
||
|
|
if request.method == 'POST':
|
||
|
|
admin_pass = request.form.get('admin_pass')
|
||
|
|
admin_pass_confirm = request.form.get('admin_pass_confirm')
|
||
|
|
|
||
|
|
if not admin_pass or not admin_pass_confirm:
|
||
|
|
flash("Vyplňte obě pole s heslem.", "error")
|
||
|
|
return render_page(INSTALL_TEMPLATE, title=g.t['title_setup'])
|
||
|
|
|
||
|
|
if admin_pass != admin_pass_confirm:
|
||
|
|
flash("Hesla se neshodují.", "error")
|
||
|
|
return render_page(INSTALL_TEMPLATE, title=g.t['title_setup'])
|
||
|
|
|
||
|
|
config_data = {
|
||
|
|
"SECRET_KEY": secrets.token_hex(24),
|
||
|
|
"ADMIN_PASSWORD": admin_pass
|
||
|
|
}
|
||
|
|
|
||
|
|
if save_env_file(config_data):
|
||
|
|
with app.app_context():
|
||
|
|
db.create_all()
|
||
|
|
session['logged_in'] = True
|
||
|
|
flash("Dashboard byl úspěšně nainstalován! Nyní můžete přidat své UrNetwork účty.", "info")
|
||
|
|
return redirect(url_for('accounts_manage'))
|
||
|
|
else:
|
||
|
|
flash("Chyba při ukládání konfigurace.", "error")
|
||
|
|
|
||
|
|
return render_page(INSTALL_TEMPLATE, title=g.t['title_setup'])
|
||
|
|
|
||
|
|
@app.route('/login', methods=['GET', 'POST'])
|
||
|
|
def login():
|
||
|
|
if session.get('logged_in'):
|
||
|
|
return redirect(url_for('private_dashboard'))
|
||
|
|
|
||
|
|
if request.method == 'POST':
|
||
|
|
password = request.form.get('password')
|
||
|
|
admin_password = os.getenv('ADMIN_PASSWORD')
|
||
|
|
|
||
|
|
if password and password == admin_password:
|
||
|
|
session['logged_in'] = True
|
||
|
|
flash(g.t['flash_logged_in'], 'info')
|
||
|
|
next_url = request.args.get('next')
|
||
|
|
return redirect(next_url or url_for('private_dashboard'))
|
||
|
|
else:
|
||
|
|
flash(g.t['error_invalid_credentials'], 'error')
|
||
|
|
|
||
|
|
return render_page(LOGIN_TEMPLATE, title=g.t['title_login'])
|
||
|
|
|
||
|
|
@app.route('/logout')
|
||
|
|
def logout():
|
||
|
|
session.pop('logged_in', None)
|
||
|
|
flash(g.t['flash_logged_out'], 'info')
|
||
|
|
return redirect(url_for('public_dashboard'))
|
||
|
|
|
||
|
|
@app.route('/')
|
||
|
|
def public_dashboard():
|
||
|
|
"""Display the public-facing dashboard with combined stats and individual account charts."""
|
||
|
|
accounts = Account.query.filter_by(is_active=True).all()
|
||
|
|
active_accounts = len(accounts)
|
||
|
|
|
||
|
|
# Get combined statistics
|
||
|
|
combined_paid = 0
|
||
|
|
combined_unpaid = 0
|
||
|
|
monthly_earnings_total = 0
|
||
|
|
|
||
|
|
account_charts = {}
|
||
|
|
colors = ['#667eea', '#764ba2', '#f59e0b', '#10b981', '#ef4444', '#3b82f6']
|
||
|
|
|
||
|
|
for idx, account in enumerate(accounts):
|
||
|
|
latest_stat = Stats.query.filter_by(account_id=account.id).order_by(Stats.timestamp.desc()).first()
|
||
|
|
if latest_stat:
|
||
|
|
combined_paid += latest_stat.paid_gb
|
||
|
|
combined_unpaid += latest_stat.unpaid_gb
|
||
|
|
|
||
|
|
# Get JWT and payments
|
||
|
|
jwt = get_valid_jwt(account)
|
||
|
|
if jwt:
|
||
|
|
payments = fetch_payment_stats(jwt)
|
||
|
|
_, monthly = calculate_earnings(payments)
|
||
|
|
monthly_earnings_total += monthly
|
||
|
|
|
||
|
|
# Get chart data for this account
|
||
|
|
entries = Stats.query.filter_by(account_id=account.id).order_by(Stats.timestamp.asc()).all()
|
||
|
|
if entries:
|
||
|
|
account_name = account.nickname or account.username
|
||
|
|
color = colors[idx % len(colors)]
|
||
|
|
account_charts[account_name] = {
|
||
|
|
"labels": [e.timestamp.strftime('%m-%d %H:%M') for e in entries],
|
||
|
|
"data": [e.paid_gb + e.unpaid_gb for e in entries],
|
||
|
|
"color": color,
|
||
|
|
"bgColor": f"rgba({int(color[1:3], 16)}, {int(color[3:5], 16)}, {int(color[5:7], 16)}, 0.2)"
|
||
|
|
}
|
||
|
|
|
||
|
|
combined = {
|
||
|
|
"paid_gb": combined_paid,
|
||
|
|
"unpaid_gb": combined_unpaid
|
||
|
|
}
|
||
|
|
|
||
|
|
# Combined chart data
|
||
|
|
all_entries = Stats.query.order_by(Stats.timestamp.asc()).all()
|
||
|
|
time_grouped = {}
|
||
|
|
for entry in all_entries:
|
||
|
|
time_key = entry.timestamp.strftime('%m-%d %H:%M')
|
||
|
|
if time_key not in time_grouped:
|
||
|
|
time_grouped[time_key] = {"paid": 0, "unpaid": 0}
|
||
|
|
time_grouped[time_key]["paid"] += entry.paid_gb
|
||
|
|
time_grouped[time_key]["unpaid"] += entry.unpaid_gb
|
||
|
|
|
||
|
|
chart_data = {
|
||
|
|
"labels": list(time_grouped.keys()),
|
||
|
|
"data": [time_grouped[k]["paid"] + time_grouped[k]["unpaid"] for k in time_grouped.keys()]
|
||
|
|
}
|
||
|
|
|
||
|
|
return render_page(
|
||
|
|
PUBLIC_DASHBOARD_TEMPLATE,
|
||
|
|
title=g.t['title_public_dashboard'],
|
||
|
|
combined=combined,
|
||
|
|
active_accounts=active_accounts,
|
||
|
|
monthly_earnings=monthly_earnings_total,
|
||
|
|
chart_data=chart_data,
|
||
|
|
account_charts=account_charts
|
||
|
|
)
|
||
|
|
|
||
|
|
@app.route('/dashboard')
|
||
|
|
@login_required
|
||
|
|
def private_dashboard():
|
||
|
|
react_props = get_react_props('overview')
|
||
|
|
return render_page(PRIVATE_DASHBOARD_REACT_TEMPLATE, title=g.t['title_owner_dashboard'], react_props=react_props)
|
||
|
|
|
||
|
|
def get_react_props(initial_page):
|
||
|
|
"""Helper to build the props dictionary for the React app."""
|
||
|
|
return {
|
||
|
|
"initial_page": initial_page,
|
||
|
|
"translations": g.t
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Account Management Routes ---
|
||
|
|
|
||
|
|
@app.route('/accounts')
|
||
|
|
@login_required
|
||
|
|
def accounts_manage():
|
||
|
|
accounts = Account.query.all()
|
||
|
|
return render_page(ACCOUNTS_MANAGE_TEMPLATE, title=g.t['title_accounts_page'], accounts=accounts)
|
||
|
|
|
||
|
|
@app.route('/accounts/add', methods=['POST'])
|
||
|
|
@login_required
|
||
|
|
def accounts_add():
|
||
|
|
username = request.form.get('username')
|
||
|
|
password = request.form.get('password')
|
||
|
|
nickname = request.form.get('nickname')
|
||
|
|
|
||
|
|
if not username or not password:
|
||
|
|
flash("Uživatelské jméno a heslo jsou povinné.", "error")
|
||
|
|
return redirect(url_for('accounts_manage'))
|
||
|
|
|
||
|
|
# Verify credentials
|
||
|
|
jwt = get_jwt_from_credentials(username, password)
|
||
|
|
if not jwt:
|
||
|
|
flash("Nepodařilo se ověřit přihlašovací údaje. Zkontrolujte je a zkuste to znovu.", "error")
|
||
|
|
return redirect(url_for('accounts_manage'))
|
||
|
|
|
||
|
|
# Check if account already exists
|
||
|
|
existing = Account.query.filter_by(username=username).first()
|
||
|
|
if existing:
|
||
|
|
flash("Tento účet je již přidán.", "error")
|
||
|
|
return redirect(url_for('accounts_manage'))
|
||
|
|
|
||
|
|
account = Account(username=username, password=password, nickname=nickname)
|
||
|
|
db.session.add(account)
|
||
|
|
db.session.commit()
|
||
|
|
|
||
|
|
flash(g.t['flash_account_added'], "info")
|
||
|
|
return redirect(url_for('accounts_manage'))
|
||
|
|
|
||
|
|
@app.route('/accounts/toggle/<int:account_id>', methods=['POST'])
|
||
|
|
@login_required
|
||
|
|
def accounts_toggle(account_id):
|
||
|
|
account = Account.query.get_or_404(account_id)
|
||
|
|
account.is_active = not account.is_active
|
||
|
|
db.session.commit()
|
||
|
|
flash(g.t['flash_account_updated'], "info")
|
||
|
|
return redirect(url_for('accounts_manage'))
|
||
|
|
|
||
|
|
@app.route('/accounts/remove/<int:account_id>', methods=['POST'])
|
||
|
|
@login_required
|
||
|
|
def accounts_remove(account_id):
|
||
|
|
account = Account.query.get_or_404(account_id)
|
||
|
|
# Delete all stats for this account
|
||
|
|
Stats.query.filter_by(account_id=account_id).delete()
|
||
|
|
db.session.delete(account)
|
||
|
|
db.session.commit()
|
||
|
|
flash(g.t['flash_account_removed'], "info")
|
||
|
|
return redirect(url_for('accounts_manage'))
|
||
|
|
|
||
|
|
# --- API Routes for React App ---
|
||
|
|
|
||
|
|
@app.route('/api/dashboard/overview')
|
||
|
|
@login_required
|
||
|
|
def api_overview_data():
|
||
|
|
accounts = Account.query.filter_by(is_active=True).all()
|
||
|
|
|
||
|
|
# Combined statistics
|
||
|
|
combined_paid = 0
|
||
|
|
combined_unpaid = 0
|
||
|
|
total_earnings = 0
|
||
|
|
|
||
|
|
account_charts = {}
|
||
|
|
colors = ['#667eea', '#764ba2', '#f59e0b', '#10b981', '#ef4444', '#3b82f6']
|
||
|
|
|
||
|
|
for idx, account in enumerate(accounts):
|
||
|
|
# Get latest stat
|
||
|
|
latest_stat = Stats.query.filter_by(account_id=account.id).order_by(Stats.timestamp.desc()).first()
|
||
|
|
if latest_stat:
|
||
|
|
combined_paid += latest_stat.paid_gb
|
||
|
|
combined_unpaid += latest_stat.unpaid_gb
|
||
|
|
|
||
|
|
# Get earnings
|
||
|
|
jwt = get_valid_jwt(account)
|
||
|
|
if jwt:
|
||
|
|
payments = fetch_payment_stats(jwt)
|
||
|
|
total, _ = calculate_earnings(payments)
|
||
|
|
total_earnings += total
|
||
|
|
|
||
|
|
# Get chart data
|
||
|
|
entries = Stats.query.filter_by(account_id=account.id).order_by(Stats.timestamp.asc()).all()
|
||
|
|
if entries:
|
||
|
|
account_name = account.nickname or account.username
|
||
|
|
color = colors[idx % len(colors)]
|
||
|
|
account_charts[account_name] = {
|
||
|
|
"labels": [e.timestamp.strftime('%m-%d %H:%M') for e in entries],
|
||
|
|
"data": [e.paid_gb + e.unpaid_gb for e in entries],
|
||
|
|
"color": color,
|
||
|
|
"bgColor": f"rgba({int(color[1:3], 16)}, {int(color[3:5], 16)}, {int(color[5:7], 16)}, 0.2)"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Combined chart
|
||
|
|
all_entries = Stats.query.order_by(Stats.timestamp.asc()).all()
|
||
|
|
time_grouped = {}
|
||
|
|
for entry in all_entries:
|
||
|
|
time_key = entry.timestamp.strftime('%m-%d %H:%M')
|
||
|
|
if time_key not in time_grouped:
|
||
|
|
time_grouped[time_key] = {"paid": 0, "unpaid": 0}
|
||
|
|
time_grouped[time_key]["paid"] += entry.paid_gb
|
||
|
|
time_grouped[time_key]["unpaid"] += entry.unpaid_gb
|
||
|
|
|
||
|
|
combined_chart = {
|
||
|
|
"labels": list(time_grouped.keys()),
|
||
|
|
"paid_gb": [time_grouped[k]["paid"] for k in time_grouped.keys()],
|
||
|
|
"unpaid_gb": [time_grouped[k]["unpaid"] for k in time_grouped.keys()]
|
||
|
|
}
|
||
|
|
|
||
|
|
return jsonify({
|
||
|
|
"combined": {
|
||
|
|
"paid_gb": combined_paid,
|
||
|
|
"unpaid_gb": combined_unpaid
|
||
|
|
},
|
||
|
|
"active_accounts": len(accounts),
|
||
|
|
"total_earnings": total_earnings,
|
||
|
|
"combined_chart": combined_chart,
|
||
|
|
"account_charts": account_charts
|
||
|
|
})
|
||
|
|
|
||
|
|
@app.route('/api/dashboard/account')
|
||
|
|
@login_required
|
||
|
|
def api_account_data():
|
||
|
|
account_id = request.args.get('account_id', 'all')
|
||
|
|
|
||
|
|
accounts = Account.query.filter_by(is_active=True).all()
|
||
|
|
account_details = {}
|
||
|
|
leaderboard = []
|
||
|
|
|
||
|
|
if account_id != 'all':
|
||
|
|
account = Account.query.get(int(account_id))
|
||
|
|
if account:
|
||
|
|
jwt = get_valid_jwt(account)
|
||
|
|
if jwt:
|
||
|
|
account_details = fetch_account_details(jwt)
|
||
|
|
leaderboard = fetch_leaderboard(jwt)
|
||
|
|
|
||
|
|
return jsonify({
|
||
|
|
"accounts": [{"id": a.id, "username": a.username, "nickname": a.nickname} for a in accounts],
|
||
|
|
"account_details": account_details,
|
||
|
|
"leaderboard": leaderboard
|
||
|
|
})
|
||
|
|
|
||
|
|
@app.route('/api/dashboard/devices')
|
||
|
|
@login_required
|
||
|
|
def api_devices_data():
|
||
|
|
accounts = Account.query.filter_by(is_active=True).all()
|
||
|
|
all_devices = []
|
||
|
|
|
||
|
|
for account in accounts:
|
||
|
|
jwt = get_valid_jwt(account)
|
||
|
|
if jwt:
|
||
|
|
devices = fetch_devices(jwt)
|
||
|
|
for device in devices:
|
||
|
|
device['account_id'] = account.id
|
||
|
|
device['account_nickname'] = account.nickname or account.username
|
||
|
|
all_devices.extend(devices)
|
||
|
|
|
||
|
|
return jsonify({"devices": all_devices})
|
||
|
|
|
||
|
|
@app.route('/api/dashboard/devices/remove/<int:account_id>/<client_id>', methods=["POST"])
|
||
|
|
@login_required
|
||
|
|
def api_remove_device(account_id, client_id):
|
||
|
|
account = Account.query.get(account_id)
|
||
|
|
if not account:
|
||
|
|
return jsonify({"success": False, "message": "Account not found"}), 404
|
||
|
|
|
||
|
|
jwt = get_valid_jwt(account)
|
||
|
|
success, message = remove_device(jwt, client_id)
|
||
|
|
|
||
|
|
if success:
|
||
|
|
return jsonify({"success": True, "message": g.t['flash_device_removed']})
|
||
|
|
else:
|
||
|
|
return jsonify({"success": False, "message": f"{g.t['flash_device_remove_fail']}{message}"}), 500
|
||
|
|
|
||
|
|
# --- Other Routes ---
|
||
|
|
|
||
|
|
@app.route('/api/locations')
|
||
|
|
def get_locations():
|
||
|
|
"""API endpoint to proxy provider locations."""
|
||
|
|
data = fetch_provider_locations()
|
||
|
|
if data: return jsonify(data)
|
||
|
|
return jsonify({"error": "Failed to fetch location data"}), 500
|
||
|
|
|
||
|
|
@app.route('/settings', methods=['GET', 'POST'])
|
||
|
|
@login_required
|
||
|
|
def settings():
|
||
|
|
webhooks = Webhook.query.all()
|
||
|
|
return render_page(SETTINGS_TEMPLATE, title=g.t['title_settings'], webhooks=webhooks)
|
||
|
|
|
||
|
|
@app.route("/webhooks/add", methods=["POST"])
|
||
|
|
@login_required
|
||
|
|
def add_webhook():
|
||
|
|
"""Add a new webhook."""
|
||
|
|
url = request.form.get("webhook_url")
|
||
|
|
payload = request.form.get("payload")
|
||
|
|
if not url or not (url.startswith("http://") or url.startswith("https://")):
|
||
|
|
flash(g.t['webhook_flash_invalid_url'], "error")
|
||
|
|
elif Webhook.query.filter_by(url=url).first():
|
||
|
|
flash(g.t['webhook_flash_exists'], "error")
|
||
|
|
else:
|
||
|
|
new_webhook = Webhook(url=url, payload=payload if payload.strip() else None)
|
||
|
|
db.session.add(new_webhook)
|
||
|
|
db.session.commit()
|
||
|
|
flash(g.t['webhook_flash_added'], "info")
|
||
|
|
return redirect(url_for('settings'))
|
||
|
|
|
||
|
|
@app.route("/webhooks/delete/<int:webhook_id>", methods=["POST"])
|
||
|
|
@login_required
|
||
|
|
def delete_webhook(webhook_id):
|
||
|
|
"""Delete a webhook."""
|
||
|
|
webhook = Webhook.query.get(webhook_id)
|
||
|
|
if webhook:
|
||
|
|
db.session.delete(webhook)
|
||
|
|
db.session.commit()
|
||
|
|
flash(g.t['webhook_flash_deleted'], "info")
|
||
|
|
return redirect(url_for('settings'))
|
||
|
|
|
||
|
|
# --- Main Entry Point ---
|
||
|
|
if __name__ == "__main__":
|
||
|
|
with app.app_context():
|
||
|
|
db.create_all()
|
||
|
|
scheduler.start()
|
||
|
|
app.run(host="0.0.0.0", port=90, debug=False)
|