+ update emblemed support in iframe and list view

This commit is contained in:
Robin
2025-10-06 12:54:41 +02:00
parent 988ab003ad
commit ca02212004
8 changed files with 416 additions and 98 deletions

117
main.py
View File

@@ -3,16 +3,17 @@ import requests
import socket
import threading
import logging
import time
import time, math
import warnings
from datetime import datetime
from datetime import datetime, timezone, timedelta
from typing import Optional, Tuple
from urllib.parse import urlparse
from flask import Flask, render_template, flash, redirect, url_for, request
from flask_admin import Admin, AdminIndexView
from flask import Flask, render_template, flash, redirect, url_for, request, make_response
from flask_admin import Admin, AdminIndexView, BaseView, expose
from flask_admin.contrib.sqla import ModelView
from flask_login import LoginManager, UserMixin, login_user, logout_user, current_user, login_required
from flask_admin.menu import MenuLink
from flask_migrate import Migrate, upgrade
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import FlaskForm
@@ -91,6 +92,7 @@ class Monitor(db.Model):
url = db.Column(db.String(200), nullable=False)
keyword = db.Column(db.String(100), nullable=True)
port = db.Column(db.Integer, nullable=True)
position = db.Column(db.Integer, nullable=False, default=0, server_default='0')
status_override = db.Column(db.String(50), nullable=True)
status_override_message = db.Column(db.Text, nullable=True)
@@ -104,7 +106,7 @@ class UptimeLog(db.Model):
"""Modell zum Protokollieren des Uptime-Status."""
id = db.Column(db.Integer, primary_key=True)
monitor_id = db.Column(db.Integer, db.ForeignKey('monitor.id'), nullable=False)
timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True)
timestamp = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True)
status_code = db.Column(db.Integer, nullable=True)
is_up = db.Column(db.Boolean, nullable=False)
@@ -156,6 +158,14 @@ class SecureAdminIndexView(SecureView, AdminIndexView):
pass
class EmbedInfoView(SecureView, BaseView):
"""Eine gesicherte Admin-Ansicht, die den iFrame-Einbettungscode anzeigt."""
@expose('/')
def index(self):
# 'render' wird von BaseView bereitgestellt und injiziert den Admin-Kontext
return self.render('admin/embed_info.html')
class MonitorModelView(SecureModelView):
"""Angepasste Admin-Ansicht für Monitore."""
form_overrides = {
@@ -188,11 +198,11 @@ class MonitorModelView(SecureModelView):
'validators': [VOptional()],
},
}
column_list = ('name', 'monitor_type', 'status_override', 'url', 'port')
form_columns = ('name', 'monitor_type', 'url', 'port', 'keyword', 'status_override', 'status_override_message')
column_list = ('name', 'monitor_type', 'position', 'status_override', 'url', 'port')
form_columns = ('name', 'monitor_type', 'position', 'url', 'port', 'keyword', 'status_override', 'status_override_message')
column_labels = dict(
name='Name', monitor_type='Typ', url='URL/Host', port='Port', keyword='Keyword',
status_override='Manueller Status', status_override_message='Status-Nachricht'
position='Position', status_override='Manueller Status', status_override_message='Status-Nachricht'
)
def on_model_change(self, form, model, is_created):
@@ -216,10 +226,17 @@ class MonitorModelView(SecureModelView):
admin = Admin(app, name='Uptime Stats Admin', template_mode='bootstrap4', index_view=SecureAdminIndexView())
admin.add_view(MonitorModelView(Monitor, db.session, name="Monitore"))
admin.add_view(SecureModelView(User, db.session, name="Administratoren", endpoint='user_admin'))
admin.add_view(EmbedInfoView(name='Einbettungs-Info', endpoint='embed-info'))
# --- Web-Routen ---
@app.context_processor
def inject_now():
"""Stellt das aktuelle UTC-Datum für alle Templates zur Verfügung."""
return {'now': datetime.now(timezone.utc)}
@app.route('/')
def index():
"""Öffentliche Statusseite."""
@@ -237,11 +254,72 @@ def index():
UptimeLog,
(UptimeLog.monitor_id == latest_log_subquery.c.monitor_id) &
(UptimeLog.timestamp == latest_log_subquery.c.max_timestamp)
).order_by(Monitor.name).all()
).order_by(Monitor.position, Monitor.name).all()
return render_template('index.html', monitors_with_status=monitors_with_status)
@app.route('/status')
def status():
"""Erstellt eine für iFrames optimierte Status-Tabellenseite."""
latest_log_subquery = db.session.query(
UptimeLog.monitor_id,
func.max(UptimeLog.timestamp).label('max_timestamp')
).group_by(UptimeLog.monitor_id).subquery()
monitors_with_status = db.session.query(
Monitor,
UptimeLog
).outerjoin(
latest_log_subquery, Monitor.id == latest_log_subquery.c.monitor_id
).outerjoin(
UptimeLog,
(UptimeLog.monitor_id == latest_log_subquery.c.monitor_id) &
(UptimeLog.timestamp == latest_log_subquery.c.max_timestamp)
).order_by(Monitor.position, Monitor.name).all()
response = make_response(render_template('status.html', monitors_with_status=monitors_with_status))
response.headers.pop('X-Frame-Options', None)
return response
@app.route('/monitor/<int:monitor_id>')
def monitor_details(monitor_id: int):
"""Zeigt Detailinformationen und Statistiken für einen einzelnen Monitor."""
monitor = db.session.get(Monitor, monitor_id)
if not monitor:
return "Monitor nicht gefunden", 404
now = datetime.now(timezone.utc)
# Statistik für 24 Stunden
start_24h = now - timedelta(hours=24)
logs_24h = db.session.scalars(
db.select(UptimeLog).where(
UptimeLog.monitor_id == monitor_id,
UptimeLog.timestamp >= start_24h
)
).all()
total_checks_24h = len(logs_24h)
up_checks_24h = sum(1 for log in logs_24h if log.is_up)
uptime_24h = (up_checks_24h / total_checks_24h * 100) if total_checks_24h > 0 else 100
# Statistik für 30 Tage
start_30d = now - timedelta(days=30)
logs_30d = db.session.scalars(
db.select(UptimeLog).where(
UptimeLog.monitor_id == monitor_id,
UptimeLog.timestamp >= start_30d
)
).all()
total_checks_30d = len(logs_30d)
up_checks_30d = sum(1 for log in logs_30d if log.is_up)
uptime_30d = (up_checks_30d / total_checks_30d * 100) if total_checks_30d > 0 else 100
return render_template('monitor_details.html', monitor=monitor,
uptime_24h=uptime_24h, uptime_30d=uptime_30d,
total_checks_24h=total_checks_24h, total_checks_30d=total_checks_30d)
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
@@ -328,14 +406,14 @@ def check_monitors(monitor_id: Optional[int] = None):
query = db.select(Monitor)
if monitor_id:
query = query.where(Monitor.id == monitor_id)
app.logger.info(f"[{datetime.now()}] Starte Prüfung für Monitor ID {monitor_id}...")
app.logger.info(f"[{datetime.now(timezone.utc)}] Starte Prüfung für Monitor ID {monitor_id}...")
else:
app.logger.info(f"[{datetime.now()}] Starte periodische Überprüfung...")
app.logger.info(f"[{datetime.now(timezone.utc)}] Starte periodische Überprüfung...")
monitors = db.session.scalars(query).all()
if not monitors:
if not monitor_id:
app.logger.info(f"[{datetime.now()}] Keine Monitore zur Überprüfung gefunden.")
app.logger.info(f"[{datetime.now(timezone.utc)}] Keine Monitore zur Überprüfung gefunden.")
return
for monitor in monitors:
@@ -348,7 +426,7 @@ def check_monitors(monitor_id: Optional[int] = None):
db.session.add(log_entry)
db.session.commit()
app.logger.info(f"[{datetime.now()}] Überprüfung abgeschlossen.")
app.logger.info(f"[{datetime.now(timezone.utc)}] Überprüfung abgeschlossen.")
def run_checks_periodically(interval: int = None):
@@ -374,17 +452,14 @@ def init_app():
with app.app_context():
app.logger.info("Führe Datenbank-Migrationen aus...")
migrations_dir = os.path.join(basedir, 'migrations')
ran_migrations = False
try:
if os.path.isdir(migrations_dir):
# Wendet ausstehende Datenbank-Migrationen an.
# Dies aktualisiert das Schema auf den neuesten Stand.
upgrade()
ran_migrations = True
app.logger.info("Migrationen abgeschlossen.")
else:
app.logger.warning("Kein migrations/-Ordner gefunden überspringe Alembic upgrade.")
app.logger.info("Datenbank-Migrationen erfolgreich angewendet.")
except Exception as e:
app.logger.warning(f"Alembic upgrade fehlgeschlagen: {e}")
app.logger.error(f"Anwenden der Datenbank-Migrationen fehlgeschlagen: {e}")
app.logger.info("Versuche, Tabellen mit db.create_all() zu erstellen, falls sie nicht existieren.")
# Sicherstellen, dass alle Tabellen existieren Fallback für frische Setups ohne Migrations
insp = inspect(db.engine)

