vps-dashboard/app.py
2025-08-25 14:35:55 +02:00

324 lines
16 KiB
Python

import os
import sqlite3
import threading
import time
from collections import deque
from datetime import datetime, timedelta
import dash
import pandas as pd
import plotly.graph_objs as go
import psutil
from dash import dcc, html
from dash.dependencies import Input, Output
from dotenv import load_dotenv
from flask import Flask, request, abort, render_template_string, redirect, url_for
# --- CONFIGURATIE ---
load_dotenv()
AUTH_TOKEN = os.getenv("AUTH_TOKEN")
DASHBOARD_TITLE = os.getenv("DASHBOARD_TITLE", "VPS Resource Dashboard")
PAGE_TITLE = os.getenv("PAGE_TITLE", "VPS Dashboard")
DB_FILE = 'resource_log.db'
LOG_HISTORY_DAYS = int(os.getenv("LOG_HISTORY_DAYS", 30))
LIVE_DATA_MAX_LENGTH = 60
# --- FLASK SERVER & DASH APP INITIALISATIE ---
server = Flask(__name__)
app = dash.Dash(__name__, server=server, url_base_pathname='/dashboard/', title=PAGE_TITLE)
# --- LOGIN PAGINA ---
LOGIN_HTML = """
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - {{ page_title }}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 flex items-center justify-center h-screen">
<div class="bg-gray-800 p-8 rounded-lg shadow-lg w-full max-w-sm">
<h1 class="text-2xl font-bold mb-6 text-center text-cyan-400">Dashboard Login</h1>
{% if error %}
<p class="bg-red-500 text-white text-center p-2 rounded mb-4">{{ error }}</p>
{% endif %}
<form method="post">
<div class="mb-4">
<label for="token" class="block text-gray-400 text-sm font-bold mb-2">Authenticatie Token</label>
<input type="password" name="token" id="token" class="shadow appearance-none border rounded w-full py-2 px-3 bg-gray-700 text-white leading-tight focus:outline-none focus:shadow-outline" required>
</div>
<div class="flex items-center justify-between">
<button class="bg-cyan-500 hover:bg-cyan-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline w-full" type="submit">
Inloggen
</button>
</div>
</form>
</div>
</body>
</html>
"""
@server.route('/', methods=['GET', 'POST'])
def login():
error = None
if request.method == 'POST':
provided_token = request.form.get('token')
if provided_token == AUTH_TOKEN:
return redirect(url_for('dashboard_redirect', token=provided_token))
else:
error = 'Ongeldige token. Probeer het opnieuw.'
return render_template_string(LOGIN_HTML, error=error, page_title=PAGE_TITLE)
@server.route('/dashboard-redirect')
def dashboard_redirect():
token = request.args.get('token')
return redirect(f'/dashboard/?token={token}')
# --- AUTHENTICATIE ---
@server.before_request
def before_request_func():
if request.path in ['/', '/dashboard-redirect']:
return
if request.path.startswith('/dashboard/'):
if request.path.startswith('/dashboard/_dash-'):
return
provided_token = request.args.get('token')
if not provided_token or provided_token != AUTH_TOKEN:
return redirect(url_for('login'))
# --- HELPER FUNCTIES ---
def bytes_to_gb(bytes_val):
return round(bytes_val / (1024**3), 1)
def format_speed(speed_kbps):
if speed_kbps >= 1024 * 1024:
return f"{speed_kbps / (1024 * 1024):.2f}", "GB/s"
elif speed_kbps >= 1024:
return f"{speed_kbps / 1024:.2f}", "MB/s"
else:
return f"{speed_kbps:.2f}", "KB/s"
# --- DATABASE FUNCTIES ---
def init_db():
conn = sqlite3.connect(DB_FILE, check_same_thread=False)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS stats (
timestamp TEXT PRIMARY KEY,
cpu_percent REAL,
ram_percent REAL,
disk_percent REAL
)
''')
conn.commit()
conn.close()
def get_system_stats():
virtual_mem = psutil.virtual_memory()
disk_usage = psutil.disk_usage('/')
return {
'cpu_percent': psutil.cpu_percent(interval=None),
'ram_percent': virtual_mem.percent,
'ram_used_gb': bytes_to_gb(virtual_mem.used),
'ram_total_gb': bytes_to_gb(virtual_mem.total),
'disk_percent': disk_usage.percent,
'disk_used_gb': bytes_to_gb(disk_usage.used),
'disk_total_gb': bytes_to_gb(disk_usage.total),
'net_io_per_nic': psutil.net_io_counters(pernic=True),
'timestamp': datetime.now()
}
def log_stats_to_db():
stats = get_system_stats()
conn = sqlite3.connect(DB_FILE, check_same_thread=False)
cursor = conn.cursor()
cursor.execute("INSERT OR REPLACE INTO stats VALUES (?, ?, ?, ?)",
(stats['timestamp'].isoformat(), stats['cpu_percent'], stats['ram_percent'], stats['disk_percent']))
conn.commit()
conn.close()
def prune_old_data():
cutoff_date = (datetime.now() - timedelta(days=LOG_HISTORY_DAYS)).isoformat()
conn = sqlite3.connect(DB_FILE, check_same_thread=False)
cursor = conn.cursor()
cursor.execute("DELETE FROM stats WHERE timestamp < ?", (cutoff_date,))
conn.commit()
conn.close()
print(f"Pruned data older than {cutoff_date}")
def start_logging_thread():
def logging_loop():
prune_counter = 0
while True:
log_stats_to_db()
prune_counter += 1
if prune_counter >= 60:
prune_old_data()
prune_counter = 0
time.sleep(60)
thread = threading.Thread(target=logging_loop, daemon=True)
thread.start()
# --- DASHBOARD LAYOUT ---
time_data = deque(maxlen=LIVE_DATA_MAX_LENGTH)
cpu_data = deque(maxlen=LIVE_DATA_MAX_LENGTH)
ram_data = deque(maxlen=LIVE_DATA_MAX_LENGTH)
net_rx_data = deque(maxlen=LIVE_DATA_MAX_LENGTH)
net_tx_data = deque(maxlen=LIVE_DATA_MAX_LENGTH)
last_net_io = {}
last_check_time = {}
nic_options = [{'label': nic, 'value': nic} for nic in psutil.net_io_counters(pernic=True).keys()]
def get_default_nic():
all_nics = [nic['value'] for nic in nic_options]
for nic in all_nics:
if nic.startswith('enp') or nic.startswith('eno'):
return nic
for nic in all_nics:
if 'lo' not in nic:
return nic
return all_nics[0] if all_nics else None
app.layout = html.Div(className="bg-gray-900 text-white min-h-screen p-8 font-sans", children=[
html.Div(className="container mx-auto", children=[
html.H1(DASHBOARD_TITLE, className="text-4xl font-bold mb-2 text-cyan-400"),
html.P("Live monitoring van systeembronnen.", className="text-gray-400 mb-8"),
html.Div(className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8", children=[
html.Div(id='cpu-live-card', className="bg-gray-800 p-6 rounded-lg shadow-lg"),
html.Div(id='ram-live-card', className="bg-gray-800 p-6 rounded-lg shadow-lg"),
html.Div(id='disk-live-card', className="bg-gray-800 p-6 rounded-lg shadow-lg"),
]),
html.Div(className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8", children=[
dcc.Graph(id='cpu-live-graph', className="bg-gray-800 rounded-lg shadow-lg"),
dcc.Graph(id='ram-live-graph', className="bg-gray-800 rounded-lg shadow-lg"),
]),
html.H2("Netwerkgebruik", className="text-3xl font-bold mb-4 text-cyan-400"),
dcc.Dropdown(id='nic-dropdown', options=nic_options, value=get_default_nic(), clearable=False, className="mb-6 text-black"),
html.Div(className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8", children=[
html.Div(id='net-rx-card', className="bg-gray-800 p-6 rounded-lg shadow-lg"),
html.Div(id='net-tx-card', className="bg-gray-800 p-6 rounded-lg shadow-lg"),
]),
dcc.Graph(id='network-live-graph', className="bg-gray-800 rounded-lg shadow-lg mb-8"),
html.H2("Historische Data", className="text-3xl font-bold mb-4 text-cyan-400"),
dcc.Graph(id='historical-graph', className="bg-gray-800 rounded-lg shadow-lg"),
]),
dcc.Interval(id='live-interval', interval=1 * 1000, n_intervals=0),
dcc.Interval(id='historical-interval', interval=1 * 60 * 1000, n_intervals=0),
])
# --- DASHBOARD CALLBACKS ---
@app.callback(
[Output('cpu-live-card', 'children'), Output('ram-live-card', 'children'),
Output('disk-live-card', 'children'), Output('cpu-live-graph', 'figure'),
Output('ram-live-graph', 'figure'), Output('net-rx-card', 'children'),
Output('net-tx-card', 'children'), Output('network-live-graph', 'figure')],
[Input('live-interval', 'n_intervals'), Input('nic-dropdown', 'value')]
)
def update_live_data(n, selected_nic):
stats = get_system_stats()
time_data.append(stats['timestamp'])
cpu_data.append(stats['cpu_percent'])
ram_data.append(stats['ram_percent'])
cpu_card = [html.H3("CPU Gebruik", className="text-xl font-semibold text-gray-300 mb-2"), html.P(f"{stats['cpu_percent']:.1f}%", className="text-5xl font-bold text-cyan-400")]
ram_card = [html.H3("RAM Gebruik", className="text-xl font-semibold text-gray-300 mb-2"), html.P(f"{stats['ram_percent']:.1f}%", className="text-5xl font-bold text-purple-400"), html.P(f"{stats['ram_used_gb']} GB / {stats['ram_total_gb']} GB", className="text-md text-gray-400 mt-2")]
disk_card = [html.H3("Schijfgebruik (/)", className="text-xl font-semibold text-gray-300 mb-2"), html.P(f"{stats['disk_percent']:.1f}%", className="text-5xl font-bold text-amber-400"), html.P(f"{stats['disk_used_gb']} GB / {stats['disk_total_gb']} GB", className="text-md text-gray-400 mt-2")]
def create_live_figure(x, y, title, color, y_range=[0, 100]):
return {
'data': [go.Scatter(x=list(x), y=list(y), mode='lines', line={'width': 2, 'color': color}, hovertemplate='%{y:.2f}%<extra></extra>')],
'layout': go.Layout(title={'text': title, 'font': {'color': 'white'}}, xaxis={'showgrid': False, 'color': 'white'}, yaxis={'title': '% Gebruik', 'gridcolor': '#4A5568', 'color': 'white', 'range': y_range}, plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', height=400, margin={'l': 60, 'r': 20, 't': 40, 'b': 40}, hovermode='x unified', hoverlabel={'bgcolor': '#1f2937', 'font': {'color': 'white'}})
}
cpu_fig = create_live_figure(time_data, cpu_data, 'CPU (Laatste minuut)', '#2dd4bf')
ram_fig = create_live_figure(time_data, ram_data, 'RAM (Laatste minuut)', '#a78bfa')
rx_speed_kbps, tx_speed_kbps = 0, 0
current_time = time.time()
if selected_nic and selected_nic in stats['net_io_per_nic']:
current_io = stats['net_io_per_nic'][selected_nic]
if selected_nic in last_net_io:
time_diff = current_time - last_check_time.get(selected_nic, current_time -1)
if time_diff > 0:
rx_speed_kbps = ((current_io.bytes_recv - last_net_io[selected_nic].bytes_recv) / time_diff) / 1024
tx_speed_kbps = ((current_io.bytes_sent - last_net_io[selected_nic].bytes_sent) / time_diff) / 1024
last_net_io[selected_nic] = current_io
last_check_time[selected_nic] = current_time
net_rx_data.append(rx_speed_kbps)
net_tx_data.append(tx_speed_kbps)
rx_val_str, rx_unit = format_speed(rx_speed_kbps)
tx_val_str, tx_unit = format_speed(tx_speed_kbps)
net_rx_card = [html.H3("Ontvangen (RX)", className="text-xl font-semibold text-gray-300 mb-2"), html.P(f"{rx_val_str} {rx_unit}", className="text-5xl font-bold text-green-400")]
net_tx_card = [html.H3("Verzonden (TX)", className="text-xl font-semibold text-gray-300 mb-2"), html.P(f"{tx_val_str} {tx_unit}", className="text-5xl font-bold text-blue-400")]
max_speed = max(list(net_rx_data) + list(net_tx_data) + [1])
y_axis_title, scale_factor = ("MB/s", 1/1024) if max_speed >= 1024 else ("KB/s", 1)
scaled_rx = [s * scale_factor for s in net_rx_data]
scaled_tx = [s * scale_factor for s in net_tx_data]
net_fig = go.Figure()
net_fig.add_trace(go.Scatter(x=list(time_data), y=scaled_rx, mode='lines', name='RX', line={'color': '#4ade80'}, hovertemplate=f'%{{y:.2f}} {y_axis_title}<extra></extra>'))
net_fig.add_trace(go.Scatter(x=list(time_data), y=scaled_tx, mode='lines', name='TX', line={'color': '#60a5fa'}, hovertemplate=f'%{{y:.2f}} {y_axis_title}<extra></extra>'))
net_fig.update_layout(title={'text': f'Netwerksnelheid - {selected_nic}', 'font': {'color': 'white'}}, xaxis={'showgrid': False, 'color': 'white'}, yaxis={'title': y_axis_title, 'gridcolor': '#4A5568', 'color': 'white'}, plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', height=400, margin={'l': 60, 'r': 20, 't': 40, 'b': 40}, legend={'font': {'color': 'white'}}, hovermode='x unified', hoverlabel={'bgcolor': '#1f2937', 'font': {'color': 'white'}})
return cpu_card, ram_card, disk_card, cpu_fig, ram_fig, net_rx_card, net_tx_card, net_fig
@app.callback(Output('historical-graph', 'figure'), [Input('historical-interval', 'n_intervals')])
def update_historical_data(n):
try:
conn = sqlite3.connect(DB_FILE, check_same_thread=False)
df = pd.read_sql_query("SELECT * FROM stats ORDER BY timestamp", conn)
conn.close()
df['timestamp'] = pd.to_datetime(df['timestamp'])
except Exception as e:
print(f"Error reading from database: {e}")
return go.Figure(layout={'title': 'Geen historische data gevonden', 'plot_bgcolor': 'rgba(0,0,0,0)', 'paper_bgcolor': 'rgba(0,0,0,0)', 'font': {'color': 'white'}})
fig = go.Figure()
hovertemplate = '<b>%{data.name}</b><br>%{x|%Y-%m-%d %H:%M:%S}<br>%{y:.2f}%<extra></extra>'
fig.add_trace(go.Scatter(x=df['timestamp'], y=df['cpu_percent'], mode='lines', name='CPU', line={'color': '#2dd4bf'}, hovertemplate=hovertemplate))
fig.add_trace(go.Scatter(x=df['timestamp'], y=df['ram_percent'], mode='lines', name='RAM', line={'color': '#a78bfa'}, hovertemplate=hovertemplate))
fig.add_trace(go.Scatter(x=df['timestamp'], y=df['disk_percent'], mode='lines', name='Schijf', line={'color': '#facc15'}, hovertemplate=hovertemplate))
fig.update_layout(
title={'text': 'Historisch Resourcegebruik', 'font': {'color': 'white'}},
xaxis={'title': 'Tijd', 'color': 'white', 'gridcolor': '#4A5568', 'rangeselector': {'buttons': list([dict(count=1, label="1u", step="hour", stepmode="backward"), dict(count=6, label="6u", step="hour", stepmode="backward"), dict(count=1, label="1d", step="day", stepmode="backward"), dict(count=7, label="7d", step="day", stepmode="backward"), dict(step="all")]), 'bgcolor': '#374151', 'font': {'color': 'white'}}, 'rangeslider': {'visible': True, 'bgcolor': '#374151'}, 'type': 'date'},
yaxis={'title': '% Gebruik', 'color': 'white', 'gridcolor': '#4A5568'},
plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)',
legend={'font': {'color': 'white'}}, height=600,
hovermode='x unified',
hoverlabel={'bgcolor': '#1f2937', 'font': {'color': 'white'}}
)
return fig
# --- APPLICATIE STARTEN ---
if __name__ == '__main__':
app.index_string = '''
<!DOCTYPE html>
<html>
<head>
{%metas%}
<title>{%title%}</title>
{%favicon%}
{%css%}
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900">
{%app_entry%}
<footer>
{%config%}
{%scripts%}
{%renderer%}
</footer>
</body>
</html>
'''
init_db()
print("Starting logging thread...")
start_logging_thread()
print(f"Dashboard is running. Data retention: {LOG_HISTORY_DAYS} days. Ga naar: http://127.0.0.1:8050/")
server.run(debug=True, host='0.0.0.0', port=8050)