- services/task_project_linker.py: task<->project mapping (title keywords + workspace_path) - routes/pages.py: /timeline, /docs, /docs/<path>, enhanced /projects/<name> and /tasks/<id> - templates: timeline.html, docs.html, doc_view.html (new), project_detail.html + task_detail.html (overhauled) - config.py: DOCS_DIRS + ProjectConfig color - base.html: Timeline + Docs nav links
333 lines
10 KiB
Python
333 lines
10 KiB
Python
"""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/<name>")
|
|
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/<task_id>")
|
|
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/<path:rel_path>")
|
|
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,
|
|
)
|