Root cause: Template expects flat structure (metrics.database.count) but monitoring module provides nested structure (metrics.by_type.database.count) with different field names (avg_duration_ms vs avg). Solution: Route Adapter Pattern - transformer function maps data structure at presentation layer. Changes: - Add transform_metrics_for_template() function to admin.py - Update metrics_dashboard() route to use transformer - Provide safe defaults for missing/empty metrics data - Handle all operation types: database, http, render Testing: All 32 admin route tests passing Documentation: - Updated implementation report with actual fix details - Created consolidated hotfix design documentation - Architectural review by architect (approved with minor concerns) Technical debt: Adapter layer should be replaced with proper data contracts in v1.2.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
491 lines
14 KiB
Python
491 lines
14 KiB
Python
"""
|
|
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/<int:note_id>", 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/<int:note_id>", 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/<int:note_id>", 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
|