feat: project overview + timeline + docs center
- 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
This commit is contained in:
245
routes/pages.py
245
routes/pages.py
@@ -1,10 +1,14 @@
|
||||
"""Pages blueprint: server-rendered HTML pages."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, render_template, request
|
||||
from flask import Blueprint, abort, render_template, request
|
||||
|
||||
from services import kanban, projects, worker
|
||||
import config
|
||||
from services import kanban, projects, task_project_linker, worker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,14 +60,43 @@ def projects_page():
|
||||
|
||||
@bp.route("/projects/<name>")
|
||||
def project_detail_page(name: str):
|
||||
"""Project detail page."""
|
||||
"""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)}
|
||||
|
||||
return render_template("project_detail.html", project=detail, name=name)
|
||||
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")
|
||||
@@ -89,11 +122,211 @@ def tasks_page():
|
||||
|
||||
@bp.route("/tasks/<task_id>")
|
||||
def task_detail_page(task_id: str):
|
||||
"""Task detail page with timeline, comments, runs, attachments."""
|
||||
"""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)}
|
||||
|
||||
return render_template("task_detail.html", data=data, task_id=task_id)
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user