diff --git a/main.py b/main.py index 7e86eef..1c905ed 100644 --- a/main.py +++ b/main.py @@ -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/') +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): - upgrade() - ran_migrations = True - app.logger.info("Migrationen abgeschlossen.") - else: - app.logger.warning("Kein migrations/-Ordner gefunden – überspringe Alembic upgrade.") + # Wendet ausstehende Datenbank-Migrationen an. + # Dies aktualisiert das Schema auf den neuesten Stand. + 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) diff --git a/migrations/versions/fe9c4d25c5da_auto_detect_model_changes.py b/migrations/versions/fe9c4d25c5da_auto_detect_model_changes.py new file mode 100644 index 0000000..da9d6b6 --- /dev/null +++ b/migrations/versions/fe9c4d25c5da_auto_detect_model_changes.py @@ -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 ### diff --git a/templates/_footer.html b/templates/_footer.html new file mode 100644 index 0000000..57769e6 --- /dev/null +++ b/templates/_footer.html @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/templates/_status_badge.html b/templates/_status_badge.html new file mode 100644 index 0000000..c6fb254 --- /dev/null +++ b/templates/_status_badge.html @@ -0,0 +1,13 @@ + + {% if monitor.status_override == 'MAINTENANCE' %} + Wartung + {% elif monitor.status_override == 'DEGRADED' %} + Probleme + {% elif last_log and last_log.is_up %} + Up + {% elif last_log and not last_log.is_up %} + Down + {% else %} + Unbekannt + {% endif %} + \ No newline at end of file diff --git a/templates/admin/embed_info.html b/templates/admin/embed_info.html new file mode 100644 index 0000000..8b5b8b0 --- /dev/null +++ b/templates/admin/embed_info.html @@ -0,0 +1,16 @@ +{% extends 'admin/master.html' %} + +{% block body %} +

iFrame-Einbettungscode

+

Kopieren Sie den folgenden HTML-Code, um die Status-Tabelle auf einer anderen Seite oder in Ihrem Intranet einzubetten:

+ +
+
+
<iframe src="{{ url_for('status', _external=True) }}" style="width: 100%; height: 400px; border: none;"></iframe>
+
+
+ +

+ Zurück zum Dashboard +

+{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index bba47e5..3a99c61 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,78 +1,86 @@ - -{% extends 'layout.html' %} - -{% block title %}Statusübersicht - Uptime Stats{% endblock %} - -{% block content %} -
-

Statusübersicht

- - {% if not monitors %} -
-

Noch keine Monitore konfiguriert.

-

Bitte fügen Sie im Admin-Bereich einen Monitor hinzu, um mit der Überwachung zu beginnen.

-
- {% else %} -
- {% for monitor in monitors %} -
-
-
-
- {{ monitor.name }} - - {# Manuelle Statusanzeige priorisieren #} - {% if monitor.status_override == 'MAINTENANCE' %} - Wartung - {% elif monitor.status_override == 'DEGRADED' %} - Probleme - {% elif monitor.status_override == 'OPERATIONAL' %} - Funktionsfähig - - {# Automatische Statusanzeige, wenn kein manueller Status gesetzt ist #} - {% elif monitor.is_up == True %} - Up - {% elif monitor.is_up == False %} - Down - {% else %} - Unbekannt - {% endif %} -
- - {# Status-Nachricht anzeigen, wenn vorhanden #} - {% if monitor.status_override_message %} -
- {{ monitor.status_override_message }} -
- {% endif %} - -

- {% if monitor.monitor_type == 'TCP' %} - {{ monitor.url }}:{{ monitor.port }} - {% else %} - {{ monitor.url }} - {% endif %} -

-
- {{ monitor.monitor_type }} - {% if monitor.monitor_type == 'KEYWORD' and monitor.keyword %} - Keyword: {{ monitor.keyword }} - {% endif %} -
-
- -
+ + + + + + Uptime Status + + + + + +
-{% endblock %} + + +
+

Statusübersicht

+ + {% if not monitors_with_status %} +
+

Keine Monitore konfiguriert.

+

Bitte loggen Sie sich in den Admin-Bereich ein, um neue Monitore hinzuzufügen.

+
+ {% else %} +
+ + + + + + + + + + {% for monitor, last_log in monitors_with_status %} + + + {% include '_status_badge.html' %} + + + {% endfor %} + +
DienstStatusLetzte Prüfung
+ {{ monitor.name }} + {% if monitor.status_override_message %} +
+ {{ monitor.status_override_message }} +
+ {% endif %} +
+ {% if last_log %} + {{ last_log.timestamp.strftime('%d.%m.%Y %H:%M:%S UTC') }} + {% else %} + Nie + {% endif %} +
+
+ {% endif %} +
+ + {% include '_footer.html' %} + + + + diff --git a/update.py b/update.py new file mode 100644 index 0000000..0899a1e --- /dev/null +++ b/update.py @@ -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() \ No newline at end of file