From 52d34007284abab12c6367c38a383055faa1eae7 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Fri, 4 Jul 2025 08:46:06 +0200 Subject: [PATCH] Add system health and system event logging. --- app.py | 129 +++++- migrate_db.py | 56 ++- models.py | 111 ++++- templates/system_admin_dashboard.html | 3 + templates/system_admin_health.html | 579 ++++++++++++++++++++++++++ templates/system_admin_settings.html | 6 +- 6 files changed, 878 insertions(+), 6 deletions(-) create mode 100644 templates/system_admin_health.html diff --git a/app.py b/app.py index 014d7c6..3cb0936 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file -from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement +from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent from data_formatting import ( format_duration, prepare_export_data, prepare_team_hours_export_data, format_table_data, format_graph_data, format_team_data @@ -481,15 +481,52 @@ def login(): session['username'] = user.username session['role'] = user.role.value + # Log successful login + SystemEvent.log_event( + 'user_login', + f'User {user.username} logged in successfully', + 'auth', + 'info', + user_id=user.id, + company_id=user.company_id, + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + flash('Login successful!', 'success') return redirect(url_for('home')) + # Log failed login attempt + SystemEvent.log_event( + 'login_failed', + f'Failed login attempt for username: {username}', + 'auth', + 'warning', + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + flash('Invalid username or password', 'error') return render_template('login.html', title='Login') @app.route('/logout') def logout(): + # Log logout event before clearing session + if 'user_id' in session: + user = User.query.get(session['user_id']) + if user: + SystemEvent.log_event( + 'user_logout', + f'User {user.username} logged out', + 'auth', + 'info', + user_id=user.id, + company_id=user.company_id, + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + session.clear() flash('You have been logged out.', 'info') return redirect(url_for('login')) @@ -1195,9 +1232,33 @@ def verify_2fa(): session['username'] = user.username session['role'] = user.role.value + # Log successful 2FA login + SystemEvent.log_event( + 'user_login_2fa', + f'User {user.username} logged in successfully with 2FA', + 'auth', + 'info', + user_id=user.id, + company_id=user.company_id, + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + flash('Login successful!', 'success') return redirect(url_for('home')) else: + # Log failed 2FA attempt + SystemEvent.log_event( + '2fa_failed', + f'Failed 2FA verification for user {user.username}', + 'auth', + 'warning', + user_id=user.id, + company_id=user.company_id, + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + flash('Invalid verification code. Please try again.', 'error') return render_template('verify_2fa.html', title='Two-Factor Authentication') @@ -2047,6 +2108,72 @@ def system_admin_settings(): total_users=total_users, total_system_admins=total_system_admins) +@app.route('/system-admin/health') +@system_admin_required +def system_admin_health(): + """System Admin: System health check and event log""" + # Get system health summary + health_summary = SystemEvent.get_system_health_summary() + + # Get recent events (last 7 days) + recent_events = SystemEvent.get_recent_events(days=7, limit=100) + + # Get events by severity for quick stats + errors = SystemEvent.get_events_by_severity('error', days=7, limit=20) + warnings = SystemEvent.get_events_by_severity('warning', days=7, limit=20) + + # System metrics + from datetime import datetime, timedelta + now = datetime.now() + + # Database connection test + db_healthy = True + db_error = None + try: + db.session.execute('SELECT 1') + except Exception as e: + db_healthy = False + db_error = str(e) + SystemEvent.log_event( + 'database_check_failed', + f'Database health check failed: {str(e)}', + 'system', + 'error' + ) + + # Application uptime (approximate based on first event) + first_event = SystemEvent.query.order_by(SystemEvent.timestamp.asc()).first() + uptime_start = first_event.timestamp if first_event else now + uptime_duration = now - uptime_start + + # Recent activity stats + today = now.date() + today_events = SystemEvent.query.filter( + func.date(SystemEvent.timestamp) == today + ).count() + + # Log the health check + SystemEvent.log_event( + 'system_health_check', + f'System health check performed by {session.get("username", "unknown")}', + 'system', + 'info', + user_id=session.get('user_id'), + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + + return render_template('system_admin_health.html', + title='System Health Check', + health_summary=health_summary, + recent_events=recent_events, + errors=errors, + warnings=warnings, + db_healthy=db_healthy, + db_error=db_error, + uptime_duration=uptime_duration, + today_events=today_events) + @app.route('/system-admin/announcements') @system_admin_required def system_admin_announcements(): diff --git a/migrate_db.py b/migrate_db.py index da45a66..4c83313 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -15,7 +15,7 @@ try: from app import app, db from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, - ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement) + ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent) from werkzeug.security import generate_password_hash FLASK_AVAILABLE = True except ImportError: @@ -71,6 +71,7 @@ def run_all_migrations(db_path=None): migrate_to_company_model(db_path) migrate_work_config_data(db_path) migrate_task_system(db_path) + migrate_system_events(db_path) if FLASK_AVAILABLE: with app.app_context(): @@ -678,6 +679,54 @@ def migrate_task_system(db_path): conn.close() +def migrate_system_events(db_path): + """Create system_event table for activity logging.""" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # Check if system_event table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='system_event'") + if not cursor.fetchone(): + print("Creating system_event table...") + cursor.execute(""" + CREATE TABLE system_event ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type VARCHAR(50) NOT NULL, + event_category VARCHAR(30) NOT NULL, + description TEXT NOT NULL, + severity VARCHAR(20) DEFAULT 'info', + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER, + company_id INTEGER, + event_metadata TEXT, + ip_address VARCHAR(45), + user_agent TEXT, + FOREIGN KEY (user_id) REFERENCES user (id), + FOREIGN KEY (company_id) REFERENCES company (id) + ) + """) + + # Add an initial system event if Flask is available + if FLASK_AVAILABLE: + # We'll add the initial event after the table is created + cursor.execute(""" + INSERT INTO system_event (event_type, event_category, description, severity, timestamp) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + """, ('system_migration', 'system', 'SystemEvent table created and initialized', 'info')) + print("Added initial system event") + + conn.commit() + print("System events migration completed successfully!") + + except Exception as e: + print(f"Error during system events migration: {e}") + conn.rollback() + raise + finally: + conn.close() + + def migrate_data(): """Handle data migration with Flask app context.""" if not FLASK_AVAILABLE: @@ -847,6 +896,8 @@ def main(): help='Run only company model migration') parser.add_argument('--basic', '-b', action='store_true', help='Run only basic table migrations') + parser.add_argument('--system-events', '-s', action='store_true', + help='Run only system events migration') args = parser.parse_args() @@ -876,6 +927,9 @@ def main(): elif args.basic: run_basic_migrations(db_path) + elif args.system_events: + migrate_system_events(db_path) + else: # Default: run all migrations run_all_migrations(db_path) diff --git a/models.py b/models.py index c22bcd9..6736d25 100644 --- a/models.py +++ b/models.py @@ -612,4 +612,113 @@ class Announcement(db.Model): def get_active_announcements_for_user(user): """Get all active announcements visible to a specific user""" announcements = Announcement.query.filter_by(is_active=True).all() - return [ann for ann in announcements if ann.is_visible_to_user(user)] \ No newline at end of file + return [ann for ann in announcements if ann.is_visible_to_user(user)] + +# System Event model for logging system activities +class SystemEvent(db.Model): + id = db.Column(db.Integer, primary_key=True) + event_type = db.Column(db.String(50), nullable=False) # e.g., 'login', 'logout', 'user_created', 'system_error' + event_category = db.Column(db.String(30), nullable=False) # e.g., 'auth', 'user_management', 'system', 'error' + description = db.Column(db.Text, nullable=False) + severity = db.Column(db.String(20), default='info') # 'info', 'warning', 'error', 'critical' + timestamp = db.Column(db.DateTime, default=datetime.now, nullable=False) + + # Optional associations + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=True) + + # Additional metadata (JSON string) + event_metadata = db.Column(db.Text, nullable=True) # Store additional event data as JSON + + # IP address and user agent for security tracking + ip_address = db.Column(db.String(45), nullable=True) # IPv6 compatible + user_agent = db.Column(db.Text, nullable=True) + + # Relationships + user = db.relationship('User', backref='system_events') + company = db.relationship('Company', backref='system_events') + + def __repr__(self): + return f'' + + @staticmethod + def log_event(event_type, description, event_category='system', severity='info', + user_id=None, company_id=None, event_metadata=None, ip_address=None, user_agent=None): + """Helper method to log system events""" + event = SystemEvent( + event_type=event_type, + event_category=event_category, + description=description, + severity=severity, + user_id=user_id, + company_id=company_id, + event_metadata=event_metadata, + ip_address=ip_address, + user_agent=user_agent + ) + db.session.add(event) + try: + db.session.commit() + except Exception as e: + db.session.rollback() + # Log to application logger if DB logging fails + import logging + logging.error(f"Failed to log system event: {e}") + + @staticmethod + def get_recent_events(days=7, limit=100): + """Get recent system events from the last N days""" + from datetime import datetime, timedelta + since = datetime.now() - timedelta(days=days) + return SystemEvent.query.filter( + SystemEvent.timestamp >= since + ).order_by(SystemEvent.timestamp.desc()).limit(limit).all() + + @staticmethod + def get_events_by_severity(severity, days=7, limit=50): + """Get events by severity level""" + from datetime import datetime, timedelta + since = datetime.now() - timedelta(days=days) + return SystemEvent.query.filter( + SystemEvent.timestamp >= since, + SystemEvent.severity == severity + ).order_by(SystemEvent.timestamp.desc()).limit(limit).all() + + @staticmethod + def get_system_health_summary(): + """Get a summary of system health based on recent events""" + from datetime import datetime, timedelta + from sqlalchemy import func + + now = datetime.now() + last_24h = now - timedelta(hours=24) + last_week = now - timedelta(days=7) + + # Count events by severity in last 24h + recent_errors = SystemEvent.query.filter( + SystemEvent.timestamp >= last_24h, + SystemEvent.severity.in_(['error', 'critical']) + ).count() + + recent_warnings = SystemEvent.query.filter( + SystemEvent.timestamp >= last_24h, + SystemEvent.severity == 'warning' + ).count() + + # Count total events in last week + weekly_events = SystemEvent.query.filter( + SystemEvent.timestamp >= last_week + ).count() + + # Get most recent error + last_error = SystemEvent.query.filter( + SystemEvent.severity.in_(['error', 'critical']) + ).order_by(SystemEvent.timestamp.desc()).first() + + return { + 'errors_24h': recent_errors, + 'warnings_24h': recent_warnings, + 'total_events_week': weekly_events, + 'last_error': last_error, + 'health_status': 'healthy' if recent_errors == 0 else 'issues' if recent_errors < 5 else 'critical' + } \ No newline at end of file diff --git a/templates/system_admin_dashboard.html b/templates/system_admin_dashboard.html index e7bc054..d09785e 100644 --- a/templates/system_admin_dashboard.html +++ b/templates/system_admin_dashboard.html @@ -183,6 +183,9 @@ ⚙️ System Settings + + 🏥 System Health + diff --git a/templates/system_admin_health.html b/templates/system_admin_health.html new file mode 100644 index 0000000..1c1c645 --- /dev/null +++ b/templates/system_admin_health.html @@ -0,0 +1,579 @@ +{% extends "layout.html" %} + +{% block content %} +
+
+

🏥 System Health Check

+

System diagnostics and event monitoring

+ ← Back to Dashboard +
+ + +
+

🔍 System Status

+
+
+
+ {% if health_summary.health_status == 'healthy' %} + ✅ + {% elif health_summary.health_status == 'issues' %} + ⚠️ + {% else %} + ❌ + {% endif %} +
+
+

Overall Health

+

{{ health_summary.health_status|title }}

+ + {% if health_summary.health_status == 'healthy' %} + All systems running normally + {% elif health_summary.health_status == 'issues' %} + Minor issues detected + {% else %} + Critical issues require attention + {% endif %} + +
+
+ +
+
+ {% if db_healthy %}✅{% else %}❌{% endif %} +
+
+

Database

+

{% if db_healthy %}Connected{% else %}Error{% endif %}

+ + {% if db_healthy %} + PostgreSQL connection active + {% else %} + {{ db_error }} + {% endif %} + +
+
+ +
+
⏱️
+
+

Uptime

+

{{ uptime_duration.days }}d {{ uptime_duration.seconds//3600 }}h {{ (uptime_duration.seconds//60)%60 }}m

+ Since first recorded event +
+
+
+
+ + +
+

📊 Event Statistics

+
+
+

{{ health_summary.errors_24h }}

+

Errors (24h)

+
+
+

{{ health_summary.warnings_24h }}

+

Warnings (24h)

+
+
+

{{ today_events }}

+

Events Today

+
+
+

{{ health_summary.total_events_week }}

+

Events This Week

+
+
+
+ + + {% if errors %} +
+

🚨 Recent Errors

+
+ {% for error in errors %} +
+
+ {{ error.event_type }} + {{ error.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} +
+
{{ error.description }}
+ {% if error.user %} +
User: {{ error.user.username }}
+ {% endif %} + {% if error.company %} +
Company: {{ error.company.name }}
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if warnings %} +
+

⚠️ Recent Warnings

+
+ {% for warning in warnings %} +
+
+ {{ warning.event_type }} + {{ warning.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} +
+
{{ warning.description }}
+ {% if warning.user %} +
User: {{ warning.user.username }}
+ {% endif %} + {% if warning.company %} +
Company: {{ warning.company.name }}
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + +
+

📋 System Event Log (Last 7 Days)

+
+ + + + + +
+
+ {% for event in recent_events %} +
+
+ {{ event.event_type }} + {{ event.event_category }} + {{ event.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} +
+
{{ event.description }}
+ {% if event.user %} +
User: {{ event.user.username }}
+ {% endif %} + {% if event.company %} +
Company: {{ event.company.name }}
+ {% endif %} + {% if event.ip_address %} +
IP: {{ event.ip_address }}
+ {% endif %} +
+ {% endfor %} +
+
+ + + {% if health_summary.last_error %} +
+

🔍 Last Critical Error

+
+
+
+

{{ health_summary.last_error.event_type }}

+ {{ health_summary.last_error.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} +
+
{{ health_summary.last_error.description }}
+ {% if health_summary.last_error.event_metadata %} + + {% endif %} +
+
+
+ {% endif %} +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/system_admin_settings.html b/templates/system_admin_settings.html index c9658a0..4e6362c 100644 --- a/templates/system_admin_settings.html +++ b/templates/system_admin_settings.html @@ -123,7 +123,7 @@

Database

-

SQLite

+

PostgreSQL

System Admin Access

@@ -155,9 +155,9 @@

Run diagnostic checks to identify potential issues with the system.