2025-07-05 20:33:26 +02:00
# ----------------------------------------
2025-07-08 17:56:05 +02:00
# UrNetwork Stats Dashboard - Refactored & Enhanced
2025-07-05 20:33:26 +02:00
# ----------------------------------------
# Original Author: https://github.com/techroy23/UrNetwork-Stats-Dashboard
# Refactored with Public/Private views, UI, Graphs, Installer, Auth, Dark Theme, and Provider Map.
2025-07-08 17:56:05 +02:00
# Enhanced with categorized, configurable features from the API and separate dashboard pages.
2025-07-05 20:33:26 +02:00
#
# --- 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
2025-07-08 17:56:05 +02:00
import secrets
2025-07-05 20:33:26 +02:00
# --- 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 :
2025-07-08 17:56:05 +02:00
if ' = ' in line and not line . strip ( ) . startswith ( ' # ' ) :
2025-07-05 20:33:26 +02:00
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 "
2025-07-08 17:56:05 +02:00
# --- 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 ' )
2025-07-05 20:33:26 +02:00
# --- 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 ) :
2025-07-08 17:56:05 +02:00
""" Represents a snapshot of paid vs unpaid bytes at a given timestamp. """
2025-07-05 20:33:26 +02:00
__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 )
2025-07-08 17:56:05 +02:00
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 )
2025-07-05 20:33:26 +02:00
# --- Helper Functions ---
2025-07-08 17:56:05 +02:00
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
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
def get_boolean_setting ( key ) :
""" Gets a boolean setting from the database. """
value = get_setting ( key , ' False ' )
return value . lower ( ) in ( ' true ' , ' 1 ' , ' t ' )
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
def is_installed ( ) :
2025-07-05 20:33:26 +02:00
""" 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 " )
2025-07-08 17:56:05 +02:00
# 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 " )
2025-07-05 20:33:26 +02:00
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 )
2025-07-08 17:56:05 +02:00
# 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
2025-07-05 20:33:26 +02:00
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 } ,
)
2025-07-08 17:56:05 +02:00
if not resp :
raise RuntimeError ( " API request failed after multiple retries. " )
2025-07-05 20:33:26 +02:00
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 )
2025-07-08 17:56:05 +02:00
# --- API Fetch Functions ---
2025-07-05 20:33:26 +02:00
def fetch_transfer_stats ( jwt_token ) :
""" Retrieve transfer statistics using the provided JWT. """
2025-07-08 17:56:05 +02:00
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
2025-07-05 20:33:26 +02:00
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. """
2025-07-08 17:56:05 +02:00
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. "
2025-07-05 20:33:26 +02:00
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
2025-07-08 17:56:05 +02:00
except ( ValueError , TypeError ) :
2025-07-05 20:33:26 +02:00
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. """
2025-07-08 17:56:05 +02:00
resp = request_with_retry ( " get " , f " { app . config [ ' UR_API_BASE ' ] } /network/provider-locations " )
return resp . json ( ) if resp else None
2025-07-05 20:33:26 +02:00
def send_webhook_notification ( stats_data ) :
""" Sends a notification to all configured webhooks. """
webhooks = Webhook . query . all ( )
2025-07-08 17:56:05 +02:00
if not webhooks : return
2025-07-05 20:33:26 +02:00
payload = {
" embeds " : [ {
" title " : " UrNetwork Stats Update " ,
" description " : " New data has been synced from the UrNetwork API. " ,
2025-07-08 17:56:05 +02:00
" color " : 5814783 ,
2025-07-05 20:33:26 +02:00
" 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 :
2025-07-08 17:56:05 +02:00
jwt = get_valid_jwt ( )
if not jwt : raise RuntimeError ( " Scheduled job could not authenticate with API. " )
2025-07-05 20:33:26 +02:00
stats_data = fetch_transfer_stats ( jwt )
2025-07-08 17:56:05 +02:00
if not stats_data : raise RuntimeError ( " Scheduled job could not fetch transfer stats. " )
2025-07-05 20:33:26 +02:00
entry = Stats (
2025-07-08 17:56:05 +02:00
paid_bytes = stats_data [ " paid_bytes " ] , paid_gb = stats_data [ " paid_gb " ] ,
unpaid_bytes = stats_data [ " unpaid_bytes " ] , unpaid_gb = stats_data [ " unpaid_gb " ]
2025-07-05 20:33:26 +02:00
)
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 >
2025-07-08 17:56:05 +02:00
< 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.9e m % 22 font-size= %2290% 22>📊</text></svg> " >
2025-07-05 20:33:26 +02:00
< 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 : 6 px 8 px ; font : 14 px / 16 px Arial , Helvetica , sans - serif ; background : white ; background : rgba ( 255 , 255 , 255 , 0.8 ) ; box - shadow : 0 0 15 px rgba ( 0 , 0 , 0 , 0.2 ) ; border - radius : 5 px ; }
. map - legend i { width : 18 px ; height : 18 px ; float : left ; margin - right : 8 px ; opacity : 0.7 ; }
. dark . map - legend { background : rgba ( 31 , 41 , 55 , 0.8 ) ; color : #e5e7eb; }
2025-07-08 17:56:05 +02:00
. group : hover . group - hover \\: block { display : block ; }
2025-07-05 20:33:26 +02:00
< / 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 ' ) ;
2025-07-08 17:56:05 +02:00
if ( window . matchMedia ( ' (prefers-color-scheme: dark) ' ) . matches ) { document . documentElement . classList . add ( ' dark ' ) ; }
else { document . documentElement . classList . remove ( ' dark ' ) ; }
2025-07-05 20:33:26 +02:00
} 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 " >
2025-07-08 17:56:05 +02:00
📊 UrNetwork Stats
2025-07-05 20:33:26 +02:00
< / div >
< div class = " hidden md:block " >
< div class = " ml-10 flex items-baseline space-x-4 " >
< a href = " {{ url_for( ' public_dashboard ' ) }} " class = " { % i f request.endpoint == ' public_dashboard ' % }bg-gray-200 dark:bg-gray-900 text-gray-800 dark:text-white { % e lse % }text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white { % e ndif % } rounded-md px-3 py-2 text-sm font-medium " > Public View < / a >
2025-07-08 17:56:05 +02:00
< div class = " relative group " >
< a href = " {{ url_for( ' private_dashboard ' ) }} " class = " { % i f request.endpoint.startswith( ' private_ ' ) or request.endpoint in [ ' login ' , ' settings ' ] % }bg-gray-200 dark:bg-gray-900 text-gray-800 dark:text-white { % e lse % }text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-black dark:hover:text-white { % e ndif % } 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 >
2025-07-05 20:33:26 +02:00
< / 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 % }
2025-07-08 17:56:05 +02:00
< 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 >
2025-07-05 20:33:26 +02:00
< 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 >
2025-07-08 17:56:05 +02:00
< footer class = " bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-8 " >
2025-07-05 20:33:26 +02:00
< 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 ' ) }} { % i f request.args.next % }?next= {{ request.args.next }} { % e ndif % } " 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 ( ) ;
}
2025-07-08 17:56:05 +02:00
if ( timerSpan ) {
timerSpan . textContent = ` $ { Math . floor ( seconds / 60 ) . toString ( ) . padStart ( 2 , ' 0 ' ) } : $ { ( seconds % 60 ) . toString ( ) . padStart ( 2 , ' 0 ' ) } ` ;
}
2025-07-05 20:33:26 +02:00
} , 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 ) ;
2025-07-08 17:56:05 +02:00
if ( statusEl ) statusEl . textContent = ` Stats updated at $ { new Date ( ) . toLocaleTimeString ( ) } ` ;
2025-07-05 20:33:26 +02:00
} else {
2025-07-08 17:56:05 +02:00
if ( statusEl ) statusEl . textContent = ` Stats up to date . Last check : $ { new Date ( ) . toLocaleTimeString ( ) } ` ;
2025-07-05 20:33:26 +02:00
}
} ) . 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 " >
2025-07-08 17:56:05 +02:00
< div class = " flex justify-between items-center " >
2025-07-05 20:33:26 +02:00
< 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 >
2025-07-08 17:56:05 +02:00
< form method = " post " action = " {{ url_for( ' clear_db ' ) }} " onsubmit = " return confirm( ' Are you sure? This will delete all historical stats data. ' ); " class = " inline-block " >
2025-07-05 20:33:26 +02:00
< 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 >
2025-07-08 17:56:05 +02:00
< p class = " text-sm text-gray-500 dark:text-gray-400 " >
Next automatic fetch : < span id = " job-timer " class = " font-semibold " > - - : - - < / span >
2025-07-05 20:33:26 +02:00
< / p >
< / div >
< ! - - Summary Cards - - >
2025-07-08 17:56:05 +02:00
< div class = " grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 " >
2025-07-05 20:33:26 +02:00
< 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 >
2025-07-08 17:56:05 +02:00
< 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 >
2025-07-05 20:33:26 +02:00
< / 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 >
2025-07-08 17:56:05 +02:00
< 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 >
2025-07-05 20:33:26 +02:00
< / 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 >
2025-07-08 17:56:05 +02:00
< dd class = " mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white " > $ { { " %.2f " | format ( total_earnings ) } } < / dd >
2025-07-05 20:33:26 +02:00
< / 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 >
2025-07-08 17:56:05 +02:00
< 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 >
2025-07-05 20:33:26 +02:00
< / 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 >
2025-07-08 17:56:05 +02:00
< 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 % }
2025-07-05 20:33:26 +02:00
< / 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 >
2025-07-08 17:56:05 +02:00
< 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 >
2025-07-05 20:33:26 +02:00
< / div >
< / div >
2025-07-08 17:56:05 +02:00
< ! - - History Table - - >
2025-07-05 20:33:26 +02:00
< 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 >
2025-07-08 17:56:05 +02:00
< tbody class = " divide-y divide-gray-200 dark:divide-gray-600 bg-white dark:bg-gray-800 " >
2025-07-05 20:33:26 +02:00
{ % 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 { % i f row.delta_gb is not none % } { % i f row.delta_gb >= 0 % }text-green-600 dark:text-green-400 { % e lse % }text-red-600 dark:text-red-400 { % e ndif % } { % e lse % }text-gray-500 dark:text-gray-400 { % e ndif % } " >
{ % 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 ) {
2025-07-08 17:56:05 +02:00
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 }
} ) ;
}
2025-07-05 20:33:26 +02:00
}
if ( chartData . deltas & & chartData . deltas . length ) {
2025-07-08 17:56:05 +02:00
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 }
} ) ;
}
2025-07-05 20:33:26 +02:00
}
}
2025-07-08 17:56:05 +02:00
function startJobTimer ( ) {
const timerSpan = document . getElementById ( ' job-timer ' ) ;
if ( ! timerSpan ) return ;
2025-07-05 20:33:26 +02:00
setInterval ( ( ) = > {
2025-07-08 17:56:05 +02:00
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 ) ;
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
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 ' ) } ` ;
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
} , 1000 ) ;
2025-07-05 20:33:26 +02:00
}
initPrivateCharts ( { { chart_data | tojson } } ) ;
2025-07-08 17:56:05 +02:00
startJobTimer ( ) ;
2025-07-05 20:33:26 +02:00
} ) ;
< / script >
2025-07-08 17:56:05 +02:00
"""
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
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 >
2025-07-05 20:33:26 +02:00
"""
2025-07-08 17:56:05 +02:00
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 >
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
< ! - - 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 = " { % i f earner.network_name == account_details.ranking.network_name % } bg-indigo-50 dark:bg-indigo-900/20 { % e ndif % } " >
< 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 >
"""
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
# --- Template Rendering Helper ---
2025-07-05 20:33:26 +02:00
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
2025-07-08 17:56:05 +02:00
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 ' ) ,
}
2025-07-05 20:33:26 +02:00
content = render_template_string ( template_content , * * context )
return render_template_string ( LAYOUT_TEMPLATE , content = content , * * context )
# --- Middleware and Before Request Handlers ---
@app.before_request
2025-07-08 17:56:05 +02:00
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 ( ) :
2025-07-05 20:33:26 +02:00
return redirect ( url_for ( ' install ' ) )
2025-07-08 17:56:05 +02:00
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 ( )
2025-07-05 20:33:26 +02:00
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 " )
2025-07-08 17:56:05 +02:00
config_data = {
2025-07-05 20:33:26 +02:00
" UR_USER " : user ,
" UR_PASS " : password ,
2025-07-08 17:56:05 +02:00
" SECRET_KEY " : secrets . token_hex ( 24 )
2025-07-05 20:33:26 +02:00
}
2025-07-08 17:56:05 +02:00
if save_env_file ( config_data ) :
2025-07-05 20:33:26 +02:00
with app . app_context ( ) :
db . create_all ( )
2025-07-08 17:56:05 +02:00
session [ ' logged_in ' ] = True
2025-07-05 20:33:26 +02:00
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 ( )
2025-07-08 17:56:05 +02:00
payments = fetch_payment_stats ( jwt ) if jwt else [ ]
2025-07-05 20:33:26 +02:00
_ , 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 ( ) :
2025-07-08 17:56:05 +02:00
""" API endpoint to proxy provider locations. """
2025-07-05 20:33:26 +02:00
data = fetch_provider_locations ( )
2025-07-08 17:56:05 +02:00
if data : return jsonify ( data )
2025-07-05 20:33:26 +02:00
return jsonify ( { " error " : " Failed to fetch location data " } ) , 500
@app.route ( ' /api/transfer_data ' )
def get_transfer_data ( ) :
2025-07-08 17:56:05 +02:00
""" API endpoint for public page polling. """
2025-07-05 20:33:26 +02:00
entries = Stats . query . order_by ( Stats . timestamp . asc ( ) ) . all ( )
2025-07-08 17:56:05 +02:00
if not entries : return jsonify ( { " status " : " no_data " } )
2025-07-05 20:33:26 +02:00
latest_entry = entries [ - 1 ]
jwt = get_valid_jwt ( )
2025-07-08 17:56:05 +02:00
payments = fetch_payment_stats ( jwt ) if jwt else [ ]
_ , monthly_earnings = calculate_earnings ( payments )
2025-07-05 20:33:26 +02:00
return jsonify ( {
" status " : " ok " ,
2025-07-08 17:56:05 +02:00
" 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
2025-07-05 20:33:26 +02:00
} )
2025-07-08 17:56:05 +02:00
# --- Private Dashboard Routes ---
2025-07-05 20:33:26 +02:00
@app.route ( ' /dashboard ' )
@login_required
def private_dashboard ( ) :
2025-07-08 17:56:05 +02:00
""" Display the detailed private dashboard overview. """
2025-07-05 20:33:26 +02:00
entries = Stats . query . order_by ( Stats . timestamp . desc ( ) ) . all ( )
latest = entries [ 0 ] if entries else None
jwt = get_valid_jwt ( )
2025-07-08 17:56:05 +02:00
payments = fetch_payment_stats ( jwt ) if jwt else [ ]
2025-07-05 20:33:26 +02:00
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 )
2025-07-08 17:56:05 +02:00
rows . append ( { " e " : e , " ts_str " : e . timestamp . strftime ( " % Y- % m- %d % H: % M: % S " ) , " delta_gb " : delta_gb } )
2025-07-05 20:33:26 +02:00
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 (
2025-07-08 17:56:05 +02:00
PRIVATE_DASHBOARD_TEMPLATE , title = " Owner Dashboard - Overview " ,
rows = rows , latest = latest , total_earnings = total_earnings , chart_data = chart_data
)
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
@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 ' ) )
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
jwt = get_valid_jwt ( )
account_details = fetch_account_details ( jwt ) if jwt else { }
leaderboard = fetch_leaderboard ( jwt ) if jwt else [ ]
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
return render_page (
ACCOUNT_PAGE_TEMPLATE , title = " Account & Leaderboard " ,
account_details = account_details , leaderboard = leaderboard
)
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
@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 ' )
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
return render_page ( DEVICES_PAGE_TEMPLATE , title = " Device Management " , devices = devices )
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
@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 ' ) )
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
@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 ' ) )
2025-07-05 20:33:26 +02:00
2025-07-08 17:56:05 +02:00
return render_page ( SETTINGS_TEMPLATE , title = " Settings " )
2025-07-05 20:33:26 +02:00
@app.route ( " /trigger " , methods = [ " POST " ] )
@login_required
def trigger_fetch ( ) :
""" Manually trigger a stats fetch. """
try :
jwt = get_valid_jwt ( )
2025-07-08 17:56:05 +02:00
if not jwt : raise RuntimeError ( " Failed to get a valid API token. " )
2025-07-05 20:33:26 +02:00
stats_data = fetch_transfer_stats ( jwt )
2025-07-08 17:56:05 +02:00
if not stats_data : raise RuntimeError ( " Failed to fetch stats from API. " )
2025-07-05 20:33:26 +02:00
entry = Stats (
2025-07-08 17:56:05 +02:00
paid_bytes = stats_data [ " paid_bytes " ] , paid_gb = stats_data [ " paid_gb " ] ,
unpaid_bytes = stats_data [ " unpaid_bytes " ] , unpaid_gb = stats_data [ " unpaid_gb " ]
2025-07-05 20:33:26 +02:00
)
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 " )
2025-07-08 17:56:05 +02:00
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 ( ) :
2025-07-05 20:33:26 +02:00
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 ( ) :
2025-07-08 17:56:05 +02:00
db . create_all ( )
2025-07-05 20:33:26 +02:00
scheduler . start ( )
app . run ( host = " 0.0.0.0 " , port = 90 , debug = False )