""" Admin routes for StarPunk Handles authenticated admin functionality including dashboard, note creation, editing, and deletion. All routes require authentication. """ from flask import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for import os import shutil from datetime import datetime from starpunk.auth import require_auth from starpunk.notes import ( create_note, delete_note, list_notes, get_note, update_note, ) # Create blueprint bp = Blueprint("admin", __name__, url_prefix="/admin") @bp.route("/") @require_auth def dashboard(): """ Admin dashboard with note list Displays all notes (published and drafts) with management controls. Requires authentication. Returns: Rendered dashboard template with complete note list Decorator: @require_auth Template: templates/admin/dashboard.html Access: g.user_me (set by require_auth decorator) """ # Get all notes (published and drafts) notes = list_notes() return render_template("admin/dashboard.html", notes=notes, user_me=g.me) @bp.route("/new", methods=["GET"]) @require_auth def new_note_form(): """ Display create note form Shows empty form for creating a new note. Requires authentication. Returns: Rendered new note form template Decorator: @require_auth Template: templates/admin/new.html """ return render_template("admin/new.html") @bp.route("/new", methods=["POST"]) @require_auth def create_note_submit(): """ Handle new note submission Creates a new note from submitted form data. Requires authentication. Form data: content: Markdown content (required) published: Checkbox for published status (optional) Returns: Redirect to dashboard on success, back to form on error Decorator: @require_auth """ content = request.form.get("content", "").strip() published = "published" in request.form if not content: flash("Content cannot be empty", "error") return redirect(url_for("admin.new_note_form")) try: note = create_note(content, published=published) flash(f"Note created: {note.slug}", "success") return redirect(url_for("admin.dashboard")) except ValueError as e: flash(f"Error creating note: {e}", "error") return redirect(url_for("admin.new_note_form")) except Exception as e: flash(f"Unexpected error creating note: {e}", "error") return redirect(url_for("admin.new_note_form")) @bp.route("/edit/", methods=["GET"]) @require_auth def edit_note_form(note_id: int): """ Display edit note form Shows form pre-filled with existing note content for editing. Requires authentication. Args: note_id: Database ID of note to edit Returns: Rendered edit form template or 404 if note not found Decorator: @require_auth Template: templates/admin/edit.html """ note = get_note(id=note_id) if not note: flash("Note not found", "error") return redirect(url_for("admin.dashboard")), 404 return render_template("admin/edit.html", note=note) @bp.route("/edit/", methods=["POST"]) @require_auth def update_note_submit(note_id: int): """ Handle note update submission Updates existing note with submitted form data. Requires authentication. Args: note_id: Database ID of note to update Form data: content: Updated markdown content (required) published: Checkbox for published status (optional) Returns: Redirect to dashboard on success, back to form on error Decorator: @require_auth """ # Check if note exists first existing_note = get_note(id=note_id, load_content=False) if not existing_note: flash("Note not found", "error") return redirect(url_for("admin.dashboard")), 404 content = request.form.get("content", "").strip() published = "published" in request.form if not content: flash("Content cannot be empty", "error") return redirect(url_for("admin.edit_note_form", note_id=note_id)) try: note = update_note(id=note_id, content=content, published=published) flash(f"Note updated: {note.slug}", "success") return redirect(url_for("admin.dashboard")) except ValueError as e: flash(f"Error updating note: {e}", "error") return redirect(url_for("admin.edit_note_form", note_id=note_id)) except Exception as e: flash(f"Unexpected error updating note: {e}", "error") return redirect(url_for("admin.edit_note_form", note_id=note_id)) @bp.route("/delete/", methods=["POST"]) @require_auth def delete_note_submit(note_id: int): """ Handle note deletion Deletes a note after confirmation. Requires authentication. Args: note_id: Database ID of note to delete Form data: confirm: Must be 'yes' to proceed with deletion Returns: Redirect to dashboard with success/error message Decorator: @require_auth """ # Check if note exists first (per ADR-012) existing_note = get_note(id=note_id, load_content=False) if not existing_note: flash("Note not found", "error") return redirect(url_for("admin.dashboard")), 404 # Check for confirmation if request.form.get("confirm") != "yes": flash("Deletion cancelled", "info") return redirect(url_for("admin.dashboard")) try: delete_note(id=note_id, soft=False) flash("Note deleted successfully", "success") except ValueError as e: flash(f"Error deleting note: {e}", "error") except Exception as e: flash(f"Unexpected error deleting note: {e}", "error") return redirect(url_for("admin.dashboard")) def transform_metrics_for_template(metrics_stats): """ Transform metrics stats to match template structure The template expects direct access to metrics.database.count, but get_metrics_stats() returns metrics.by_type.database.count. This function adapts the data structure to match template expectations. Args: metrics_stats: Dict from get_metrics_stats() with nested by_type structure Returns: Dict with flattened structure matching template expectations Per ADR-060: Route Adapter Pattern for template compatibility """ transformed = {} # Map by_type to direct access for op_type in ['database', 'http', 'render']: if 'by_type' in metrics_stats and op_type in metrics_stats['by_type']: type_data = metrics_stats['by_type'][op_type] transformed[op_type] = { 'count': type_data.get('count', 0), 'avg': type_data.get('avg_duration_ms', 0), 'min': type_data.get('min_duration_ms', 0), 'max': type_data.get('max_duration_ms', 0) } else: # Provide defaults for missing types or when by_type doesn't exist transformed[op_type] = { 'count': 0, 'avg': 0, 'min': 0, 'max': 0 } # Keep other top-level stats transformed['total_count'] = metrics_stats.get('total_count', 0) transformed['max_size'] = metrics_stats.get('max_size', 1000) transformed['process_id'] = metrics_stats.get('process_id', 0) return transformed @bp.route("/metrics-dashboard") @require_auth def metrics_dashboard(): """ Metrics visualization dashboard (Phase 3) Displays performance metrics, database statistics, and system health with visual charts and auto-refresh capability. Per Q19 requirements: - Server-side rendering with Jinja2 - htmx for auto-refresh - Chart.js from CDN for graphs - Progressive enhancement (works without JS) Returns: Rendered dashboard template with metrics Decorator: @require_auth Template: templates/admin/metrics_dashboard.html """ # Defensive imports with graceful degradation for missing modules try: from starpunk.database.pool import get_pool_stats from starpunk.monitoring import get_metrics_stats monitoring_available = True except ImportError: monitoring_available = False # Provide fallback functions that return error messages def get_pool_stats(): return {"error": "Database pool monitoring not available"} def get_metrics_stats(): return {"error": "Monitoring module not implemented"} # Get current metrics for initial page load metrics_data = {} pool_stats = {} try: raw_metrics = get_metrics_stats() metrics_data = transform_metrics_for_template(raw_metrics) except Exception as e: flash(f"Error loading metrics: {e}", "warning") # Provide safe defaults matching template expectations metrics_data = { 'database': {'count': 0, 'avg': 0, 'min': 0, 'max': 0}, 'http': {'count': 0, 'avg': 0, 'min': 0, 'max': 0}, 'render': {'count': 0, 'avg': 0, 'min': 0, 'max': 0}, 'total_count': 0, 'max_size': 1000, 'process_id': 0 } try: pool_stats = get_pool_stats() except Exception as e: flash(f"Error loading pool stats: {e}", "warning") return render_template( "admin/metrics_dashboard.html", metrics=metrics_data, pool=pool_stats, user_me=g.me ) @bp.route("/metrics") @require_auth def metrics(): """ Performance metrics and database pool statistics endpoint Per Phase 2 requirements: - Expose database pool statistics - Show performance metrics from MetricsBuffer - Requires authentication Returns: JSON with metrics and pool statistics Response codes: 200: Metrics retrieved successfully Decorator: @require_auth """ from flask import current_app from starpunk.database.pool import get_pool_stats from starpunk.monitoring import get_metrics_stats response = { "timestamp": datetime.utcnow().isoformat() + "Z", "process_id": os.getpid(), "database": {}, "performance": {} } # Get database pool statistics try: pool_stats = get_pool_stats() response["database"]["pool"] = pool_stats except Exception as e: response["database"]["pool"] = {"error": str(e)} # Get performance metrics try: metrics_stats = get_metrics_stats() response["performance"] = metrics_stats except Exception as e: response["performance"] = {"error": str(e)} return jsonify(response), 200 @bp.route("/health") @require_auth def health_diagnostics(): """ Full health diagnostics endpoint for admin use Per developer Q&A Q10: - Always requires authentication - Provides comprehensive diagnostics - Includes metrics, database pool statistics, and system info Returns: JSON with complete system diagnostics Response codes: 200: Diagnostics retrieved successfully 500: Critical health issues detected Decorator: @require_auth """ from flask import current_app from starpunk.database.pool import get_pool_stats diagnostics = { "status": "healthy", "version": current_app.config.get("VERSION", "unknown"), "environment": current_app.config.get("ENV", "unknown"), "process_id": os.getpid(), "checks": {}, "metrics": {}, "database": {} } overall_healthy = True # Database connectivity check try: from starpunk.database import get_db db = get_db() result = db.execute("SELECT 1").fetchone() db.close() diagnostics["checks"]["database"] = { "status": "healthy", "message": "Database accessible" } # Get database pool statistics try: pool_stats = get_pool_stats() diagnostics["database"]["pool"] = pool_stats except Exception as e: diagnostics["database"]["pool"] = {"error": str(e)} except Exception as e: diagnostics["checks"]["database"] = { "status": "unhealthy", "error": str(e) } overall_healthy = False # Filesystem check try: data_path = current_app.config.get("DATA_PATH", "data") if not os.path.exists(data_path): raise Exception("Data path not accessible") diagnostics["checks"]["filesystem"] = { "status": "healthy", "path": data_path, "writable": os.access(data_path, os.W_OK), "readable": os.access(data_path, os.R_OK) } except Exception as e: diagnostics["checks"]["filesystem"] = { "status": "unhealthy", "error": str(e) } overall_healthy = False # Disk space check try: data_path = current_app.config.get("DATA_PATH", "data") stat = shutil.disk_usage(data_path) percent_free = (stat.free / stat.total) * 100 diagnostics["checks"]["disk"] = { "status": "healthy" if percent_free > 10 else ("warning" if percent_free > 5 else "critical"), "total_gb": round(stat.total / (1024**3), 2), "used_gb": round(stat.used / (1024**3), 2), "free_gb": round(stat.free / (1024**3), 2), "percent_free": round(percent_free, 2), "percent_used": round((stat.used / stat.total) * 100, 2) } if percent_free <= 5: overall_healthy = False except Exception as e: diagnostics["checks"]["disk"] = { "status": "unhealthy", "error": str(e) } overall_healthy = False # Performance metrics try: from starpunk.monitoring import get_metrics_stats metrics_stats = get_metrics_stats() diagnostics["metrics"] = metrics_stats except Exception as e: diagnostics["metrics"] = {"error": str(e)} # Update overall status diagnostics["status"] = "healthy" if overall_healthy else "unhealthy" return jsonify(diagnostics), 200 if overall_healthy else 500