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:
hermes-dashboard
2026-08-17 20:00:37 +08:00
parent 1e5e30ffcb
commit c0cbd683f4
11 changed files with 813 additions and 16 deletions

View File

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