"""Pages blueprint: server-rendered HTML pages.""" import logging import os import time from pathlib import Path from flask import Blueprint, abort, render_template, request import config from services import kanban, projects, task_project_linker, worker logger = logging.getLogger(__name__) bp = Blueprint("pages", __name__) @bp.route("/") def dashboard(): """Main dashboard: worker status, kanban board, processes, projects strip.""" try: worker_status = worker.get_worker_status() except Exception as exc: logger.exception("dashboard worker_status failed") worker_status = {"current_task": None, "processes": [], "soul_excerpt": ""} try: board_data = kanban.get_kanban_board() board = board_data.get("board", {}) except Exception as exc: logger.exception("dashboard board failed") board = {} try: all_projects = projects.get_all_projects() except Exception as exc: logger.exception("dashboard projects failed") all_projects = [] return render_template( "dashboard.html", worker_status=worker_status, board=board, processes=worker_status.get("processes", []), projects=all_projects, ) @bp.route("/projects") def projects_page(): """Projects table page.""" try: all_projects = projects.get_all_projects() except Exception as exc: logger.exception("projects_page failed") all_projects = [] return render_template("projects.html", projects=all_projects) @bp.route("/projects/") def project_detail_page(name: str): """Project detail page with git info, tasks timeline, and commits.""" try: detail = projects.get_project_detail(name) except Exception as exc: logger.exception("project_detail_page failed") detail = {"error": str(exc)} try: tasks = projects.get_project_tasks(name) except Exception as exc: logger.exception("project tasks failed for %s", name) tasks = [] try: commits = projects.get_project_commits_in_window(name, limit=20) except Exception as exc: logger.exception("project commits failed for %s", name) commits = [] # Files changed per task (use task time window) task_files: dict[str, dict[str, list[str]]] = {} for t in tasks: if t.get("started_at") and t.get("completed_at"): files = task_project_linker.get_task_files_changed( name, t["started_at"], t["completed_at"] ) if files: task_files[t["task_id"]] = files return render_template( "project_detail.html", project=detail, name=name, tasks=tasks, commits=commits, task_files=task_files, ) @bp.route("/tasks") def tasks_page(): """Tasks table page with optional status filter.""" status = request.args.get("status") try: data = kanban.get_tasks(status) tasks = data.get("tasks", []) counts = data.get("counts", {}) except Exception as exc: logger.exception("tasks_page failed") tasks = [] counts = {} return render_template( "tasks.html", tasks=tasks, counts=counts, current_status=status, ) @bp.route("/tasks/") def task_detail_page(task_id: str): """Task detail page with timeline, comments, runs, attachments, project link.""" try: data = kanban.get_task_detail(task_id) except Exception as exc: logger.exception("task_detail_page failed") data = {"error": str(exc)} project_name: str | None = None commits: list[dict] = [] files_changed: dict[str, list[str]] = {} if not data.get("error"): try: project_name = task_project_linker.link_task_to_project(task_id) except Exception: logger.exception("link_task_to_project failed for %s", task_id) task = data.get("task", {}) started = task.get("started_at") completed = task.get("completed_at") if project_name and started: end_ts = completed or int(time.time()) try: commits = projects.get_project_commits_in_window( project_name, start_ts=int(started), end_ts=int(end_ts), limit=20 ) files_changed = task_project_linker.get_task_files_changed( project_name, int(started), int(end_ts) ) except Exception: logger.exception("task commits failed for %s", task_id) return render_template( "task_detail.html", data=data, task_id=task_id, project_name=project_name, commits=commits, files_changed=files_changed, ) @bp.route("/timeline") def timeline_page(): """Full task timeline grouped by project.""" try: data = kanban.get_tasks() all_tasks = data.get("tasks", []) except Exception as exc: logger.exception("timeline tasks failed") all_tasks = [] # Bucket each task into a project via linker by_project: dict[str, list[dict]] = {p["name"]: [] for p in config.MANAGED_PROJECTS} by_project["unassigned"] = [] for t in all_tasks: try: pname = task_project_linker.link_task_to_project(t["id"]) or "unassigned" except Exception: pname = "unassigned" duration_s = None if t.get("started_at") and t.get("completed_at"): duration_s = int(t["completed_at"]) - int(t["started_at"]) by_project.setdefault(pname, []).append({ "task_id": t["id"], "title": t["title"], "status": t["status"], "created_at": t.get("created_at"), "started_at": t.get("started_at"), "completed_at": t.get("completed_at"), "duration_s": duration_s, "outcome_summary": (t.get("result") or "")[:150], }) stats = { "total": len(all_tasks), "done": sum(1 for t in all_tasks if t.get("status") == "done"), "blocked": sum(1 for t in all_tasks if t.get("status") == "blocked"), "running": sum(1 for t in all_tasks if t.get("status") == "running"), "ready": sum(1 for t in all_tasks if t.get("status") == "ready"), "failed": sum(1 for t in all_tasks if t.get("status") == "failed"), } proj_colors = {p["name"]: p.get("color", "#888") for p in config.MANAGED_PROJECTS} return render_template( "timeline.html", by_project=by_project, stats=stats, proj_colors=proj_colors, ) def _scan_docs() -> list[dict]: """Scan DOCS_DIRS for .md files; return metadata list.""" items: list[dict] = [] for docs_dir in config.DOCS_DIRS: root = Path(docs_dir) if not root.is_dir(): continue for md in sorted(root.rglob("*.md")): try: stat = md.stat() rel = md.relative_to(root) with open(md, "r", encoding="utf-8", errors="replace") as fh: content = fh.read() title = rel.stem for line in content.splitlines(): line_s = line.strip() if line_s.startswith("# "): title = line_s[2:].strip() break snippet = "" for line in content.splitlines(): line_s = line.strip() if line_s and not line_s.startswith("#"): snippet = line_s[:100] break items.append({ "abs_path": str(md), "rel_path": f"{root.name}/{rel}", "title": title, "size": stat.st_size, "mtime": int(stat.st_mtime), "snippet": snippet, }) except OSError: continue items.sort(key=lambda x: x["mtime"], reverse=True) return items def _resolve_doc_path(rel_path: str) -> Path | None: """Resolve user-supplied rel_path against DOCS_DIRS with traversal protection.""" for docs_dir in config.DOCS_DIRS: root = Path(docs_dir).resolve() candidate = (root / rel_path).resolve() try: candidate.relative_to(root) except ValueError: continue if candidate.is_file() and candidate.suffix == ".md": return candidate # Also support root-name-prefixed form (e.g. "docs/foo.md" -> root/docs/foo.md skipped; "foo.md" tried) for docs_dir in config.DOCS_DIRS: root = Path(docs_dir).resolve() if rel_path.startswith(root.name + "/"): stripped = rel_path[len(root.name) + 1:] candidate = (root / stripped).resolve() try: candidate.relative_to(root) except ValueError: continue if candidate.is_file() and candidate.suffix == ".md": return candidate return None @bp.route("/docs") def docs_page(): """Docs list page.""" try: docs = _scan_docs() except Exception as exc: logger.exception("docs scan failed") docs = [] return render_template("docs.html", docs=docs) @bp.route("/docs/") def doc_view_page(rel_path: str): """Render a single markdown doc.""" doc_path = _resolve_doc_path(rel_path) if doc_path is None: abort(404) try: with open(doc_path, "r", encoding="utf-8", errors="replace") as fh: raw = fh.read() except OSError: abort(404) rendered_html: str | None = None try: import markdown # type: ignore rendered_html = markdown.markdown( raw, extensions=["fenced_code", "tables", "toc"] ) except ImportError: rendered_html = None title = doc_path.stem for line in raw.splitlines(): line_s = line.strip() if line_s.startswith("# "): title = line_s[2:].strip() break return render_template( "doc_view.html", rel_path=rel_path, title=title, raw=raw, rendered_html=rendered_html, )