View File

@@ -0,0 +1,32 @@
"""Auto-detect model changes
Revision ID: fe9c4d25c5da
Revises:
Create Date: 2025-10-06 12:00:18.340503
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fe9c4d25c5da'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('monitor', schema=None) as batch_op:
batch_op.add_column(sa.Column('position', sa.Integer(), server_default='0', nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('monitor', schema=None) as batch_op:
batch_op.drop_column('position')
# ### end Alembic commands ###

11
templates/_footer.html Normal file
View File

@@ -0,0 +1,11 @@
<footer class="footer mt-auto py-3 bg-light border-top">
<div class="container text-center">
<span class="text-muted">
&copy; {{ now.year }} <a href="https://rl-dev.de" target="_blank" class="text-decoration-none">rl-dev.de</a>
</span>
<span class="mx-2 text-muted">|</span>
<a href="https://git.rl-dev.de/Robin/Uptime-Stats" target="_blank" class="text-decoration-none text-muted">
<i class="bi bi-git"></i> Source Code
</a>
</div>
</footer>

View File

@@ -0,0 +1,13 @@
<td>
{% if monitor.status_override == 'MAINTENANCE' %}
<span class="badge bg-primary">Wartung <i class="bi bi-tools"></i></span>
{% elif monitor.status_override == 'DEGRADED' %}
<span class="badge bg-warning text-dark">Probleme <i class="bi bi-exclamation-triangle"></i></span>
{% elif last_log and last_log.is_up %}
<span class="badge bg-success status-badge">Up <i class="bi bi-check-circle"></i></span>
{% elif last_log and not last_log.is_up %}
<span class="badge bg-danger status-badge">Down <i class="bi bi-x-circle"></i></span>
{% else %}
<span class="badge bg-secondary">Unbekannt <i class="bi bi-question-circle"></i></span>
{% endif %}
</td>

View File

@@ -0,0 +1,16 @@
{% extends 'admin/master.html' %}
{% block body %}
<h1>iFrame-Einbettungscode</h1>
<p>Kopieren Sie den folgenden HTML-Code, um die Status-Tabelle auf einer anderen Seite oder in Ihrem Intranet einzubetten:</p>
<div class="card">
<div class="card-body bg-light">
<pre><code>&lt;iframe src="{{ url_for('status', _external=True) }}" style="width: 100%; height: 400px; border: none;"&gt;&lt;/iframe&gt;</code></pre>
</div>
</div>
<p class="mt-4">
<a href="{{ url_for('admin.index') }}" class="btn btn-primary"><i class="bi bi-arrow-left"></i> Zurück zum Dashboard</a>
</p>
{% endblock %}

View File

@@ -1,78 +1,86 @@
{% extends 'layout.html' %}
{% block title %}Statusübersicht - Uptime Stats{% endblock %}
{% block content %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Uptime Status</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
.status-badge.bg-danger {
animation: pulse-red 1.5s infinite;
}
@keyframes pulse-green {
0% { box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(25, 135, 84, 0); }
100% { box-shadow: 0 0 0 0 rgba(25, 135, 84, 0); }
}
@keyframes pulse-red {
0% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(220, 53, 69, 0); }
100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); }
}
</style>
</head>
<body class="d-flex flex-column vh-100">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<i class="bi bi-bar-chart-line-fill"></i> Uptime Status
</a>
<div class="ms-auto">
<a href="{{ url_for('admin.index') }}" class="btn btn-outline-light">
<i class="bi bi-person-circle"></i> Admin-Bereich
</a>
</div>
</div>
</nav>
<main class="container mt-4 flex-grow-1">
<h1 class="mb-4">Statusübersicht</h1>
{% if not monitors %}
{% if not monitors_with_status %}
<div class="alert alert-info text-center">
<h4>Noch keine Monitore konfiguriert.</h4>
<p>Bitte gen Sie im <a href="{{ url_for('admin.index') }}" class="alert-link">Admin-Bereich</a> einen Monitor hinzu, um mit der Überwachung zu beginnen.</p>
<h4>Keine Monitore konfiguriert.</h4>
<p>Bitte loggen Sie sich in den <a href="{{ url_for('admin.index') }}" class="alert-link">Admin-Bereich</a> ein, um neue Monitore hinzuzufügen.</p>
</div>
{% else %}
<div class="row">
{% for monitor in monitors %}
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100 shadow-sm">
<div class="card-body">
<h5 class="card-title d-flex justify-content-between">
{{ monitor.name }}
{# Manuelle Statusanzeige priorisieren #}
{% if monitor.status_override == 'MAINTENANCE' %}
<span class="badge bg-primary status-badge">Wartung <i class="bi bi-tools"></i></span>
{% elif monitor.status_override == 'DEGRADED' %}
<span class="badge bg-warning text-dark status-badge">Probleme <i class="bi bi-exclamation-triangle"></i></span>
{% elif monitor.status_override == 'OPERATIONAL' %}
<span class="badge bg-success status-badge">Funktionsfähig <i class="bi bi-check-circle"></i></span>
{# Automatische Statusanzeige, wenn kein manueller Status gesetzt ist #}
{% elif monitor.is_up == True %}
<span class="badge bg-success status-badge">Up <i class="bi bi-check-circle"></i></span>
{% elif monitor.is_up == False %}
<span class="badge bg-danger status-badge">Down <i class="bi bi-x-circle"></i></span>
{% else %}
<span class="badge bg-secondary status-badge">Unbekannt <i class="bi bi-question-circle"></i></span>
{% endif %}
</h5>
{# Status-Nachricht anzeigen, wenn vorhanden #}
<div class="table-responsive">
<table class="table table-bordered table-hover">
<thead class="table-light">
<tr>
<th>Dienst</th>
<th>Status</th>
<th>Letzte Prüfung</th>
</tr>
</thead>
<tbody>
{% for monitor, last_log in monitors_with_status %}
<tr>
<td>
<a href="{{ url_for('monitor_details', monitor_id=monitor.id) }}" class="text-decoration-none fw-bold">{{ monitor.name }}</a>
{% if monitor.status_override_message %}
<div class="alert alert-info mt-2 p-2">
<small>{{ monitor.status_override_message }}</small>
<div class="text-muted fst-italic small">
<i class="bi bi-info-circle"></i> {{ monitor.status_override_message }}
</div>
{% endif %}
<p class="card-text">
{% if monitor.monitor_type == 'TCP' %}
<span class="text-muted text-break">{{ monitor.url }}:{{ monitor.port }}</span>
</td>
{% include '_status_badge.html' %}
<td>
{% if last_log %}
{{ last_log.timestamp.strftime('%d.%m.%Y %H:%M:%S UTC') }}
{% else %}
<a href="{{ monitor.url }}" target="_blank" class="text-muted text-break">{{ monitor.url }}</a>
<span class="text-muted">Nie</span>
{% endif %}
</p>
<div>
<span class="badge rounded-pill bg-primary">{{ monitor.monitor_type }}</span>
{% if monitor.monitor_type == 'KEYWORD' and monitor.keyword %}
<span class="badge rounded-pill bg-light text-dark">Keyword: {{ monitor.keyword }}</span>
{% endif %}
</div>
</div>
<div class="card-footer text-muted">
<small>Zuletzt geprüft:
{% if monitor.last_checked != 'Nie' %}
{{ monitor.last_checked.strftime('%d.%m.%Y %H:%M:%S UTC') }}
{% else %}
Nie
{% endif %}
</small>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endblock %}
</main>
{% include '_footer.html' %}
<script src="ht
</html>

78
templates/status.html Normal file
View File

@@ -0,0 +1,78 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Statusübersicht</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body {
background-color: transparent;
}
.status-badge.bg-success {
animation: pulse-green 2s infinite;
}
.status-badge.bg-danger {
animation: pulse-red 1.5s infinite;
}
@keyframes pulse-green {
0% { box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(25, 135, 84, 0); }
100% { box-shadow: 0 0 0 0 rgba(25, 135, 84, 0); }
}
@keyframes pulse-red {
0% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(220, 53, 69, 0); }
100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); }
}
</style>
</head>
<body>
<div class="container-fluid">
{% if not monitors_with_status %}
<div class="alert alert-info text-center">
<h4>Keine Monitore konfiguriert.</h4>
</div>
{% else %}
<table class="table table-striped table-hover">
<thead class="thead-dark">
<tr>
<th>Dienst</th>
<th class="text-center">Status</th>
<th>Letzte Prüfung</th>
</tr>
</thead>
<tbody>
{% for monitor, last_log in monitors_with_status %}
<tr>
<td>
<a href="{{ url_for('monitor_details', monitor_id=monitor.id, _external=True) }}" target="_blank" class="text-decoration-none fw-bold">{{ monitor.name }}</a>
{% if monitor.status_override_message %}
<div class="text-muted fst-italic small">
<i class="bi bi-info-circle"></i> {{ monitor.status_override_message }}
</div>
{% endif %}
</td>
{% with %}
{% set monitor=monitor %}
{% set last_log=last_log %}
{% include '_status_badge.html' %}
{% endwith %}
<td>
{% if last_log %}
{{ last_log.timestamp.strftime('%d.%m.%Y %H:%M:%S UTC') }}
{% else %}
Nie
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% include '_footer.html' %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

85
update.py Normal file
View File

@@ -0,0 +1,85 @@
import subprocess
import sys
import os
import platform
def run_command(command, cwd=None):
"""
Führt einen Befehl aus, gibt seine Ausgabe in Echtzeit aus und prüft auf Fehler.
"""
print(f"\n>>> Führe aus: {' '.join(command)}")
print("-" * 40)
try:
# Stelle sicher, dass der Python-Interpreter des venv für Module verwendet wird
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8',
errors='replace', # Verhindert Fehler bei unerwarteten Zeichen
cwd=cwd,
bufsize=1 # Zeilen-gepuffert
)
# Lese und drucke die Ausgabe Zeile für Zeile
for line in iter(process.stdout.readline, ''):
print(line, end='')
process.wait() # Warte auf das Ende des Prozesses
if process.returncode != 0:
print("-" * 40)
print(f"FEHLER: Befehl schlug mit Exit-Code {process.returncode} fehl.")
print("-" * 40)
return False
print("-" * 40)
print(">>> Befehl erfolgreich abgeschlossen.")
return True
except FileNotFoundError:
print(f"FEHLER: Befehl '{command[0]}' nicht gefunden. Ist Git/Python korrekt installiert und im PATH?")
return False
except Exception as e:
print(f"Ein unerwarteter Fehler ist aufgetreten: {e}")
return False
def main():
"""Haupt-Update-Prozess."""
print("Starte den Update-Prozess der Anwendung...")
project_root = os.path.dirname(os.path.abspath(__file__))
# Finde den Python-Interpreter des aktuellen Virtual Environments
python_executable = sys.executable
# Schritt 1: Neueste Änderungen von Git holen
if not run_command(["git", "pull"], cwd=project_root):
print("\nUpdate fehlgeschlagen: Konnte die neuesten Änderungen nicht von Git holen.")
sys.exit(1)
# Schritt 2: Python-Abhängigkeiten installieren/aktualisieren
if not run_command([python_executable, "-m", "pip", "install", "-r", "requirements.txt"], cwd=project_root):
print("\nUpdate fehlgeschlagen: Konnte die Python-Abhängigkeiten nicht installieren.")
sys.exit(1)
# Schritt 3: Datenbank-Migrationen erstellen (falls nötig)
# Dieser Befehl erkennt Modelländerungen und erstellt ein Migrationsskript.
# Er kann fehlschlagen, wenn es keine Änderungen gibt, das ist in Ordnung.
print("\nVersuche, Datenbank-Änderungen zu erkennen...")
run_command([python_executable, "-m", "flask", "--app", "main", "db", "migrate", "-m", "Auto-detect model changes"], cwd=project_root)
# Schritt 4: Datenbank-Migrationen anwenden
print("\nWende Datenbank-Migrationen an...")
if not run_command([python_executable, "-m", "flask", "--app", "main", "db", "upgrade"], cwd=project_root):
print("\nUpdate fehlgeschlagen: Konnte die Datenbank-Migrationen nicht anwenden.")
sys.exit(1)
print("\n\n===================================================")
print("✅ Update erfolgreich abgeschlossen!")
print("Bitte starten Sie die Anwendung (main.py) neu, um die Änderungen zu übernehmen.")
print("===================================================")
if __name__ == "__main__":
main()