UrNetwork-Stats-Dashboard-r.../main_app.py
2025-07-08 17:56:05 +02:00

1430 lines
73 KiB
Python

# ----------------------------------------
# UrNetwork Stats Dashboard - Refactored & Enhanced
# ----------------------------------------
# Original Author: https://github.com/techroy23/UrNetwork-Stats-Dashboard
# Refactored with Public/Private views, UI, Graphs, Installer, Auth, Dark Theme, and Provider Map.
# Enhanced with categorized, configurable features from the API and separate dashboard pages.
#
# --- main_app.py ---
# ----------------------------------------
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
# --- Application Setup & Configuration ---
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Environment file path
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
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///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"
# --- New Feature Flags (Defaults) ---
# These will be used to initialize the settings in the database on first run.
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 ---
app = Flask(__name__)
app.config.from_object(Config)
db = SQLAlchemy(app)
scheduler = APScheduler()
scheduler.init_app(app)
# --- Database Models ---
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)
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)
class Webhook(db.Model):
"""Represents a webhook URL."""
__tablename__ = 'webhook'
id = db.Column(db.Integer, primary_key=True)
url = db.Column(db.String, unique=True, nullable=False)
class Setting(db.Model):
"""Represents a key-value setting for the application, like feature flags."""
__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 all(os.getenv(var) for var in ["UR_USER", "UR_PASS", "SECRET_KEY"])
def save_env_file(config_data):
"""Save configuration data to the .env file."""
try:
with open(ENV_FILE, "w") as f:
for key, value in config_data.items():
f.write(f"{key.upper()}={value}\n")
# Also write the initial feature flag settings
f.write("\n# Feature Flags (can be managed in the UI after install)\n")
f.write(f"ENABLE_ACCOUNT_STATS=True\n")
f.write(f"ENABLE_LEADERBOARD=True\n")
f.write(f"ENABLE_DEVICE_STATS=True\n")
load_env() # Reload env after writing
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)
# Instead of raising a fatal RuntimeError, return None to handle gracefully
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():
"""Gets a valid JWT for API calls."""
user = os.getenv("UR_USER")
password = os.getenv("UR_PASS")
if not user or not password:
logging.error("Cannot fetch JWT; user credentials not found in environment.")
return None
return get_jwt_from_credentials(user, 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 []
return resp.json().get("clients", [])
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):
"""Sends a notification to all configured webhooks."""
webhooks = Webhook.query.all()
if not webhooks: return
payload = {
"embeds": [{
"title": "UrNetwork Stats Update",
"description": "New data has been synced from the UrNetwork API.",
"color": 5814783,
"fields": [
{"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')}"}
}]
}
for webhook in webhooks:
try:
requests.post(webhook.url, json=payload, timeout=10)
logging.info(f"Sent webhook notification to {webhook.url}")
except requests.exceptions.RequestException as e:
logging.error(f"Failed to send webhook to {webhook.url}: {e}")
# --- Authentication Decorator ---
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get('logged_in'):
flash("You must be logged in to view this page.", "error")
return redirect(url_for('login', next=request.url))
return f(*args, **kwargs)
return decorated_function
# --- Scheduled Job ---
@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."""
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...")
try:
jwt = get_valid_jwt()
if not jwt: raise RuntimeError("Scheduled job could not authenticate with API.")
stats_data = fetch_transfer_stats(jwt)
if not stats_data: raise RuntimeError("Scheduled job could not fetch transfer stats.")
entry = Stats(
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 new stats entry at {entry.timestamp}")
send_webhook_notification(stats_data)
except Exception as e:
logging.error(f"Scheduled job 'log_stats_job' failed: {e}")
# --- HTML Templates ---
LAYOUT_TEMPLATE = """
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} - UrNetwork Stats</title>
<script src="https://cdn.tailwindcss.com"></script>
<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="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>
.dark .dark-hidden { display: none; }
.light .light-hidden { display: none; }
#map { height: 500px; width: 100%; }
.map-legend { padding: 6px 8px; font: 14px/16px Arial, Helvetica, sans-serif; background: white; background: rgba(255,255,255,0.8); box-shadow: 0 0 15px rgba(0,0,0,0.2); border-radius: 5px; }
.map-legend i { width: 18px; height: 18px; float: left; margin-right: 8px; opacity: 0.7; }
.dark .map-legend { background: rgba(31, 41, 55, 0.8); color: #e5e7eb; }
.group:hover .group-hover\\:block { display: block; }
</style>
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
function setTheme(theme) {
if (theme === 'system') {
localStorage.removeItem('theme');
if (window.matchMedia('(prefers-color-scheme: dark)').matches) { document.documentElement.classList.add('dark'); }
else { document.documentElement.classList.remove('dark'); }
} else {
localStorage.theme = theme;
if (theme === 'dark') { document.documentElement.classList.add('dark'); }
else { document.documentElement.classList.remove('dark'); }
}
}
</script>
</head>
<body class="h-full bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 transition-colors duration-300">
<div class="min-h-full">
<nav class="bg-white dark:bg-gray-800 shadow-sm">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0 text-gray-800 dark:text-white font-bold text-lg">
📊 UrNetwork Stats
</div>
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<a href="{{ url_for('public_dashboard') }}" class="{% if request.endpoint == 'public_dashboard' %}bg-gray-200 dark:bg-gray-900 text-gray-800 dark:text-white{% else %}text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white{% endif %} rounded-md px-3 py-2 text-sm font-medium">Public View</a>
<div class="relative group">
<a href="{{ url_for('private_dashboard') }}" class="{% if request.endpoint.startswith('private_') or request.endpoint in ['login', 'settings'] %}bg-gray-200 dark:bg-gray-900 text-gray-800 dark:text-white{% else %}text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white{% endif %} rounded-md px-3 py-2 text-sm font-medium inline-flex items-center">
<span>Owner Dashboard</span>
<svg class="ml-2 -mr-1 h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" /></svg>
</a>
<div class="absolute left-0 mt-2 w-48 rounded-md shadow-lg bg-white dark:bg-gray-700 ring-1 ring-black ring-opacity-5 hidden group-hover:block z-10">
<div class="py-1" role="menu" aria-orientation="vertical">
<a href="{{ url_for('private_dashboard') }}" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600" role="menuitem">Overview</a>
<a href="{{ url_for('private_account_page') }}" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600" role="menuitem">Account & Leaderboard</a>
<a href="{{ url_for('private_devices_page') }}" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600" role="menuitem">Devices</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="relative">
<button id="theme-menu-button" class="p-2 rounded-full text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">
<svg class="h-6 w-6 light-hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
<svg class="h-6 w-6 dark-hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
</button>
<div id="theme-menu" class="origin-top-right absolute right-0 mt-2 w-36 rounded-md shadow-lg py-1 bg-white dark:bg-gray-700 ring-1 ring-black ring-opacity-5 focus:outline-none hidden" role="menu">
<a href="#" onclick="setTheme('light'); location.reload();" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600" role="menuitem">Light</a>
<a href="#" onclick="setTheme('dark'); location.reload();" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600" role="menuitem">Dark</a>
<a href="#" onclick="setTheme('system'); location.reload();" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600" role="menuitem">System</a>
</div>
</div>
{% if session.logged_in %}
<a href="{{ url_for('settings') }}" class="text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white rounded-md px-3 py-2 text-sm font-medium">Settings</a>
<a href="{{ url_for('logout') }}" class="text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white rounded-md px-3 py-2 text-sm font-medium">Logout</a>
{% endif %}
</div>
</div>
</div>
</nav>
<header class="bg-white dark:bg-gray-800 shadow">
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">{{ title }}</h1>
</div>
</header>
<main>
<div class="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 rounded-md {{ 'bg-red-100 border-red-400 text-red-700' if category == 'error' else 'bg-blue-100 border-blue-400 text-blue-700' }} border p-4" role="alert">
<p class="font-bold">{{ category.capitalize() }}</p>
<p>{{ message }}</p>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{{ content | safe }}
</div>
</main>
</div>
<footer class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-8">
<div class="mx-auto max-w-7xl px-6 py-12 md:flex md:items-center md:justify-between lg:px-8">
<div class="mt-8 md:order-1 md:mt-0">
<p class="text-center text-xs leading-5 text-gray-500 dark:text-gray-400">
&copy; {{ now.year }} UrNetwork Stats Dashboard. All rights reserved.
<br>
Original concept by <a href="https://github.com/techroy23/UrNetwork-Stats-Dashboard" target="_blank" class="font-semibold text-indigo-600 dark:text-indigo-400 hover:text-indigo-500">techroy23</a>.
Refactored by Gemini.
<br>
Hosted on <a href="https://ur.io/app?bonus=HWP2NJ" target="_blank" class="font-semibold text-indigo-600 dark:text-indigo-400 hover:text-indigo-500">URnetwork VPN</a>.
</p>
</div>
</div>
</footer>
<script>
const themeMenuButton = document.getElementById('theme-menu-button');
const themeMenu = document.getElementById('theme-menu');
if(themeMenuButton) {
themeMenuButton.addEventListener('click', () => {
themeMenu.classList.toggle('hidden');
});
document.addEventListener('click', (e) => {
if (!themeMenuButton.contains(e.target) && !themeMenu.contains(e.target)) {
themeMenu.classList.add('hidden');
}
});
}
</script>
</body>
</html>
"""
INSTALL_TEMPLATE = """
<div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<h2 class="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900 dark:text-white">Initial Application Setup</h2>
<p class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">Please provide your UrNetwork credentials to configure the dashboard.</p>
</div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form class="space-y-6" action="{{ url_for('install') }}" method="POST">
<div>
<label for="ur_user" class="block text-sm font-medium leading-6 text-gray-900 dark:text-gray-200">UrNetwork Username</label>
<div class="mt-2">
<input id="ur_user" name="ur_user" type="text" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 dark:text-white bg-white dark:bg-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 p-2">
</div>
</div>
<div>
<label for="ur_pass" class="block text-sm font-medium leading-6 text-gray-900 dark:text-gray-200">UrNetwork Password</label>
<div class="mt-2">
<input id="ur_pass" name="ur_pass" type="password" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 dark:text-white bg-white dark:bg-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 p-2">
</div>
</div>
<div>
<button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Install & Verify</button>
</div>
</form>
</div>
</div>
"""
LOGIN_TEMPLATE = """
<div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<h2 class="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900 dark:text-white">Sign in to your dashboard</h2>
</div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form class="space-y-6" action="{{ url_for('login') }}{% if request.args.next %}?next={{ request.args.next }}{% endif %}" method="POST">
<div>
<label for="username" class="block text-sm font-medium leading-6 text-gray-900 dark:text-gray-200">Username</label>
<div class="mt-2">
<input id="username" name="username" type="text" autocomplete="username" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 dark:text-white bg-white dark:bg-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 p-2">
</div>
</div>
<div>
<div class="flex items-center justify-between">
<label for="password" class="block text-sm font-medium leading-6 text-gray-900 dark:text-gray-200">Password</label>
</div>
<div class="mt-2">
<input id="password" name="password" type="password" autocomplete="current-password" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 dark:text-white bg-white dark:bg-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 p-2">
</div>
</div>
<div>
<button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
</div>
</form>
</div>
</div>
"""
PUBLIC_DASHBOARD_TEMPLATE = """
<div class="space-y-8">
<div class="flex justify-end">
<p class="text-sm text-gray-500 dark:text-gray-400" id="transfer-stats-status">
Monitoring for updates...
</p>
</div>
<!-- Stats Cards -->
<div id="stats-cards-container" class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">Total Paid Data</dt>
<dd id="stat-paid-gb" class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ "%.3f"|format(latest.paid_gb if latest else 0) }} GB</dd>
</div>
<div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">Total Unpaid Data</dt>
<dd id="stat-unpaid-gb" class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ "%.3f"|format(latest.unpaid_gb if latest else 0) }} GB</dd>
</div>
<div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">Earnings (Last 30 Days)</dt>
<dd id="stat-monthly-earnings" class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">${{ "%.2f"|format(monthly_earnings) }}</dd>
</div>
<div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">Last Update</dt>
<dd id="stat-last-update" class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ latest.timestamp.strftime('%H:%M') if latest else 'N/A' }}</dd>
</div>
</div>
<!-- Chart -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">Total Data Provided Over Time (GB)</h3>
<div id="public-chart-container" class="relative h-96">
{% if chart_data.labels %}
<canvas id="publicStatsChart"></canvas>
{% else %}
<p class="text-center text-gray-500 dark:text-gray-400">Not enough data to display a chart. Check back later.</p>
{% endif %}
</div>
</div>
<!-- Locations Section -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
<div class="flex justify-between items-center">
<nav id="location-tabs" class="-mb-px flex space-x-8" aria-label="Tabs">
<a href="#" class="tab-link border-indigo-500 text-indigo-600 whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium" aria-current="page" data-tab="map-view">By Country (Map)</a>
<a href="#" class="tab-link border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300 whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium" data-tab="list-view">By Country (List)</a>
</nav>
<p class="text-sm text-gray-500 dark:text-gray-400">
Provider stats refresh in: <span id="provider-refresh-timer" class="font-semibold">02:00</span>
</p>
</div>
</div>
<div id="map-view" class="tab-content py-4">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">Provider Locations</h3>
<div id="map-container" class="bg-gray-200 dark:bg-gray-700 rounded-lg">
<div id="map"></div>
</div>
</div>
<div id="list-view" class="tab-content hidden py-4">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">Providers by Country</h3>
<div id="country-list-container" class="space-y-4">
<p class="text-center text-gray-500 dark:text-gray-400">Loading country data...</p>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// --- Global State ---
window.charts = {};
window.map = null;
window.geojsonLayer = null;
let latestTransferTimestamp = '{{ latest_timestamp or "" }}';
// --- Tabs ---
function initTabs() {
const tabs = document.querySelectorAll('.tab-link');
const contents = document.querySelectorAll('.tab-content');
tabs.forEach(tab => {
tab.addEventListener('click', (e) => {
e.preventDefault();
const targetId = tab.dataset.tab;
tabs.forEach(t => {
t.classList.remove('border-indigo-500', 'text-indigo-600');
t.classList.add('border-transparent', 'text-gray-500', 'hover:border-gray-300', 'hover:text-gray-700', 'dark:text-gray-400', 'dark:hover:border-gray-600', 'dark:hover:text-gray-300');
});
tab.classList.add('border-indigo-500', 'text-indigo-600');
contents.forEach(c => c.id === targetId ? c.classList.remove('hidden') : c.classList.add('hidden'));
if (targetId === 'map-view' && window.map) setTimeout(() => window.map.invalidateSize(), 1);
});
});
}
// --- Location Data (Map & List) ---
function updateLocationData(providerData) {
if (!providerData || !providerData.locations) return;
const worldGeoData = {{ world_map_data | tojson }};
if (!worldGeoData) {
document.getElementById('map-container').innerHTML = '<p class="text-center text-red-500 p-4">Could not load map data.</p>';
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 (window.geojsonLayer) {
window.map.removeLayer(window.geojsonLayer);
}
window.geojsonLayer = L.geoJson(worldGeoData, {
style: feature => ({
fillColor: (d => d > 100 ? '#08519c' : d > 25 ? '#3182bd' : d > 10 ? '#6baed6' : d > 0 ? '#eff3ff' : '#9ca3af')(feature.properties.density),
weight: 1, opacity: 1, color: document.documentElement.classList.contains('dark') ? '#4b5563' : 'white', dashArray: '3', fillOpacity: 0.7
}),
onEachFeature: (feature, layer) => layer.on({
mouseover: e => {
const l = e.target;
l.setStyle({ weight: 3, color: '#fdba74', dashArray: '', fillOpacity: 0.9 });
l.bringToFront();
window.mapInfo.update(l.feature.properties);
},
mouseout: e => { window.geojsonLayer.resetStyle(e.target); window.mapInfo.update(); }
})
}).addTo(window.map);
initCountryListView(providerData);
}
function initMap(providerData) {
const mapElement = document.getElementById('map');
if (!mapElement) return;
window.map = L.map(mapElement, { center: [20, 0], zoom: 2, minZoom: 2, maxZoom: 6, worldCopyJump: true });
const tileUrl = document.documentElement.classList.contains('dark') ? 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png' : 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png';
L.tileLayer(tileUrl, { attribution: '&copy; OpenStreetMap &copy; CARTO' }).addTo(window.map);
window.mapInfo = L.control();
window.mapInfo.onAdd = function() { this._div = L.DomUtil.create('div', 'map-legend'); this.update(); return this._div; };
window.mapInfo.update = function(props) { this._div.innerHTML = `<h4>Provider Count</h4><b>${props ? props.name : 'Hover over a country'}</b><br />${props ? props.density || 0 : '0'} providers`; };
window.mapInfo.addTo(window.map);
updateLocationData(providerData);
}
function initCountryListView(providerData) {
const container = document.getElementById('country-list-container');
if (!container) return;
container.innerHTML = '';
if (!providerData.locations || providerData.locations.length === 0) {
container.innerHTML = '<p class="text-center text-gray-500 dark:text-gray-400">No country data available.</p>';
return;
}
const table = document.createElement('table');
table.className = 'min-w-full divide-y divide-gray-300 dark:divide-gray-700';
table.innerHTML = `<thead class="bg-gray-50 dark:bg-gray-700"><tr><th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-200 sm:pl-6">Country</th><th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Providers</th></tr></thead><tbody class="divide-y divide-gray-200 dark:divide-gray-600 bg-white dark:bg-gray-800"></tbody>`;
const tbody = table.querySelector('tbody');
providerData.locations.sort((a, b) => b.provider_count - a.provider_count).forEach(loc => {
const tr = document.createElement('tr');
tr.innerHTML = `<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-white sm:pl-6"><img src="https://flagcdn.com/w20/${loc.country_code.toLowerCase()}.png" class="inline-block h-4 w-6 mr-3" alt="${loc.name} flag"> ${loc.name}</td><td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">${loc.provider_count}</td>`;
tbody.appendChild(tr);
});
container.appendChild(table);
}
function fetchLocationData() {
fetch('{{ url_for("get_locations") }}').then(res => res.json()).then(updateLocationData)
.catch(error => console.error('Error fetching location data:', error));
}
// --- Transfer Stats (Cards & Chart) ---
function updatePublicTransferStats(data) {
document.getElementById('stat-paid-gb').textContent = `${data.public_stats.paid_gb} GB`;
document.getElementById('stat-unpaid-gb').textContent = `${data.public_stats.unpaid_gb} GB`;
document.getElementById('stat-monthly-earnings').textContent = `$${data.monthly_earnings.toFixed(2)}`;
document.getElementById('stat-last-update').textContent = data.public_stats.last_update;
if (window.charts.publicStatsChart) {
window.charts.publicStatsChart.data.labels = data.public_chart_data.labels;
window.charts.publicStatsChart.data.datasets[0].data = data.public_chart_data.data;
window.charts.publicStatsChart.update();
}
}
function initPublicChart(chartData) {
const container = document.getElementById('public-chart-container');
if (!container) return;
container.innerHTML = '<canvas id="publicStatsChart"></canvas>';
const ctx = document.getElementById('publicStatsChart').getContext('2d');
Chart.defaults.color = document.documentElement.classList.contains('dark') ? '#e5e7eb' : '#1f2937';
Chart.defaults.borderColor = document.documentElement.classList.contains('dark') ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
window.charts.publicStatsChart = new Chart(ctx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: [{ label: 'Total Data (GB)', data: chartData.data, fill: true, borderColor: 'rgb(79, 70, 229)', backgroundColor: 'rgba(79, 70, 229, 0.1)', tension: 0.1 }]
},
options: { responsive: true, maintainAspectRatio: false }
});
}
// --- Timers and Polling ---
function startProviderRefreshTimer() {
const timerSpan = document.getElementById('provider-refresh-timer');
let seconds = 120;
setInterval(() => {
seconds--;
if (seconds < 0) {
seconds = 120;
fetchLocationData();
}
if (timerSpan) {
timerSpan.textContent = `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
}
}, 1000);
}
function startTransferStatsPolling() {
const statusEl = document.getElementById('transfer-stats-status');
setInterval(() => {
fetch('{{ url_for("get_transfer_data") }}').then(res => res.json()).then(data => {
if (data.status === 'ok' && data.latest_timestamp !== latestTransferTimestamp) {
latestTransferTimestamp = data.latest_timestamp;
updatePublicTransferStats(data);
if (statusEl) statusEl.textContent = `Stats updated at ${new Date().toLocaleTimeString()}`;
} else {
if (statusEl) statusEl.textContent = `Stats up to date. Last check: ${new Date().toLocaleTimeString()}`;
}
}).catch(error => console.error('Error fetching transfer data:', error));
}, 60000); // Poll every minute
}
// --- Initial Load ---
initTabs();
initPublicChart({{ chart_data | tojson }});
fetch('{{ url_for("get_locations") }}').then(res => res.json()).then(initMap).catch(e => console.error(e));
startProviderRefreshTimer();
startTransferStatsPolling();
});
</script>
"""
PRIVATE_DASHBOARD_TEMPLATE = """
<div class="space-y-8">
<div class="flex justify-between items-center">
<div>
<form method="post" action="{{ url_for('trigger_fetch') }}" class="inline-block">
<button type="submit" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">Fetch Now</button>
</form>
<form method="post" action="{{ url_for('clear_db') }}" onsubmit="return confirm('Are you sure? This will delete all historical stats data.');" class="inline-block">
<button type="submit" class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">Clear All Data</button>
</form>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Next automatic fetch: <span id="job-timer" class="font-semibold">--:--</span>
</p>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">Total Paid Data</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ "%.3f"|format(latest.paid_gb if latest else 0) }} GB</dd>
</div>
<div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">Total Unpaid Data</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ "%.3f"|format(latest.unpaid_gb if latest else 0) }} GB</dd>
</div>
<div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">Total Earnings</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">${{ "%.2f"|format(total_earnings) }}</dd>
</div>
<div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">Last Update</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ latest.timestamp.strftime('%H:%M') if latest else 'N/A' }}</dd>
</div>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">Paid vs. Unpaid Data (GB)</h3>
<div class="relative h-96">
{% if chart_data.labels and chart_data.labels|length > 0 %}<canvas id="paidUnpaidChart"></canvas>{% else %}<p class="text-center text-gray-500 dark:text-gray-400">Not enough data for this chart.</p>{% endif %}
</div>
</div>
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">Data Change per Interval (GB)</h3>
<div class="relative h-96">
{% if chart_data.deltas and chart_data.deltas|length > 0 %}<canvas id="deltaChart"></canvas>{% else %}<p class="text-center text-gray-500 dark:text-gray-400">Not enough data for this chart.</p>{% endif %}
</div>
</div>
</div>
<!-- History Table -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">Detailed History</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Timestamp</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Paid (GB)</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Unpaid (GB)</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Change (GB)</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-600 bg-white dark:bg-gray-800">
{% for row in rows %}
<tr>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ row.ts_str }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ "%.3f"|format(row.e.paid_gb) }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ "%.3f"|format(row.e.unpaid_gb) }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm {% if row.delta_gb is not none %}{% if row.delta_gb >= 0 %}text-green-600 dark:text-green-400{% else %}text-red-600 dark:text-red-400{% endif %}{% else %}text-gray-500 dark:text-gray-400{% endif %}">
{% if row.delta_gb is not none %}{{ '%+.3f'|format(row.delta_gb) }}{% else %}N/A{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center py-4 text-gray-500 dark:text-gray-400">No data available.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
window.charts = {};
function initPrivateCharts(chartData) {
Chart.defaults.color = document.documentElement.classList.contains('dark') ? '#e5e7eb' : '#1f2937';
Chart.defaults.borderColor = document.documentElement.classList.contains('dark') ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
if (chartData.labels && chartData.labels.length) {
const paidUnpaidCtx = document.getElementById('paidUnpaidChart')?.getContext('2d');
if (paidUnpaidCtx) {
window.charts.paidUnpaidChart = new Chart(paidUnpaidCtx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: [
{ label: 'Unpaid (GB)', data: chartData.unpaid_gb, borderColor: 'rgb(239, 68, 68)', backgroundColor: 'rgba(239, 68, 68, 0.2)', fill: true, tension: 0.1 },
{ label: 'Paid (GB)', data: chartData.paid_gb, borderColor: 'rgb(34, 197, 94)', backgroundColor: 'rgba(34, 197, 94, 0.2)', fill: true, tension: 0.1 }
]
},
options: { responsive: true, maintainAspectRatio: false }
});
}
}
if (chartData.deltas && chartData.deltas.length) {
const deltaCtx = document.getElementById('deltaChart')?.getContext('2d');
if (deltaCtx) {
window.charts.deltaChart = new Chart(deltaCtx, {
type: 'bar',
data: {
labels: chartData.labels.slice(1),
datasets: [{
label: 'Change in Total Data (GB)',
data: chartData.deltas,
backgroundColor: (c) => c.raw >= 0 ? 'rgba(34, 197, 94, 0.6)' : 'rgba(239, 68, 68, 0.6)'
}]
},
options: { responsive: true, maintainAspectRatio: false }
});
}
}
}
function startJobTimer() {
const timerSpan = document.getElementById('job-timer');
if (!timerSpan) return;
setInterval(() => {
const now = new Date();
const minutes = now.getMinutes();
const nextJobMinute = (Math.floor(minutes / 15) + 1) * 15;
const nextJobTime = new Date(now);
if (nextJobMinute >= 60) {
nextJobTime.setHours(now.getHours() + 1);
nextJobTime.setMinutes(0);
} else {
nextJobTime.setMinutes(nextJobMinute);
}
nextJobTime.setSeconds(0);
const diff = Math.round((nextJobTime - now) / 1000);
const minsLeft = Math.floor(diff / 60);
const secsLeft = diff % 60;
timerSpan.textContent = `${minsLeft.toString().padStart(2, '0')}:${secsLeft.toString().padStart(2, '0')}`;
}, 1000);
}
initPrivateCharts({{ chart_data | tojson }});
startJobTimer();
});
</script>
"""
SETTINGS_TEMPLATE = """
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<form method="post" action="{{ url_for('settings') }}">
<div class="space-y-12">
<div class="border-b border-gray-900/10 dark:border-gray-200/10 pb-12">
<h2 class="text-base font-semibold leading-7 text-gray-900 dark:text-white">Feature Flags</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Enable or disable optional dashboard components.</p>
<div class="mt-10 space-y-10">
<fieldset>
<div class="space-y-6">
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="enable_account_stats" name="enable_account_stats" type="checkbox" class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-600 bg-gray-200 dark:bg-gray-900" {% if settings.ENABLE_ACCOUNT_STATS %}checked{% endif %}>
</div>
<div class="text-sm leading-6">
<label for="enable_account_stats" class="font-medium text-gray-900 dark:text-white">Account Stats Page</label>
<p class="text-gray-500 dark:text-gray-400">Enable the 'Account & Leaderboard' page.</p>
</div>
</div>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="enable_device_stats" name="enable_device_stats" type="checkbox" class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-600 bg-gray-200 dark:bg-gray-900" {% if settings.ENABLE_DEVICE_STATS %}checked{% endif %}>
</div>
<div class="text-sm leading-6">
<label for="enable_device_stats" class="font-medium text-gray-900 dark:text-white">Devices Page</label>
<p class="text-gray-500 dark:text-gray-400">Enable the 'Devices' page for status and management.</p>
</div>
</div>
</div>
</fieldset>
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-x-6">
<a href="{{ url_for('private_dashboard') }}" class="text-sm font-semibold leading-6 text-gray-900 dark:text-white">Cancel</a>
<button type="submit" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Save Settings</button>
</div>
</form>
</div>
"""
ACCOUNT_PAGE_TEMPLATE = """
<div class="space-y-8">
<!-- Account Details Section -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">Account Details</h3>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
<div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">Account Points</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ account_details.points if account_details.points is not none else 'N/A' }}</dd>
</div>
<div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">Your Rank</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">#{{ account_details.ranking.leaderboard_rank if account_details.ranking and account_details.ranking.leaderboard_rank is not none else 'N/A' }}</dd>
</div>
<div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">Total Referrals</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ account_details.referrals.total_referrals if account_details.referrals else 'N/A' }}</dd>
</div>
</div>
</div>
<!-- Leaderboard Section -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">Leaderboard</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-200 sm:pl-6">Rank</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Network Name</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Data Provided (MiB)</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-600 bg-white dark:bg-gray-800">
{% for earner in leaderboard %}
<tr class="{% if earner.network_name == account_details.ranking.network_name %} bg-indigo-50 dark:bg-indigo-900/20 {% endif %}">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-white sm:pl-6">{{ loop.index }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ earner.network_name if earner.is_public and not earner.contains_profanity else '[private]' }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ "%.2f"|format(earner.net_mib_count) }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-center py-4 text-gray-500 dark:text-gray-400">Could not fetch leaderboard data.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
"""
DEVICES_PAGE_TEMPLATE = """
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">Device Status & Management</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-200 sm:pl-6">Status</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Device Name</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Client ID</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200">Provide Mode</th>
<th class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Remove</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-600 bg-white dark:bg-gray-800">
{% for device in devices %}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium sm:pl-6">
{% if device.connections %}
<span class="inline-flex items-center rounded-md bg-green-100 dark:bg-green-900 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 ring-1 ring-inset ring-green-600/20">Online</span>
{% else %}
<span class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 ring-1 ring-inset ring-gray-500/10">Offline</span>
{% endif %}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ device.device_name or 'Unnamed Device' }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400 font-mono text-xs">{{ device.client_id }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ device.provide_mode_str }}</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<form method="post" action="{{ url_for('private_remove_device', client_id=device.client_id) }}" onsubmit="return confirm('Are you sure you want to remove this device? This cannot be undone.');">
<button type="submit" class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">Remove<span class="sr-only">, {{ device.device_name }}</span></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center py-4 text-gray-500 dark:text-gray-400">Could not fetch device data or no devices found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
"""
# --- Template Rendering Helper ---
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 for embedding.")
except Exception as e:
logging.error(f"Could not download and cache world map data. Map will be disabled. Error: {e}")
def render_page(template_content, **context):
"""Renders a page by injecting its content into the main layout."""
if context.get('is_public_dashboard'):
context['world_map_data'] = WORLD_MAP_DATA
if session.get('logged_in'):
context['settings'] = {
'ENABLE_ACCOUNT_STATS': get_boolean_setting('ENABLE_ACCOUNT_STATS'),
'ENABLE_DEVICE_STATS': get_boolean_setting('ENABLE_DEVICE_STATS'),
}
content = render_template_string(template_content, **context)
return render_template_string(LAYOUT_TEMPLATE, content=content, **context)
# --- Middleware and Before Request Handlers ---
@app.before_request
def check_installation_and_init():
"""Before each request, check if the app is installed and set up g."""
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 after install: Initializing settings in database.")
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 for access."""
return {"now": g.now}
# --- Routes ---
@app.route('/install', methods=['GET', 'POST'])
def install():
if is_installed():
flash("Application is already installed.", "info")
return redirect(url_for('public_dashboard'))
if request.method == 'POST':
user = request.form.get('ur_user')
password = request.form.get('ur_pass')
if not user or not password:
flash("Username and Password are required.", "error")
return render_page(INSTALL_TEMPLATE, title="Setup")
jwt = get_jwt_from_credentials(user, password)
if not jwt:
flash("Could not verify credentials with UrNetwork API. Please check them and try again.", "error")
return render_page(INSTALL_TEMPLATE, title="Setup")
config_data = {
"UR_USER": user,
"UR_PASS": password,
"SECRET_KEY": secrets.token_hex(24)
}
if save_env_file(config_data):
with app.app_context():
db.create_all()
session['logged_in'] = True
flash("Installation successful! Your dashboard is now configured.", "info")
return redirect(url_for('private_dashboard'))
else:
flash("A server error occurred while saving the configuration file.", "error")
return render_page(INSTALL_TEMPLATE, 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':
username = request.form.get('username')
password = request.form.get('password')
if username == os.getenv('UR_USER') and password == os.getenv('UR_PASS'):
session['logged_in'] = True
flash('You have been logged in successfully.', 'info')
next_url = request.args.get('next')
return redirect(next_url or url_for('private_dashboard'))
else:
flash('Invalid username or password.', 'error')
return render_page(LOGIN_TEMPLATE, title="Login")
@app.route('/logout')
def logout():
session.pop('logged_in', None)
flash('You have been logged out.', 'info')
return redirect(url_for('public_dashboard'))
@app.route('/')
def public_dashboard():
"""Display the public-facing dashboard with summary stats, map, and a graph."""
entries = Stats.query.order_by(Stats.timestamp.asc()).all()
latest = entries[-1] if entries else None
jwt = get_valid_jwt()
payments = fetch_payment_stats(jwt) if jwt else []
_, monthly_earnings = calculate_earnings(payments)
chart_data = {
"labels": [e.timestamp.strftime('%m-%d %H:%M') for e in entries],
"data": [e.paid_gb + e.unpaid_gb for e in entries]
}
latest_timestamp = latest.timestamp.isoformat() if latest else None
return render_page(PUBLIC_DASHBOARD_TEMPLATE, title="Public Dashboard", latest=latest, monthly_earnings=monthly_earnings, chart_data=chart_data, is_public_dashboard=True, latest_timestamp=latest_timestamp)
@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('/api/transfer_data')
def get_transfer_data():
"""API endpoint for public page polling."""
entries = Stats.query.order_by(Stats.timestamp.asc()).all()
if not entries: return jsonify({"status": "no_data"})
latest_entry = entries[-1]
jwt = get_valid_jwt()
payments = fetch_payment_stats(jwt) if jwt else []
_, monthly_earnings = calculate_earnings(payments)
return jsonify({
"status": "ok",
"latest_timestamp": latest_entry.timestamp.isoformat(),
"public_stats": {
"paid_gb": "%.3f" % latest_entry.paid_gb,
"unpaid_gb": "%.3f" % latest_entry.unpaid_gb,
"last_update": latest_entry.timestamp.strftime('%H:%M')
},
"public_chart_data": {
"labels": [e.timestamp.strftime('%m-%d %H:%M') for e in entries],
"data": [(e.paid_gb + e.unpaid_gb) for e in entries]
},
"monthly_earnings": monthly_earnings
})
# --- Private Dashboard Routes ---
@app.route('/dashboard')
@login_required
def private_dashboard():
"""Display the detailed private dashboard overview."""
entries = Stats.query.order_by(Stats.timestamp.desc()).all()
latest = entries[0] if entries else None
jwt = get_valid_jwt()
payments = fetch_payment_stats(jwt) if jwt else []
total_earnings, _ = calculate_earnings(payments)
rows = []
for i, e in enumerate(entries):
delta_gb = None
if i < len(entries) - 1:
prev_entry = entries[i+1]
delta_gb = (e.paid_gb + e.unpaid_gb) - (prev_entry.paid_gb + prev_entry.unpaid_gb)
rows.append({"e": e, "ts_str": e.timestamp.strftime("%Y-%m-%d %H:%M:%S"), "delta_gb": delta_gb})
rev_entries = list(reversed(entries))
deltas = [r['delta_gb'] for r in reversed(rows) if r['delta_gb'] is not None]
chart_data = {
"labels": [e.timestamp.strftime('%m-%d %H:%M') for e in rev_entries],
"paid_gb": [e.paid_gb for e in rev_entries],
"unpaid_gb": [e.unpaid_gb for e in rev_entries],
"deltas": deltas if deltas else []
}
return render_page(
PRIVATE_DASHBOARD_TEMPLATE, title="Owner Dashboard - Overview",
rows=rows, latest=latest, total_earnings=total_earnings, chart_data=chart_data
)
@app.route('/dashboard/account')
@login_required
def private_account_page():
"""Display the account details and leaderboard page."""
if not get_boolean_setting('ENABLE_ACCOUNT_STATS'):
flash("This feature is currently disabled. You can enable it in the settings.", "warning")
return redirect(url_for('private_dashboard'))
jwt = get_valid_jwt()
account_details = fetch_account_details(jwt) if jwt else {}
leaderboard = fetch_leaderboard(jwt) if jwt else []
return render_page(
ACCOUNT_PAGE_TEMPLATE, title="Account & Leaderboard",
account_details=account_details, leaderboard=leaderboard
)
@app.route('/dashboard/devices')
@login_required
def private_devices_page():
"""Display the device management page."""
if not get_boolean_setting('ENABLE_DEVICE_STATS'):
flash("This feature is currently disabled. You can enable it in the settings.", "warning")
return redirect(url_for('private_dashboard'))
jwt = get_valid_jwt()
devices = fetch_devices(jwt) if jwt else []
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 render_page(DEVICES_PAGE_TEMPLATE, title="Device Management", devices=devices)
@app.route('/dashboard/devices/remove/<client_id>', methods=["POST"])
@login_required
def private_remove_device(client_id):
"""Handle the removal of a device."""
jwt = get_valid_jwt()
success, message = remove_device(jwt, client_id)
if success:
flash(message, "info")
else:
flash(f"Failed to remove device: {message}", "error")
return redirect(url_for('private_devices_page'))
@app.route('/settings', methods=['GET', 'POST'])
@login_required
def settings():
"""Page to manage feature flag settings."""
if request.method == 'POST':
settings_map = {
'ENABLE_ACCOUNT_STATS': 'enable_account_stats',
'ENABLE_DEVICE_STATS': 'enable_device_stats',
}
for key, form_key in settings_map.items():
setting = Setting.query.get(key)
if setting:
setting.value = str(request.form.get(form_key) == 'on')
db.session.commit()
flash("Settings saved successfully.", "info")
return redirect(url_for('settings'))
return render_page(SETTINGS_TEMPLATE, title="Settings")
@app.route("/trigger", methods=["POST"])
@login_required
def trigger_fetch():
"""Manually trigger a stats fetch."""
try:
jwt = get_valid_jwt()
if not jwt: raise RuntimeError("Failed to get a valid API token.")
stats_data = fetch_transfer_stats(jwt)
if not stats_data: raise RuntimeError("Failed to fetch stats from API.")
entry = Stats(
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()
flash("Successfully fetched and saved latest stats.", "info")
except Exception as e:
logging.error(f"Manual fetch failed: {e}")
flash(f"Failed to fetch stats: {e}", "error")
return redirect(url_for('private_dashboard'))
@app.route("/clear", methods=["POST"])
@login_required
def clear_db():
"""Delete all stats records from the database."""
try:
num_rows_deleted = db.session.query(Stats).delete()
db.session.commit()
flash(f"Successfully cleared {num_rows_deleted} records from the database.", "info")
except Exception as e:
logging.error(f"Failed to clear database: {e}")
flash("An error occurred while clearing the database.", "error")
db.session.rollback()
return redirect(url_for('private_dashboard'))
@app.route("/webhooks/add", methods=["POST"])
@login_required
def add_webhook():
"""Add a new webhook."""
url = request.form.get("webhook_url")
if not url or not (url.startswith("http://") or url.startswith("https://")):
flash("A valid Webhook URL is required.", "error")
elif Webhook.query.filter_by(url=url).first():
flash("This webhook URL is already registered.", "error")
else:
new_webhook = Webhook(url=url)
db.session.add(new_webhook)
db.session.commit()
flash("Webhook added successfully.", "info")
return redirect(url_for('private_dashboard'))
@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("Webhook deleted successfully.", "info")
else:
flash("Webhook not found.", "error")
return redirect(url_for('private_dashboard'))
# --- 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)