"""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,
)