diff --git a/pyproject.toml b/pyproject.toml index 2603013..d8dd7a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "psycopg-pool>=3.2", "pydantic>=2.0.0", "python-dotenv>=1.0.0", + "python-multipart>=0.0.20", "sqlalchemy>=2.0.0", "tomli>=2.0.0", "trafilatura>=1.9.0", diff --git a/src/second_brain/web/app.py b/src/second_brain/web/app.py index 4ad1c21..59f8304 100644 --- a/src/second_brain/web/app.py +++ b/src/second_brain/web/app.py @@ -14,14 +14,21 @@ from __future__ import annotations from pathlib import Path from typing import List, Optional -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, Form, HTTPException, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel +from sqlalchemy import func from second_brain.config import DOMAINS from second_brain.database import get_database from second_brain.models import Extraction, Source, SourceStatus +from second_brain.settings_store import ( + get_settings, + parse_int_or_none, + parse_time_or_none, + update_settings, +) # --------------------------------------------------------------------------- # App setup @@ -295,3 +302,176 @@ def _source_to_response(s: Source) -> SourceResponse: ingested_at=s.ingested_at.isoformat() if s.ingested_at else "", has_extraction=s.extraction is not None, ) + + +# --------------------------------------------------------------------------- +# Dashboard — pipeline observability +# --------------------------------------------------------------------------- + + +@app.get("/dashboard", response_class=HTMLResponse) +async def dashboard(request: Request): + """Overview: status counts, recent activity, and a settings snapshot.""" + db = get_database() + with db.session() as sess: + # Counts by status. Materialise inside the session so the keys stay + # plain str → int and templates don't try to touch a closed session. + rows = ( + sess.query(Source.status, func.count(Source.id)) + .group_by(Source.status) + .all() + ) + counts = {s.value: 0 for s in SourceStatus} + for status, n in rows: + counts[status.value] = int(n) + total = sum(counts.values()) + + recent_rows = ( + sess.query(Source) + .order_by(Source.ingested_at.desc()) + .limit(10) + .all() + ) + recent = [_source_to_dict(s) for s in recent_rows] + + settings_row = get_settings(sess) + settings_summary = _settings_to_dict(settings_row) + + return templates.TemplateResponse( + request, + "dashboard.html", + { + "counts": counts, + "total": total, + "recent": recent, + "statuses_in_order": [s.value for s in SourceStatus], + "settings": settings_summary, + }, + ) + + +# --------------------------------------------------------------------------- +# Settings — view + HTMX save +# --------------------------------------------------------------------------- + + +@app.get("/settings", response_class=HTMLResponse) +async def settings_view(request: Request): + db = get_database() + with db.session() as sess: + row = get_settings(sess) + ctx = _settings_to_dict(row) + + return templates.TemplateResponse( + request, + "settings.html", + {"settings": ctx, "flash": None, "error": None}, + ) + + +@app.post("/settings/save", response_class=HTMLResponse) +async def settings_save( + request: Request, + transcription_enabled: Optional[str] = Form(None), + transcription_active_hours_start: Optional[str] = Form(None), + transcription_active_hours_end: Optional[str] = Form(None), + transcription_max_concurrent_gpu_jobs: Optional[str] = Form(None), + transcription_max_video_length_seconds: Optional[str] = Form(None), + transcription_max_items_per_run: Optional[str] = Form(None), + extraction_enabled: Optional[str] = Form(None), + extraction_active_hours_start: Optional[str] = Form(None), + extraction_active_hours_end: Optional[str] = Form(None), + extraction_max_items_per_run: Optional[str] = Form(None), +): + """HTMX save endpoint — returns the rendered settings card with a flash.""" + db = get_database() + error: Optional[str] = None + flash: Optional[str] = None + + try: + fields = { + # Checkboxes only show up in the form payload when checked. The + # template emits a hidden "_present" companion so we can tell + # "unchecked" apart from "field missing because of a partial post". + "transcription_enabled": transcription_enabled == "on", + "transcription_active_hours_start": parse_time_or_none( + transcription_active_hours_start + ), + "transcription_active_hours_end": parse_time_or_none( + transcription_active_hours_end + ), + "transcription_max_concurrent_gpu_jobs": int( + transcription_max_concurrent_gpu_jobs or "1" + ), + "transcription_max_video_length_seconds": parse_int_or_none( + transcription_max_video_length_seconds + ), + "transcription_max_items_per_run": int( + transcription_max_items_per_run or "10" + ), + "extraction_enabled": extraction_enabled == "on", + "extraction_active_hours_start": parse_time_or_none( + extraction_active_hours_start + ), + "extraction_active_hours_end": parse_time_or_none( + extraction_active_hours_end + ), + "extraction_max_items_per_run": int(extraction_max_items_per_run or "20"), + } + # Cheap inline validation — keep counts non-negative and gpu jobs >= 1. + if fields["transcription_max_concurrent_gpu_jobs"] < 1: + raise ValueError("transcription_max_concurrent_gpu_jobs must be ≥ 1") + if fields["transcription_max_items_per_run"] < 0: + raise ValueError("transcription_max_items_per_run must be ≥ 0") + if fields["extraction_max_items_per_run"] < 0: + raise ValueError("extraction_max_items_per_run must be ≥ 0") + if ( + fields["transcription_max_video_length_seconds"] is not None + and fields["transcription_max_video_length_seconds"] < 0 + ): + raise ValueError("transcription_max_video_length_seconds must be ≥ 0") + + with db.session() as sess: + row = update_settings(sess, **fields) + ctx = _settings_to_dict(row) + flash = "Saved." + except (ValueError, KeyError) as exc: + error = str(exc) + # Re-render the form using whatever the user submitted so they don't + # lose in-progress edits on a validation bounce. + with db.session() as sess: + ctx = _settings_to_dict(get_settings(sess)) + + return templates.TemplateResponse( + request, + "_settings_card.html", + {"settings": ctx, "flash": flash, "error": error}, + ) + + +# --------------------------------------------------------------------------- +# Settings serialiser +# --------------------------------------------------------------------------- + + +def _settings_to_dict(row) -> dict: + """Render the settings row as a template-friendly dict (no ORM bleed).""" + + def t(v): + return v.strftime("%H:%M") if v is not None else "" + + return { + "transcription_enabled": row.transcription_enabled, + "transcription_active_hours_start": t(row.transcription_active_hours_start), + "transcription_active_hours_end": t(row.transcription_active_hours_end), + "transcription_max_concurrent_gpu_jobs": row.transcription_max_concurrent_gpu_jobs, + "transcription_max_video_length_seconds": row.transcription_max_video_length_seconds, + "transcription_max_items_per_run": row.transcription_max_items_per_run, + "extraction_enabled": row.extraction_enabled, + "extraction_active_hours_start": t(row.extraction_active_hours_start), + "extraction_active_hours_end": t(row.extraction_active_hours_end), + "extraction_max_items_per_run": row.extraction_max_items_per_run, + "updated_at": row.updated_at.strftime("%Y-%m-%d %H:%M:%S") + if row.updated_at + else "", + } diff --git a/src/second_brain/web/templates/_settings_card.html b/src/second_brain/web/templates/_settings_card.html new file mode 100644 index 0000000..7fc709c --- /dev/null +++ b/src/second_brain/web/templates/_settings_card.html @@ -0,0 +1,130 @@ +{# HTMX-replaceable inner card. Posted to /settings/save; swaps itself in place. #} +
personal knowledge pipeline
diff --git a/src/second_brain/web/templates/dashboard.html b/src/second_brain/web/templates/dashboard.html new file mode 100644 index 0000000..4e92937 --- /dev/null +++ b/src/second_brain/web/templates/dashboard.html @@ -0,0 +1,158 @@ +{% extends "base.html" %} + +{% block title %}Dashboard — second-brain{% endblock %} + +{% block content %} +{% set status_colors = { + 'pending': 'bg-gray-100 text-gray-700', + 'pulled': 'bg-blue-100 text-blue-700', + 'transcribed': 'bg-yellow-100 text-yellow-700', + 'analyzed': 'bg-orange-100 text-orange-700', + 'accepted': 'bg-green-100 text-green-700', + 'published': 'bg-purple-100 text-purple-700', + 'failed': 'bg-red-100 text-red-700' +} %} +{% set domain_colors = { + 'development': 'bg-cyan-50 text-cyan-700 border-cyan-200', + 'content': 'bg-pink-50 text-pink-700 border-pink-200', + 'business': 'bg-amber-50 text-amber-700 border-amber-200', + 'homelab': 'bg-teal-50 text-teal-700 border-teal-200' +} %} + ++ updated {{ settings.updated_at }} +
+{{ source.title }}
+{{ source.url }}
+No sources yet.
+ {% endif %} +
+ Workers read these at the start of each run. DB values override settings.toml.
+