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:
17
config.py
17
config.py
@@ -43,15 +43,22 @@ class ProjectConfig(TypedDict):
|
|||||||
name: str
|
name: str
|
||||||
path: str
|
path: str
|
||||||
gitea_repo: str
|
gitea_repo: str
|
||||||
|
color: str
|
||||||
|
|
||||||
|
|
||||||
# Managed projects to monitor
|
# Managed projects to monitor
|
||||||
MANAGED_PROJECTS: list[ProjectConfig] = [
|
MANAGED_PROJECTS: list[ProjectConfig] = [
|
||||||
{"name": "blog-app", "path": "/home/yi/opencode-blog-showcase/blog-app", "gitea_repo": "blog-app"},
|
{"name": "blog-app", "path": "/home/yi/opencode-blog-showcase/blog-app", "gitea_repo": "blog-app", "color": "#4a9eff"},
|
||||||
{"name": "novel-app", "path": "/home/yi/novel-workspace/novel-app", "gitea_repo": "novel-app"},
|
{"name": "novel-app", "path": "/home/yi/novel-workspace/novel-app", "gitea_repo": "novel-app", "color": "#9e4aff"},
|
||||||
{"name": "hermes-dashboard", "path": "/home/yi/hermes-dashboard", "gitea_repo": "hermes-dashboard"},
|
{"name": "hermes-dashboard", "path": "/home/yi/hermes-dashboard", "gitea_repo": "hermes-dashboard", "color": "#ff9e4a"},
|
||||||
{"name": "omo-openagent", "path": "/home/yi/.cache/opencode/packages/oh-my-openagent@latest", "gitea_repo": "omo-openagent-config"},
|
{"name": "omo-openagent", "path": "/home/yi/.cache/opencode/packages/oh-my-openagent@latest", "gitea_repo": "omo-openagent-config", "color": "#4aff9e"},
|
||||||
{"name": "omo-config", "path": "/home/yi/.config/opencode", "gitea_repo": "omo-config"},
|
{"name": "omo-config", "path": "/home/yi/.config/opencode", "gitea_repo": "omo-config", "color": "#ff4a9e"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Docs directories scanned by /docs (browser-readable documentation center)
|
||||||
|
DOCS_DIRS: list[str] = [
|
||||||
|
"/home/yi/novel-workspace/docs",
|
||||||
|
"/home/yi/opencode-blog-showcase/docs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
245
routes/pages.py
245
routes/pages.py
@@ -1,10 +1,14 @@
|
|||||||
"""Pages blueprint: server-rendered HTML pages."""
|
"""Pages blueprint: server-rendered HTML pages."""
|
||||||
|
|
||||||
import logging
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -56,14 +60,43 @@ def projects_page():
|
|||||||
|
|
||||||
@bp.route("/projects/<name>")
|
@bp.route("/projects/<name>")
|
||||||
def project_detail_page(name: str):
|
def project_detail_page(name: str):
|
||||||
"""Project detail page."""
|
"""Project detail page with git info, tasks timeline, and commits."""
|
||||||
try:
|
try:
|
||||||
detail = projects.get_project_detail(name)
|
detail = projects.get_project_detail(name)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("project_detail_page failed")
|
logger.exception("project_detail_page failed")
|
||||||
detail = {"error": str(exc)}
|
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")
|
@bp.route("/tasks")
|
||||||
@@ -89,11 +122,211 @@ def tasks_page():
|
|||||||
|
|
||||||
@bp.route("/tasks/<task_id>")
|
@bp.route("/tasks/<task_id>")
|
||||||
def task_detail_page(task_id: str):
|
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:
|
try:
|
||||||
data = kanban.get_task_detail(task_id)
|
data = kanban.get_task_detail(task_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("task_detail_page failed")
|
logger.exception("task_detail_page failed")
|
||||||
data = {"error": str(exc)}
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import subprocess
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
from services import task_project_linker
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -159,3 +160,20 @@ def get_project_detail(name: str) -> dict[str, Any]:
|
|||||||
info["status_porcelain"] = status_porcelain
|
info["status_porcelain"] = status_porcelain
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_tasks(name: str) -> list[dict[str, Any]]:
|
||||||
|
"""Tasks linked to a project (via task_project_linker)."""
|
||||||
|
return task_project_linker.link_project_tasks(name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_commits_in_window(
|
||||||
|
name: str,
|
||||||
|
start_ts: int | None = None,
|
||||||
|
end_ts: int | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Commits for a project, optionally within a time window."""
|
||||||
|
return task_project_linker.link_project_commits(
|
||||||
|
name, limit=limit, since_ts=start_ts, until_ts=end_ts
|
||||||
|
)
|
||||||
|
|||||||
198
services/task_project_linker.py
Normal file
198
services/task_project_linker.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""Task <-> Project linker.
|
||||||
|
|
||||||
|
Maps kanban tasks to managed projects by title keywords + workspace_path
|
||||||
|
substring matching. Used by /projects/<name>, /timeline, and /tasks/<id>.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import config
|
||||||
|
from extensions import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TITLE_KEYWORDS: list[tuple[str, str]] = [
|
||||||
|
("blog-app", "blog-app"),
|
||||||
|
("novel-app", "novel-app"),
|
||||||
|
("novel-writer", "novel-app"),
|
||||||
|
("hermes-dashboard", "hermes-dashboard"),
|
||||||
|
("gitea", "hermes-dashboard"),
|
||||||
|
("omo-openagent", "omo-openagent"),
|
||||||
|
("omo-config", "omo-config"),
|
||||||
|
("opencode", "omo-config"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def link_task_to_project(task_id: str) -> str | None:
|
||||||
|
"""Return project name for a task_id, or None if no match.
|
||||||
|
|
||||||
|
Match priority: workspace_path substring > title keyword.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT title, workspace_path FROM tasks WHERE id = ?", (task_id,)
|
||||||
|
).fetchone()
|
||||||
|
except sqlite3.Error:
|
||||||
|
logger.exception("link_task_to_project db error for %s", task_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
title = (row["title"] or "").lower()
|
||||||
|
workspace = (row["workspace_path"] or "").lower()
|
||||||
|
|
||||||
|
for proj in config.MANAGED_PROJECTS:
|
||||||
|
proj_path = proj["path"].lower()
|
||||||
|
if proj_path and proj_path in workspace:
|
||||||
|
return proj["name"]
|
||||||
|
|
||||||
|
for keyword, project_name in _TITLE_KEYWORDS:
|
||||||
|
if keyword in title:
|
||||||
|
return project_name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def link_project_tasks(project_name: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return all tasks linked to a given project, newest first."""
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, title, status, created_at, started_at, completed_at, "
|
||||||
|
"workspace_path, result FROM tasks ORDER BY created_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.Error:
|
||||||
|
logger.exception("link_project_tasks db error")
|
||||||
|
return []
|
||||||
|
|
||||||
|
proj = next((p for p in config.MANAGED_PROJECTS if p["name"] == project_name), None)
|
||||||
|
proj_path = (proj["path"] if proj else "").lower()
|
||||||
|
|
||||||
|
matched: list[dict[str, Any]] = []
|
||||||
|
for r in rows:
|
||||||
|
title = (r["title"] or "").lower()
|
||||||
|
workspace = (r["workspace_path"] or "").lower()
|
||||||
|
|
||||||
|
is_match = False
|
||||||
|
if proj_path and proj_path in workspace:
|
||||||
|
is_match = True
|
||||||
|
else:
|
||||||
|
for keyword, pname in _TITLE_KEYWORDS:
|
||||||
|
if pname == project_name and keyword in title:
|
||||||
|
is_match = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not is_match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
duration_s = None
|
||||||
|
if r["started_at"] and r["completed_at"]:
|
||||||
|
duration_s = int(r["completed_at"]) - int(r["started_at"])
|
||||||
|
|
||||||
|
matched.append({
|
||||||
|
"task_id": r["id"],
|
||||||
|
"title": r["title"],
|
||||||
|
"status": r["status"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"started_at": r["started_at"],
|
||||||
|
"completed_at": r["completed_at"],
|
||||||
|
"duration_s": duration_s,
|
||||||
|
"outcome_summary": (r["result"] or "")[:200],
|
||||||
|
"workspace_path": r["workspace_path"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(cwd: str, args: list[str]) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git"] + args, cwd=cwd,
|
||||||
|
capture_output=True, text=True, check=False, timeout=5,
|
||||||
|
)
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else ""
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def link_project_commits(
|
||||||
|
project_name: str,
|
||||||
|
limit: int = 20,
|
||||||
|
since_ts: int | None = None,
|
||||||
|
until_ts: int | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return recent commits for a project, optionally filtered by time window."""
|
||||||
|
proj = next((p for p in config.MANAGED_PROJECTS if p["name"] == project_name), None)
|
||||||
|
if not proj:
|
||||||
|
return []
|
||||||
|
path = proj["path"]
|
||||||
|
|
||||||
|
fmt = "%H|%an|%ct|%s"
|
||||||
|
args = ["log", f"-{limit}", f"--format={fmt}"]
|
||||||
|
if since_ts:
|
||||||
|
args.append(f"--since=@{since_ts}")
|
||||||
|
if until_ts:
|
||||||
|
args.append(f"--until=@{until_ts}")
|
||||||
|
|
||||||
|
out = _run_git(path, args)
|
||||||
|
if not out:
|
||||||
|
return []
|
||||||
|
|
||||||
|
commits: list[dict[str, Any]] = []
|
||||||
|
for line in out.splitlines():
|
||||||
|
parts = line.split("|", 3)
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
full_hash, author, ts, subject = parts
|
||||||
|
commits.append({
|
||||||
|
"hash": full_hash,
|
||||||
|
"short": full_hash[:7],
|
||||||
|
"author": author,
|
||||||
|
"ts": int(ts) if ts.isdigit() else 0,
|
||||||
|
"subject": subject,
|
||||||
|
"gitea_url": f"http://localhost:3000/admin/{proj['gitea_repo']}/commit/{full_hash}",
|
||||||
|
})
|
||||||
|
return commits
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_files_changed(
|
||||||
|
project_name: str,
|
||||||
|
since_ts: int | None,
|
||||||
|
until_ts: int | None,
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
"""Return {commit_short: [file1, file2, ...]} for commits in time window."""
|
||||||
|
proj = next((p for p in config.MANAGED_PROJECTS if p["name"] == project_name), None)
|
||||||
|
if not proj or not since_ts or not until_ts:
|
||||||
|
return {}
|
||||||
|
path = proj["path"]
|
||||||
|
|
||||||
|
args = [
|
||||||
|
"log",
|
||||||
|
f"--since=@{since_ts}",
|
||||||
|
f"--until=@{until_ts}",
|
||||||
|
"--format=%h",
|
||||||
|
"--name-only",
|
||||||
|
]
|
||||||
|
out = _run_git(path, args)
|
||||||
|
if not out:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
current_hash: str | None = None
|
||||||
|
for line in out.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if len(line) <= 10 and "/" not in line and "." not in line and not line.startswith(" "):
|
||||||
|
if line.isalnum():
|
||||||
|
current_hash = line
|
||||||
|
result.setdefault(current_hash, [])
|
||||||
|
continue
|
||||||
|
if current_hash:
|
||||||
|
result[current_hash].append(line)
|
||||||
|
return result
|
||||||
@@ -342,6 +342,8 @@
|
|||||||
<a href="/">Dashboard</a>
|
<a href="/">Dashboard</a>
|
||||||
<a href="/projects">Projects</a>
|
<a href="/projects">Projects</a>
|
||||||
<a href="/tasks">Tasks</a>
|
<a href="/tasks">Tasks</a>
|
||||||
|
<a href="/timeline">Timeline</a>
|
||||||
|
<a href="/docs">Docs</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="mt-20">📦 Project Status</h2>
|
<h2 class="mt-20">📦 Project Overview</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
81
templates/doc_view.html
Normal file
81
templates/doc_view.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }} - Docs{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p class="text-small text-muted">
|
||||||
|
<a href="/docs">Docs</a> > {{ rel_path }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>📄 {{ title }}</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
{% if rendered_html %}
|
||||||
|
<div class="markdown-body">
|
||||||
|
{{ rendered_html|safe }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<pre>{{ raw }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-20">
|
||||||
|
<a href="/docs" class="btn">← Back to Docs</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 {
|
||||||
|
color: #fff;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
.markdown-body h1 { font-size: 22px; }
|
||||||
|
.markdown-body h2 { font-size: 18px; }
|
||||||
|
.markdown-body h3 { font-size: 16px; }
|
||||||
|
.markdown-body p { margin: 10px 0; }
|
||||||
|
.markdown-body code {
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ff9e4a;
|
||||||
|
}
|
||||||
|
.markdown-body pre {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.markdown-body pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.markdown-body ul, .markdown-body ol {
|
||||||
|
margin-left: 25px;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.markdown-body table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.markdown-body th, .markdown-body td {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
}
|
||||||
|
.markdown-body th { background: #333; }
|
||||||
|
.markdown-body blockquote {
|
||||||
|
border-left: 3px solid #4a9eff;
|
||||||
|
padding-left: 15px;
|
||||||
|
margin: 10px 0;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.markdown-body a { color: #4a9eff; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
44
templates/docs.html
Normal file
44
templates/docs.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Docs - Hermes{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>📚 Documentation Center</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p class="text-muted">{{ docs|length }} documents across all docs directories</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
{% if docs %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Modified</th>
|
||||||
|
<th>Snippet</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in docs %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/docs/{{ d.rel_path }}">{{ d.title }}</a></td>
|
||||||
|
<td class="text-small text-muted">{{ d.rel_path }}</td>
|
||||||
|
<td class="text-small">{{ d.size }} B</td>
|
||||||
|
<td class="text-small">{{ d.mtime|int|dt }}</td>
|
||||||
|
<td class="text-small text-muted">{{ d.snippet[:80] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No documents found</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-20">
|
||||||
|
<a href="/" class="btn">← Back to Dashboard</a>
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -54,14 +54,92 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-20">
|
<div class="card mt-20">
|
||||||
<h3>Recent Commits (last 10)</h3>
|
<h3>📋 Task Timeline ({{ tasks|length }} tasks)</h3>
|
||||||
{% if project.recent_commits %}
|
{% if tasks %}
|
||||||
<pre>{% for commit in project.recent_commits %}{{ commit }}
|
<table>
|
||||||
{% endfor %}</pre>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Outcome</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in tasks %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-small"><a href="/tasks/{{ t.task_id }}">{{ t.task_id }}</a></td>
|
||||||
|
<td>{{ t.title[:80] }}</td>
|
||||||
|
<td><span class="status-badge status-{{ t.status }}">{{ t.status }}</span></td>
|
||||||
|
<td class="text-small">{% if t.created_at %}{{ t.created_at|int|dt }}{% endif %}</td>
|
||||||
|
<td class="text-small">
|
||||||
|
{% if t.duration_s %}
|
||||||
|
{% if t.duration_s < 60 %}{{ t.duration_s }}s
|
||||||
|
{% elif t.duration_s < 3600 %}{{ (t.duration_s / 60)|round(1) }}m
|
||||||
|
{% else %}{{ (t.duration_s / 3600)|round(1) }}h{% endif %}
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-small text-muted">{{ t.outcome_summary[:80] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No tasks linked to this project</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-20">
|
||||||
|
<h3>🔀 Recent Commits ({{ commits|length }})</h3>
|
||||||
|
{% if commits %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Hash</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in commits %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-small"><a href="{{ c.gitea_url }}" target="_blank">{{ c.short }}</a></td>
|
||||||
|
<td class="text-small">{{ c.author }}</td>
|
||||||
|
<td class="text-small">{{ c.ts|int|dt }}</td>
|
||||||
|
<td>{{ c.subject }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No commits found</p>
|
<p class="text-muted">No commits found</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if task_files %}
|
||||||
|
<div class="card mt-20">
|
||||||
|
<h3>📁 Files Changed (per task time window)</h3>
|
||||||
|
{% for task_id, commit_files in task_files.items() %}
|
||||||
|
<div style="margin-bottom:15px;">
|
||||||
|
<h4 style="margin-bottom:5px;">
|
||||||
|
<a href="/tasks/{{ task_id }}">{{ task_id }}</a>
|
||||||
|
</h4>
|
||||||
|
{% for chash, files in commit_files.items() %}
|
||||||
|
<div class="text-small" style="margin-left:15px;">
|
||||||
|
<strong>{{ chash }}</strong>: {{ files|length }} file(s)
|
||||||
|
<ul style="margin-left:20px;">
|
||||||
|
{% for f in files[:10] %}<li>{{ f }}</li>{% endfor %}
|
||||||
|
{% if files|length > 10 %}<li class="text-muted">... {{ files|length - 10 }} more</li>{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p class="mt-20">
|
<p class="mt-20">
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
{% set task = data.task %}
|
{% set task = data.task %}
|
||||||
<h2>📋 Task #{{ task.id }}: {{ task.title }}</h2>
|
<h2>📋 Task #{{ task.id }}: {{ task.title }}</h2>
|
||||||
|
|
||||||
|
{% if project_name %}
|
||||||
|
<div class="card mb-20">
|
||||||
|
<p><strong>📦 Project:</strong> <a href="/projects/{{ project_name }}">{{ project_name }}</a></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -44,6 +50,41 @@
|
|||||||
<pre>{{ task.result }}</pre>
|
<pre>{{ task.result }}</pre>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if commits %}
|
||||||
|
<div class="card">
|
||||||
|
<h3>🔀 Commits During Task ({{ commits|length }})</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Hash</th><th>Time</th><th>Subject</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in commits %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-small"><a href="{{ c.gitea_url }}" target="_blank">{{ c.short }}</a></td>
|
||||||
|
<td class="text-small">{{ c.ts|int|dt }}</td>
|
||||||
|
<td>{{ c.subject }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if files_changed %}
|
||||||
|
<div class="card">
|
||||||
|
<h3>📁 Files Changed</h3>
|
||||||
|
{% for chash, files in files_changed.items() %}
|
||||||
|
<div class="text-small" style="margin-bottom:10px;">
|
||||||
|
<strong>{{ chash }}</strong>:
|
||||||
|
<ul style="margin-left:20px;">
|
||||||
|
{% for f in files[:15] %}<li>{{ f }}</li>{% endfor %}
|
||||||
|
{% if files|length > 15 %}<li class="text-muted">... {{ files|length - 15 }} more</li>{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
95
templates/timeline.html
Normal file
95
templates/timeline.html
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Timeline - Hermes{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>🕐 Full Task Timeline</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Summary</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Total:</strong> {{ stats.total }} |
|
||||||
|
<strong>Done:</strong> <span class="status-badge status-done">{{ stats.done }}</span> |
|
||||||
|
<strong>Blocked:</strong> <span class="status-badge status-blocked">{{ stats.blocked }}</span> |
|
||||||
|
<strong>Running:</strong> <span class="status-badge status-running">{{ stats.running }}</span> |
|
||||||
|
<strong>Ready:</strong> <span class="status-badge status-ready">{{ stats.ready }}</span> |
|
||||||
|
<strong>Failed:</strong> <span class="status-badge status-failed">{{ stats.failed }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for pname, tasks in by_project.items() %}
|
||||||
|
{% if tasks %}
|
||||||
|
<div class="card mt-20">
|
||||||
|
<h3>
|
||||||
|
{% if pname != 'unassigned' %}
|
||||||
|
<a href="/projects/{{ pname }}" style="color: {{ proj_colors.get(pname, '#4a9eff') }};">📦 {{ pname }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">📦 {{ pname }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-small text-muted">({{ tasks|length }} tasks)</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{% set ns = namespace(min_ts=99999999999, max_ts=0) %}
|
||||||
|
{% for t in tasks %}
|
||||||
|
{% if t.created_at and t.created_at < ns.min_ts %}{% set ns.min_ts = t.created_at %}{% endif %}
|
||||||
|
{% if t.completed_at and t.completed_at > ns.max_ts %}{% set ns.max_ts = t.completed_at %}{% endif %}
|
||||||
|
{% if t.created_at and not t.completed_at and t.created_at > ns.max_ts %}{% set ns.max_ts = t.created_at %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% set span = (ns.max_ts - ns.min_ts) if ns.max_ts > ns.min_ts else 1 %}
|
||||||
|
|
||||||
|
{% set status_color = {'done': '#4aff4a', 'blocked': '#ff4a4a', 'running': '#4a9eff', 'ready': '#aaaaaa', 'failed': '#ff9e4a'} %}
|
||||||
|
<div style="background:#1a1a1a;padding:10px;border-radius:3px;margin-bottom:15px;">
|
||||||
|
{% for t in tasks[:20] %}
|
||||||
|
{% if t.created_at %}
|
||||||
|
{% set left = ((t.created_at - ns.min_ts) / span * 100)|round(2) %}
|
||||||
|
{% set width = (((t.completed_at or t.created_at) - t.created_at) / span * 100)|round(2) %}
|
||||||
|
{% if width < 0.5 %}{% set width = 0.5 %}{% endif %}
|
||||||
|
{% set bar_color = status_color.get(t.status, '#888') %}
|
||||||
|
<div style="position:relative;height:18px;margin-bottom:2px;">
|
||||||
|
<a href="/tasks/{{ t.task_id }}" style="display:block;position:absolute;left:{{ left }}%;width:{{ width }}%;height:100%;background:{{ bar_color }};opacity:0.75;border-radius:2px;text-decoration:none;" title="[{{ t.status }}] {{ t.title[:60] }}"></a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="text-small text-muted" style="margin-top:5px;">
|
||||||
|
{% if ns.min_ts < 99999999999 %}{{ ns.min_ts|int|dt }} → {{ ns.max_ts|int|dt }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Outcome</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in tasks %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-small"><a href="/tasks/{{ t.task_id }}">{{ t.task_id }}</a></td>
|
||||||
|
<td>{{ t.title[:80] }}</td>
|
||||||
|
<td><span class="status-badge status-{{ t.status }}">{{ t.status }}</span></td>
|
||||||
|
<td class="text-small">{% if t.created_at %}{{ t.created_at|int|dt }}{% endif %}</td>
|
||||||
|
<td class="text-small">
|
||||||
|
{% if t.duration_s %}
|
||||||
|
{% if t.duration_s < 60 %}{{ t.duration_s }}s
|
||||||
|
{% elif t.duration_s < 3600 %}{{ (t.duration_s / 60)|round(1) }}m
|
||||||
|
{% else %}{{ (t.duration_s / 3600)|round(1) }}h{% endif %}
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-small text-muted">{{ t.outcome_summary[:80] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<p class="mt-20">
|
||||||
|
<a href="/" class="btn">← Back to Dashboard</a>
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user