2431 lines
62 KiB
Python
2431 lines
62 KiB
Python
# ----------------------------------------
|
|
|
|
# UrNetwork Stats Dashboard - Refactored
|
|
|
|
# ----------------------------------------
|
|
|
|
# Original Author: https://github.com/techroy23/UrNetwork-Stats-Dashboard
|
|
|
|
# Refactored with Public/Private views, UI, Graphs, Installer, Auth, Dark Theme, and Provider Map.
|
|
|
|
#
|
|
|
|
# --- 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
|
|
|
|
|
|
# --- 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:
|
|
|
|
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"
|
|
|
|
|
|
# --- 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)
|
|
|
|
|
|
# --- Helper Functions ---
|
|
|
|
|
|
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")
|
|
|
|
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)
|
|
|
|
raise RuntimeError(f"All {retries} attempts to {method.upper()} {url} failed: {last_exc}")
|
|
|
|
|
|
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},
|
|
|
|
)
|
|
|
|
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)
|
|
|
|
|
|
|
|
def fetch_transfer_stats(jwt_token):
|
|
|
|
"""Retrieve transfer statistics using the provided JWT."""
|
|
|
|
headers = {"Authorization": f"Bearer {jwt_token}", "Accept": "*/*"}
|
|
|
|
resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/transfer/stats", headers=headers)
|
|
|
|
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 None
|
|
|
|
try:
|
|
|
|
headers = {"Authorization": f"Bearer {jwt_token}", "Accept": "*/*"}
|
|
|
|
resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/account/payments", headers=headers)
|
|
|
|
return resp.json().get("account_payments", [])
|
|
|
|
except Exception as e:
|
|
|
|
logging.error(f"Failed to fetch payment stats: {e}")
|
|
|
|
return []
|
|
|
|
|
|
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:
|
|
|
|
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."""
|
|
|
|
try:
|
|
|
|
resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/network/provider-locations")
|
|
|
|
return resp.json()
|
|
|
|
except Exception as e:
|
|
|
|
logging.error(f"Failed to fetch provider locations: {e}")
|
|
|
|
return None
|
|
|
|
|
|
def send_webhook_notification(stats_data):
|
|
|
|
"""Sends a notification to all configured webhooks."""
|
|
|
|
webhooks = Webhook.query.all()
|
|
|
|
if not webhooks:
|
|
|
|
return
|
|
|
|
|
|
# A more generic payload that might work for Discord, Slack, etc.
|
|
|
|
payload = {
|
|
|
|
"embeds": [{
|
|
|
|
"title": "UrNetwork Stats Update",
|
|
|
|
"description": "New data has been synced from the UrNetwork API.",
|
|
|
|
"color": 5814783, # A nice blue color
|
|
|
|
"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)
|
|
|
|
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; }
|
|
|
|
</style>
|
|
|
|
<script>
|
|
|
|
// Set theme on page load
|
|
|
|
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>
|
|
|
|
<a href="{{ url_for('private_dashboard') }}" class="{% if request.endpoint in ['private_dashboard', 'login'] %}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">Owner Dashboard</a>
|
|
|
|
</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('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">
|
|
|
|
<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">
|
|
|
|
© {{ 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>.
|
|
|
|
<br>
|
|
|
|
<a href="https://hits.sh/38.242.156.120:90/"><img alt="Hits" src="https://hits.sh/38.242.156.120:90.svg?style=for-the-badge"/></a>
|
|
|
|
</p>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</footer>
|
|
|
|
<script>
|
|
|
|
// Theme menu toggle
|
|
|
|
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: '© OpenStreetMap © 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();
|
|
|
|
}
|
|
|
|
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);
|
|
|
|
statusEl.textContent = `Stats updated at ${new Date().toLocaleTimeString()}`;
|
|
|
|
} else {
|
|
|
|
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?');" 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" id="transfer-stats-status">
|
|
|
|
Monitoring for updates...
|
|
|
|
</p>
|
|
|
|
</div>
|
|
|
|
|
|
<!-- Summary 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">Total Earnings</dt>
|
|
|
|
<dd id="stat-total-earnings" 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 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>
|
|
|
|
|
|
|
|
<!-- 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 id="paid-unpaid-chart-container" 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 id="delta-chart-container" 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>
|
|
|
|
|
|
|
|
<!-- Webhook Management -->
|
|
|
|
<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">Webhook Management</h3>
|
|
|
|
<form method="post" action="{{ url_for('add_webhook') }}" class="flex items-center space-x-2 mb-4">
|
|
|
|
<input type="url" name="webhook_url" placeholder="https://discord.com/api/webhooks/..." 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">
|
|
|
|
<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">Add Webhook</button>
|
|
|
|
</form>
|
|
|
|
|
|
|
|
<ul id="webhook-list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
|
|
{% for webhook in webhooks %}
|
|
|
|
<li class="py-2 flex justify-between items-center">
|
|
|
|
<span class="text-gray-700 dark:text-gray-300 truncate">{{ webhook.url }}</span>
|
|
|
|
<form method="post" action="{{ url_for('delete_webhook', webhook_id=webhook.id) }}" class="inline-block">
|
|
|
|
<button type="submit" class="text-red-500 hover:text-red-700 text-sm font-semibold">Delete</button>
|
|
|
|
</form>
|
|
|
|
</li>
|
|
|
|
{% else %}
|
|
|
|
<li class="py-2 text-center text-gray-500 dark:text-gray-400">No webhooks configured.</li>
|
|
|
|
{% endfor %}
|
|
|
|
</ul>
|
|
|
|
</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 id="history-table-body" 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() {
|
|
|
|
// --- Global State ---
|
|
|
|
window.charts = {};
|
|
|
|
let latestTransferTimestamp = '{{ latest_timestamp or "" }}';
|
|
|
|
|
|
|
|
// --- Charting ---
|
|
|
|
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)';
|
|
|
|
|
|
|
|
// Paid/Unpaid Chart
|
|
|
|
const paidUnpaidContainer = document.getElementById('paid-unpaid-chart-container');
|
|
|
|
if (chartData.labels && chartData.labels.length) {
|
|
|
|
paidUnpaidContainer.innerHTML = '<canvas id="paidUnpaidChart"></canvas>';
|
|
|
|
const paidUnpaidCtx = document.getElementById('paidUnpaidChart').getContext('2d');
|
|
|
|
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 }
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Delta Chart
|
|
|
|
const deltaContainer = document.getElementById('delta-chart-container');
|
|
|
|
if (chartData.deltas && chartData.deltas.length) {
|
|
|
|
deltaContainer.innerHTML = '<canvas id="deltaChart"></canvas>';
|
|
|
|
const deltaCtx = document.getElementById('deltaChart').getContext('2d');
|
|
|
|
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 updatePrivateData(data) {
|
|
|
|
// Update summary cards
|
|
|
|
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-total-earnings').textContent = `$${data.total_earnings.toFixed(2)}`;
|
|
|
|
document.getElementById('stat-last-update').textContent = data.public_stats.last_update;
|
|
|
|
|
|
|
|
// Update charts
|
|
|
|
if (window.charts.paidUnpaidChart) {
|
|
|
|
const chart = window.charts.paidUnpaidChart;
|
|
|
|
chart.data.labels = data.private_chart_data.labels;
|
|
|
|
chart.data.datasets[0].data = data.private_chart_data.unpaid_gb;
|
|
|
|
chart.data.datasets[1].data = data.private_chart_data.paid_gb;
|
|
|
|
chart.update();
|
|
|
|
}
|
|
|
|
if (window.charts.deltaChart) {
|
|
|
|
const chart = window.charts.deltaChart;
|
|
|
|
chart.data.labels = data.private_chart_data.labels.slice(1);
|
|
|
|
chart.data.datasets[0].data = data.private_chart_data.deltas;
|
|
|
|
chart.update();
|
|
|
|
}
|
|
|
|
|
|
// Update table
|
|
|
|
const tbody = document.getElementById('history-table-body');
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
if (data.private_rows && data.private_rows.length) {
|
|
|
|
data.private_rows.forEach(row => {
|
|
|
|
const tr = document.createElement('tr');
|
|
|
|
const deltaFormatted = row.delta_gb !== null ? (row.delta_gb > 0 ? '+' : '') + row.delta_gb.toFixed(3) : 'N/A';
|
|
|
|
const deltaColor = row.delta_gb !== null ? (row.delta_gb >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400') : 'text-gray-500 dark:text-gray-400';
|
|
|
|
tr.innerHTML = `
|
|
|
|
<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">${row.paid_gb.toFixed(3)}</td>
|
|
|
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">${row.unpaid_gb.toFixed(3)}</td>
|
|
|
|
<td class="whitespace-nowrap px-3 py-4 text-sm ${deltaColor}">${deltaFormatted}</td>
|
|
|
|
`;
|
|
|
|
tbody.appendChild(tr);
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-gray-500 dark:text-gray-400">No data available.</td></tr>';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// --- Polling ---
|
|
|
|
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;
|
|
|
|
updatePrivateData(data);
|
|
|
|
statusEl.textContent = `Stats updated at ${new Date().toLocaleTimeString()}`;
|
|
|
|
} else {
|
|
|
|
statusEl.textContent = `Stats up to date. Last check: ${new Date().toLocaleTimeString()}`;
|
|
|
|
}
|
|
|
|
}).catch(error => console.error('Error polling for private data:', error));
|
|
|
|
}, 60000); // Poll every minute
|
|
|
|
}
|
|
|
|
|
|
// --- Initial Load ---
|
|
|
|
initPrivateCharts({{ chart_data | tojson }});
|
|
|
|
startTransferStatsPolling();
|
|
|
|
});
|
|
|
|
</script>
|
|
|
|
"""
|
|
|
|
|
|
# --- Template Rendering Helper ---
|
|
|
|
|
|
# Load world map data once at startup
|
|
|
|
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
|
|
|
|
|
|
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():
|
|
|
|
"""Before each request, check if the app is installed."""
|
|
|
|
if request.endpoint not in ['install', 'static', 'get_locations', 'get_transfer_data'] and not is_installed():
|
|
|
|
return redirect(url_for('install'))
|
|
|
|
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 = {
|
|
|
|
"UR_USER": user,
|
|
|
|
"UR_PASS": password,
|
|
|
|
"SECRET_KEY": os.urandom(24).hex()
|
|
|
|
}
|
|
|
|
if save_env_file(config):
|
|
|
|
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)
|
|
|
|
_, 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, simplifying client-side fetching."""
|
|
|
|
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 to get all transfer stats for dynamic updates."""
|
|
|
|
entries = Stats.query.order_by(Stats.timestamp.asc()).all()
|
|
|
|
if not entries:
|
|
|
|
return jsonify({"status": "no_data"})
|
|
|
|
|
|
latest_entry = entries[-1]
|
|
|
|
latest_timestamp = latest_entry.timestamp.isoformat()
|
|
|
|
|
|
|
|
jwt = get_valid_jwt()
|
|
|
|
payments = fetch_payment_stats(jwt)
|
|
|
|
total_earnings, monthly_earnings = calculate_earnings(payments)
|
|
|
|
|
|
# Data for Public View
|
|
|
|
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]
|
|
|
|
}
|
|
|
|
|
|
# Data for Private View
|
|
|
|
desc_entries = sorted(entries, key=lambda x: x.timestamp, reverse=True)
|
|
|
|
private_rows = []
|
|
|
|
for i, e in enumerate(desc_entries):
|
|
|
|
delta_gb = None
|
|
|
|
if i < len(desc_entries) - 1:
|
|
|
|
prev_entry = desc_entries[i+1]
|
|
|
|
delta_gb = (e.paid_gb + e.unpaid_gb) - (prev_entry.paid_gb + prev_entry.unpaid_gb)
|
|
|
|
private_rows.append({
|
|
|
|
"ts_str": e.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
|
"paid_gb": e.paid_gb,
|
|
|
|
"unpaid_gb": e.unpaid_gb,
|
|
|
|
"delta_gb": delta_gb
|
|
|
|
})
|
|
|
|
|
|
|
|
rev_entries = list(reversed(desc_entries))
|
|
|
|
deltas = [r['delta_gb'] for r in reversed(private_rows) if r['delta_gb'] is not None]
|
|
|
|
private_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
|
|
|
|
}
|
|
|
|
|
|
return jsonify({
|
|
|
|
"status": "ok",
|
|
|
|
"latest_timestamp": latest_timestamp,
|
|
|
|
"public_stats": public_stats,
|
|
|
|
"public_chart_data": public_chart_data,
|
|
|
|
"monthly_earnings": monthly_earnings,
|
|
|
|
"total_earnings": total_earnings,
|
|
|
|
"private_rows": private_rows,
|
|
|
|
"private_chart_data": private_chart_data
|
|
|
|
})
|
|
|
|
|
|
|
|
@app.route('/dashboard')
|
|
|
|
@login_required
|
|
|
|
def private_dashboard():
|
|
|
|
"""Display the detailed private dashboard for the owner."""
|
|
|
|
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)
|
|
|
|
total_earnings, _ = calculate_earnings(payments)
|
|
|
|
webhooks = Webhook.query.all()
|
|
|
|
|
|
|
|
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 []
|
|
|
|
}
|
|
|
|
|
|
|
|
latest_timestamp = entries[0].timestamp.isoformat() if entries else None
|
|
|
|
|
|
return render_page(
|
|
|
|
PRIVATE_DASHBOARD_TEMPLATE,
|
|
|
|
title="Owner Dashboard",
|
|
|
|
rows=rows,
|
|
|
|
latest=latest,
|
|
|
|
total_earnings=total_earnings,
|
|
|
|
chart_data=chart_data,
|
|
|
|
latest_timestamp=latest_timestamp,
|
|
|
|
webhooks=webhooks
|
|
|
|
)
|
|
|
|
|
|
@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)
|
|
|
|
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:
|
|
|
|
flash("Webhook URL cannot be empty.", "error")
|
|
|
|
return redirect(url_for('private_dashboard'))
|
|
|
|
|
|
# Basic URL validation
|
|
|
|
if not url.startswith("http://") and not url.startswith("https://"):
|
|
|
|
flash("Invalid webhook URL.", "error")
|
|
|
|
return redirect(url_for('private_dashboard'))
|
|
|
|
|
|
|
|
existing = Webhook.query.filter_by(url=url).first()
|
|
|
|
if existing:
|
|
|
|
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():
|
|
|
|
if is_installed():
|
|
|
|
db.create_all()
|
|
|
|
|
|
scheduler.start()
|
|
|
|
app.run(host="0.0.0.0", port=90, debug=False)
|
|
|