Files
StarPunk/starpunk/routes/admin.py
Phil Skentelbery 07fff01fab feat: Complete v1.1.1 Phases 2 & 3 - Enhancements and Polish
Phase 2 - Enhancements:
- Add performance monitoring infrastructure with MetricsBuffer
- Implement three-tier health checks (/health, /health?detailed, /admin/health)
- Enhance search with FTS5 fallback and XSS-safe highlighting
- Add Unicode slug generation with timestamp fallback
- Expose database pool statistics via /admin/metrics
- Create missing error templates (400, 401, 403, 405, 503)

Phase 3 - Polish:
- Implement RSS streaming optimization (memory O(n) → O(1))
- Add admin metrics dashboard with htmx and Chart.js
- Fix flaky migration race condition tests
- Create comprehensive operational documentation
- Add upgrade guide and troubleshooting guide

Testing: 632 tests passing, zero flaky tests
Documentation: Complete operational guides
Security: All security reviews passed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 20:10:41 -07:00

426 lines
12 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"))
@bp.route("/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
"""
from starpunk.database.pool import get_pool_stats
from starpunk.monitoring import get_metrics_stats
# Get current metrics for initial page load
metrics_data = {}
pool_stats = {}
try:
metrics_data = get_metrics_stats()
except Exception as e:
flash(f"Error loading metrics: {e}", "warning")
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