From 1e5e30ffcb229fa1c76d58c081f81bc114fc0c71 Mon Sep 17 00:00:00 2001 From: hermes-dashboard Date: Mon, 17 Aug 2026 19:51:51 +0800 Subject: [PATCH] initial: hermes-dashboard snapshot (post overview + git integration) --- .gitignore | 13 ++ app.py | 123 ++++++++++++ config.py | 201 +++++++++++++++++++ extensions.py | 54 ++++++ requirements.txt | 3 + routes/__init__.py | 6 + routes/api.py | 313 ++++++++++++++++++++++++++++++ routes/pages.py | 99 ++++++++++ scripts/deploy.sh | 81 ++++++++ security.py | 50 +++++ services/__init__.py | 1 + services/gitea.py | 29 +++ services/kanban.py | 139 ++++++++++++++ services/projects.py | 161 ++++++++++++++++ services/worker.py | 78 ++++++++ static/js/main.js | 176 +++++++++++++++++ templates/base.html | 352 ++++++++++++++++++++++++++++++++++ templates/dashboard.html | 151 +++++++++++++++ templates/project_detail.html | 70 +++++++ templates/projects.html | 46 +++++ templates/task_detail.html | 144 ++++++++++++++ templates/tasks.html | 43 +++++ 22 files changed, 2333 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 config.py create mode 100644 extensions.py create mode 100644 requirements.txt create mode 100644 routes/__init__.py create mode 100644 routes/api.py create mode 100644 routes/pages.py create mode 100755 scripts/deploy.sh create mode 100644 security.py create mode 100644 services/__init__.py create mode 100644 services/gitea.py create mode 100644 services/kanban.py create mode 100644 services/projects.py create mode 100644 services/worker.py create mode 100644 static/js/main.js create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/project_detail.html create mode 100644 templates/projects.html create mode 100644 templates/task_detail.html create mode 100644 templates/tasks.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24b31e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +*.pyo +*.db +.env +*.pid +*.log +venv/ +node_modules/ +*.egg-info/ +dist/ +build/ +.omo/ diff --git a/app.py b/app.py new file mode 100644 index 0000000..89b95de --- /dev/null +++ b/app.py @@ -0,0 +1,123 @@ +"""Flask application factory for hermes-dashboard. + +Read-only monitoring dashboard for omo + Gitea + kanban. +""" + +import logging +import os +import time +from datetime import datetime +from zoneinfo import ZoneInfo + +from flask import Flask, g, has_request_context, jsonify, request +import uuid + +import config +import security +from extensions import close_db +from routes import api_bp, pages_bp + +# Logging setup +class _RequestIdFilter(logging.Filter): + """Inject request_id into LogRecord.""" + + def filter(self, record: logging.LogRecord) -> bool: + if has_request_context(): + record.request_id = getattr(g, "request_id", "-") + else: + record.request_id = "-" + return True + + +_log_format = "%(asctime)s [%(process)d] [%(request_id)s] %(levelname)s: %(message)s" +_handler = logging.StreamHandler() +_handler.setFormatter(logging.Formatter(_log_format)) +_handler.addFilter(_RequestIdFilter()) + +_root = logging.getLogger() +_root.setLevel(logging.INFO) +if not any(isinstance(h, logging.StreamHandler) and h.formatter for h in _root.handlers): + _root.addHandler(_handler) + +logger = logging.getLogger(__name__) + + +def _format_dt(ts: int | str | None) -> str: + """Format unix epoch as Asia/Shanghai local time.""" + if not ts: + return "" + try: + epoch = int(ts) + dt = datetime.fromtimestamp(epoch, tz=ZoneInfo("Asia/Shanghai")) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError, OSError): + return "" + + +def _format_rel(ts: int | str | None) -> str: + """Format unix epoch as relative time (e.g., '3m ago').""" + if not ts: + return "" + try: + epoch = int(ts) + now = int(time.time()) + diff = now - epoch + if diff < 0: + return "in the future" + if diff < 60: + return f"{diff}s ago" + if diff < 3600: + return f"{diff // 60}m ago" + if diff < 86400: + return f"{diff // 3600}h ago" + return f"{diff // 86400}d ago" + except (ValueError, TypeError, OSError): + return "" + + +def create_app() -> Flask: + """Create and configure the Flask application.""" + app = Flask( + __name__, + template_folder=os.path.join(config.BASE_DIR, "templates"), + static_folder=os.path.join(config.BASE_DIR, "static"), + ) + app.secret_key = config.SECRET_KEY + + # Jinja filters + app.jinja_env.filters["dt"] = _format_dt + app.jinja_env.filters["rel"] = _format_rel + + @app.before_request + def _assign_request_id() -> None: + g.request_id = uuid.uuid4().hex[:8] + + @app.after_request + def _log_request(response): + logger.info( + "%s %s -> %s (%s)", + request.method, + request.path, + response.status_code, + g.request_id, + ) + return response + + @app.route("/healthz") + def healthz(): + return jsonify({"status": "ok"}), 200 + + app.teardown_appcontext(close_db) + security.init_app(app) + + app.register_blueprint(api_bp) + app.register_blueprint(pages_bp) + + return app + + +app = create_app() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=config.PORT, debug=False, threaded=True) diff --git a/config.py b/config.py new file mode 100644 index 0000000..e21eacd --- /dev/null +++ b/config.py @@ -0,0 +1,201 @@ +"""Configuration for hermes-dashboard. + +All values can be overridden via environment variables (HERMES_DASHBOARD_* prefix). +""" + +import os +from typing import TypedDict + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Database paths +KANBAN_DB_PATH = os.environ.get( + "HERMES_DASHBOARD_KANBAN_DB", + "/home/yi/.hermes/kanban.db" +) + +# Gitea configuration +GITEA_API_URL = os.environ.get("HERMES_DASHBOARD_GITEA_URL", "http://localhost:3000/api/v1") +GITEA_TOKEN_PATH = os.environ.get( + "HERMES_DASHBOARD_GITEA_TOKEN_PATH", + "/home/yi/gitea/.admin_token" +) + +# Worker SOUL.md path +SOUL_MD_PATH = os.environ.get( + "HERMES_DASHBOARD_SOUL_MD", + "/home/yi/.hermes/profiles/omo-pm/SOUL.md" +) + +# Config file paths +OPENCODE_CONFIG_PATH = "/home/yi/.config/opencode/opencode.jsonc" +OMO_CONFIG_PATH = "/home/yi/.omo/omo.jsonc" + +# Server port +PORT = int(os.environ.get("HERMES_DASHBOARD_PORT", "8092")) + +# Flask secret key +SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "dev-hermes-dashboard-secret-change-me") + + +class ProjectConfig(TypedDict): + """Type definition for managed project configuration.""" + name: str + path: str + gitea_repo: str + + +# Managed projects to monitor +MANAGED_PROJECTS: list[ProjectConfig] = [ + {"name": "blog-app", "path": "/home/yi/opencode-blog-showcase/blog-app", "gitea_repo": "blog-app"}, + {"name": "novel-app", "path": "/home/yi/novel-workspace/novel-app", "gitea_repo": "novel-app"}, + {"name": "hermes-dashboard", "path": "/home/yi/hermes-dashboard", "gitea_repo": "hermes-dashboard"}, + {"name": "omo-openagent", "path": "/home/yi/.cache/opencode/packages/oh-my-openagent@latest", "gitea_repo": "omo-openagent-config"}, + {"name": "omo-config", "path": "/home/yi/.config/opencode", "gitea_repo": "omo-config"}, +] + + +# Dispatch templates for kanban task creation +DISPATCH_TEMPLATES: list[dict] = [ + { + "name": "fix-csrf-blog-app", + "description": "修复 blog-app 的 CSRF token 验证问题", + "default_body": """## 背景 +blog-app 在 POST 请求时 CSRF token 验证失败,导致用户无法提交表单。 +需要检查 token 生成和验证逻辑是否一致。 + +## 任务 +1. 检查 `security.py` 中 `validate_csrf()` 的实现 +2. 确认前端表单是否正确传递 `csrf_token` 字段 +3. 检查 session 配置是否正确 +4. 添加单元测试覆盖 CSRF 场景 + +## 验收 +- [ ] POST 请求能正常通过 CSRF 验证 +- [ ] 无效 token 返回 403 +- [ ] 测试覆盖率 > 80% +""", + }, + { + "name": "refactor-module", + "description": "重构指定模块,提升代码质量和可维护性", + "default_body": """## 背景 +目标模块代码复杂度高,需要重构以降低维护成本。 +当前存在重复逻辑、过长函数、缺少类型注解等问题。 + +## 任务 +1. 分析模块依赖关系,画出调用图 +2. 提取公共逻辑到 utils/helper 模块 +3. 添加完整的类型注解和 docstring +4. 拆分超过 250 行的函数 +5. 确保所有现有测试通过 + +## 验收 +- [ ] 无函数超过 250 行 +- [ ] 所有函数有类型注解 +- [ ] 现有测试全部通过 +- [ ] 代码审查通过 +""", + }, + { + "name": "research-npm-package", + "description": "调研 npm 包的功能、API 和集成方案", + "default_body": """## 背景 +需要评估目标 npm 包是否适合集成到当前项目。 +重点关注 API 设计、bundle size、维护状态。 + +## 任务 +1. 查阅官方文档,总结核心 API +2. 检查 GitHub 仓库:star 数、最近提交、issue 处理速度 +3. 对比同类替代方案(至少 2 个) +4. 写一个最小集成 demo +5. 记录潜在的 breaking changes 风险 + +## 验收 +- [ ] 输出调研报告(Markdown 格式) +- [ ] 包含 API 示例代码 +- [ ] 明确推荐/不推荐及理由 +- [ ] demo 可运行 +""", + }, + { + "name": "fix-omo-task", + "description": "修复 omo-pm worker 任务执行异常", + "default_body": """## 背景 +omo-pm 在执行某类任务时出现异常退出或结果不符合预期。 +需要排查 worker 日志和任务状态。 + +## 任务 +1. 查看 `~/.hermes/logs/` 下相关日志 +2. 检查 kanban.db 中失败任务的 status 和 result 字段 +3. 复现问题场景 +4. 定位根因并修复 +5. 添加回归测试 + +## 验收 +- [ ] 问题任务能正常完成 +- [ ] 日志无异常堆栈 +- [ ] 回归测试通过 +""", + }, + { + "name": "daily-blog-post", + "description": "生成并发布每日博客文章", + "default_body": """## 背景 +每日博客更新任务,需要生成一篇高质量文章并发布到 blog-app。 + +## 任务 +1. 从主题列表选择一个话题 +2. 撰写文章(800-1500 字) +3. 生成配图(如有需要) +4. 通过 blog-app API 发布 +5. 验证文章页面可访问 + +## 验收 +- [ ] 文章已发布且可访问 +- [ ] 无错别字和格式问题 +- [ ] 标签分类正确 +""", + }, + { + "name": "security-audit", + "description": "对项目进行安全审计,检查常见漏洞", + "default_body": """## 背景 +定期安全审计,确保项目没有明显的安全漏洞。 +重点关注 OWASP Top 10 中的常见风险。 + +## 任务 +1. 检查 SQL 注入风险(参数化查询) +2. 检查 XSS 风险(输出编码) +3. 检查认证/授权逻辑 +4. 检查敏感信息泄露(日志、错误信息) +5. 检查依赖包已知漏洞 +6. 输出安全审计报告 + +## 验收 +- [ ] 审计报告包含发现和建议 +- [ ] 高危问题已修复 +- [ ] 中低危问题有修复计划 +""", + }, + { + "name": "novel-chapter-draft", + "description": "起草小说新章节", + "default_body": """## 背景 +继续小说创作,完成新章节的初稿。 +需要保持与前文风格一致,推进剧情发展。 + +## 任务 +1. 回顾上一章内容和伏笔 +2. 规划本章剧情节点 +3. 撰写初稿(3000-5000 字) +4. 检查人物对话是否符合人设 +5. 标记需要后续呼应的伏笔 + +## 验收 +- [ ] 初稿完成 +- [ ] 字数达标 +- [ ] 剧情连贯性检查通过 +""", + }, +] diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..1028a26 --- /dev/null +++ b/extensions.py @@ -0,0 +1,54 @@ +"""Flask extensions: per-request database connection and Gitea HTTP session. + +These helpers depend on Flask's `g` and can only be used within app/request context. +""" + +import logging +import sqlite3 +from typing import Any + +import requests +from flask import g + +import config + +logger = logging.getLogger(__name__) + + +def get_db() -> sqlite3.Connection: + """Return per-request SQLite connection with Row factory.""" + if "db" not in g: + try: + conn = sqlite3.connect(config.KANBAN_DB_PATH) + conn.row_factory = sqlite3.Row + g.db = conn + except sqlite3.Error as exc: + logger.error("Failed to connect to kanban DB: %s", exc) + raise + return g.db + + +def close_db(exception: BaseException | None = None) -> None: + """Teardown handler registered via app.teardown_appcontext.""" + conn = g.pop("db", None) + if conn is not None: + conn.close() + + +def get_gitea_session() -> requests.Session: + """Return per-request requests.Session for Gitea API calls.""" + if "gitea_session" not in g: + session = requests.Session() + session.headers.update({"Accept": "application/json"}) + + # Try to load admin token + try: + with open(config.GITEA_TOKEN_PATH, "r", encoding="utf-8") as f: + token = f.read().strip() + if token: + session.headers["Authorization"] = f"token {token}" + except (FileNotFoundError, PermissionError, OSError) as exc: + logger.debug("Gitea token not available: %s", exc) + + g.gitea_session = session + return g.gitea_session diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..806517f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +psutil>=7.0 +requests>=2.32 diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..6f1b35c --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1,6 @@ +"""Routes package.""" + +from routes.api import bp as api_bp +from routes.pages import bp as pages_bp + +__all__ = ["api_bp", "pages_bp"] diff --git a/routes/api.py b/routes/api.py new file mode 100644 index 0000000..e01a2e1 --- /dev/null +++ b/routes/api.py @@ -0,0 +1,313 @@ +"""JSON API blueprint: /api/* endpoints.""" + +import json +import logging +import re +import subprocess +import time +from typing import Any + +from flask import Blueprint, jsonify, request +from werkzeug.exceptions import HTTPException + +import config +from security import validate_csrf +from services import gitea, kanban, projects, worker + +logger = logging.getLogger(__name__) + +bp = Blueprint("api", __name__, url_prefix="/api") + + +@bp.route("/workers") +def api_workers() -> tuple[Any, int]: + """Worker status: current task, processes, soul excerpt.""" + try: + data = worker.get_worker_status() + return jsonify(data), 200 + except Exception as exc: + logger.exception("api_workers failed") + return jsonify({"error": str(exc)}), 200 + + +@bp.route("/tasks") +def api_tasks() -> tuple[Any, int]: + """Tasks list with optional status filter.""" + try: + status = request.args.get("status") + data = kanban.get_tasks(status) + return jsonify(data), 200 + except Exception as exc: + logger.exception("api_tasks failed") + return jsonify({"error": str(exc), "tasks": [], "counts": {}}), 200 + + +@bp.route("/tasks/recent") +def api_tasks_recent() -> tuple[Any, int]: + """Recent tasks list (last 5).""" + try: + tasks = kanban.get_recent_tasks(5) + return jsonify({"tasks": tasks}), 200 + except Exception as exc: + logger.exception("api_tasks_recent failed") + return jsonify({"error": str(exc), "tasks": []}), 200 + + +@bp.route("/tasks/") +def api_task_detail(task_id: str) -> tuple[Any, int]: + """Full task detail with events, comments, runs, attachments.""" + try: + data = kanban.get_task_detail(task_id) + return jsonify(data), 200 + except Exception as exc: + logger.exception("api_task_detail failed") + return jsonify({"error": str(exc)}), 200 + + +@bp.route("/tasks//cancel", methods=["POST"]) +def api_task_cancel(task_id: str) -> tuple[Any, int]: + """Cancel a running task by blocking it.""" + # NOTE: LAN-only dashboard, no auth. Add basic auth before exposing publicly. + try: + validate_csrf() + + db = kanban.get_db() + row = db.execute("SELECT status FROM tasks WHERE id = ?", (task_id,)).fetchone() + if not row: + return jsonify({"error": "task not found"}), 404 + + status = row["status"] + if status != "running": + return jsonify({"error": f"task is {status}, only running tasks can be cancelled"}), 409 + + try: + subprocess.run( + ["hermes", "kanban", "block", task_id, "user requested cancel from dashboard", "--kind", "transient"], + check=True, + capture_output=True, + text=True, + timeout=15, + ) + except subprocess.CalledProcessError as exc: + return jsonify({"error": "hermes kanban block failed", "stderr": exc.stderr[:500]}), 500 + except subprocess.TimeoutExpired: + return jsonify({"error": "hermes timed out"}), 500 + + return jsonify({"ok": True, "task_id": task_id, "status": "blocked"}), 200 + except HTTPException: + raise + except Exception as exc: + logger.exception("api_task_cancel failed") + return jsonify({"error": str(exc)}), 500 + + +@bp.route("/tasks//comment", methods=["POST"]) +def api_task_comment(task_id: str) -> tuple[Any, int]: + """Add a comment to a task.""" + # NOTE: LAN-only dashboard, no auth. Add basic auth before exposing publicly. + try: + validate_csrf() + + body = request.get_json(silent=True) or {} + text = (body.get("text") or "").strip() + if not text: + return jsonify({"error": "text is required and must be non-empty"}), 400 + + db = kanban.get_db() + row = db.execute("SELECT id FROM tasks WHERE id = ?", (task_id,)).fetchone() + if not row: + return jsonify({"error": "task not found"}), 404 + + try: + subprocess.run( + ["hermes", "kanban", "comment", task_id, text, "--author", "dashboard"], + check=True, + capture_output=True, + text=True, + timeout=15, + ) + except subprocess.CalledProcessError as exc: + return jsonify({"error": "hermes kanban comment failed", "stderr": exc.stderr[:500]}), 500 + except subprocess.TimeoutExpired: + return jsonify({"error": "hermes timed out"}), 500 + + return jsonify({"ok": True, "task_id": task_id}), 200 + except HTTPException: + raise + except Exception as exc: + logger.exception("api_task_comment failed") + return jsonify({"error": str(exc)}), 500 + + +@bp.route("/projects") +def api_projects() -> tuple[Any, int]: + """All managed projects with git info.""" + try: + data = projects.get_all_projects() + return jsonify({"projects": data}), 200 + except Exception as exc: + logger.exception("api_projects failed") + return jsonify({"error": str(exc), "projects": []}), 200 + + +@bp.route("/projects/") +def api_project_detail(name: str) -> tuple[Any, int]: + """Detailed project info with recent commits and diff stat.""" + try: + data = projects.get_project_detail(name) + return jsonify(data), 200 + except Exception as exc: + logger.exception("api_project_detail failed") + return jsonify({"error": str(exc)}), 200 + + +@bp.route("/processes") +def api_processes() -> tuple[Any, int]: + """Running opencode worker processes.""" + try: + processes = worker.get_running_processes() + return jsonify({"processes": processes}), 200 + except Exception as exc: + logger.exception("api_processes failed") + return jsonify({"error": str(exc), "processes": []}), 200 + + +@bp.route("/soul") +def api_soul() -> tuple[Any, int]: + """First 2000 chars of SOUL.md.""" + try: + data = worker.get_soul_full() + return jsonify(data), 200 + except Exception as exc: + logger.exception("api_soul failed") + return jsonify({"error": str(exc), "content": ""}), 200 + + +@bp.route("/configs") +def api_configs() -> tuple[Any, int]: + """Raw text of opencode.jsonc and omo.jsonc.""" + result: dict[str, str] = {"opencode_jsonc": "", "omo_jsonc": ""} + try: + with open(config.OPENCODE_CONFIG_PATH, "r", encoding="utf-8") as f: + result["opencode_jsonc"] = f.read() + except (FileNotFoundError, PermissionError, OSError) as exc: + result["opencode_jsonc"] = f"(not available: {exc})" + + try: + with open(config.OMO_CONFIG_PATH, "r", encoding="utf-8") as f: + result["omo_jsonc"] = f.read() + except (FileNotFoundError, PermissionError, OSError) as exc: + result["omo_jsonc"] = f"(not available: {exc})" + + return jsonify(result), 200 + + +@bp.route("/gitea/repos") +def api_gitea_repos() -> tuple[Any, int]: + """Gitea repos list.""" + try: + data = gitea.get_repos() + return jsonify(data), 200 + except Exception as exc: + logger.exception("api_gitea_repos failed") + return jsonify({"error": str(exc), "repos": [], "available": False}), 200 + + +# NOTE: LAN-only dashboard, no auth. Add basic auth before exposing publicly. + + +@bp.route("/dispatch/templates") +def api_dispatch_templates() -> tuple[Any, int]: + """Available dispatch templates for task creation.""" + try: + return jsonify({"templates": config.DISPATCH_TEMPLATES}), 200 + except Exception as exc: + logger.exception("api_dispatch_templates failed") + return jsonify({"error": str(exc), "templates": []}), 200 + + +@bp.route("/dispatch", methods=["POST"]) +def api_dispatch() -> tuple[Any, int]: + """Create a new kanban task via hermes CLI.""" + try: + validate_csrf() + + body = request.get_json(silent=True) or {} + title = (body.get("title") or "").strip() + if not title: + return jsonify({"error": "title is required and must be non-empty"}), 400 + + task_body = body.get("body") or "" + max_runtime = body.get("max_runtime", 1800) + if not isinstance(max_runtime, int) or max_runtime < 60 or max_runtime > 7200: + return jsonify({"error": "max_runtime must be an integer between 60 and 7200"}), 400 + + assignee = body.get("assignee") or "omo-pm" + + try: + result = subprocess.run( + [ + "hermes", "kanban", "create", title, + "--body", task_body, + "--assignee", assignee, + "--max-runtime", str(max_runtime), + "--json", + ], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + except subprocess.CalledProcessError as exc: + return jsonify({"error": "hermes kanban create failed", "stderr": exc.stderr[:500]}), 500 + except subprocess.TimeoutExpired: + return jsonify({"error": "hermes timed out"}), 500 + + stdout = result.stdout + task_id = None + + try: + data = json.loads(stdout) + task_id = data.get("id") or (data.get("task") or {}).get("id") + except (json.JSONDecodeError, AttributeError): + pass + + if not task_id: + match = re.search(r"t_[0-9a-f]{8}", stdout) + if match: + task_id = match.group(0) + + if not task_id: + return jsonify({"error": "could not parse task id", "stdout": stdout[:500]}), 500 + + return jsonify({ + "task_id": task_id, + "status": "ready", + "created_at": int(time.time()), + }), 200 + except HTTPException: + raise + except Exception as exc: + logger.exception("api_dispatch failed") + return jsonify({"error": str(exc)}), 500 + + +@bp.errorhandler(403) +def api_forbidden(e): + return jsonify({"error": "CSRF token missing or invalid", "code": 403}), 403 + + +@bp.errorhandler(404) +def api_not_found(e): + return jsonify({"error": "not found", "code": 404}), 404 + + +@bp.errorhandler(405) +def api_method_not_allowed(e): + return jsonify({"error": "method not allowed", "code": 405}), 405 + + +@bp.errorhandler(500) +def api_internal_error(e): + logger.exception("api internal error") + return jsonify({"error": "internal server error", "code": 500}), 500 diff --git a/routes/pages.py b/routes/pages.py new file mode 100644 index 0000000..363f1c3 --- /dev/null +++ b/routes/pages.py @@ -0,0 +1,99 @@ +"""Pages blueprint: server-rendered HTML pages.""" + +import logging + +from flask import Blueprint, render_template, request + +from services import kanban, projects, 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.""" + 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) + + +@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.""" + 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) diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..9a4fc2c --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Restart the hermes-dashboard Flask server on 0.0.0.0:8092. +# +# Guarantees: +# - PID file (app.pid) tracks the live server. +# - Old server is killed before new one starts. +# - Health check loops on /healthz up to 30s (1s interval). +# - On failure: kill the failed new PID, print last 50 lines of log, exit 1. + +set -u +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PID_FILE="$APP_DIR/app.pid" +LOG_FILE="/tmp/hermes-dashboard.log" +HEALTHZ_URL="http://127.0.0.1:8092/healthz" +HEALTHZ_TIMEOUT_S=30 +PYTHON_BIN="/usr/bin/python3" + +cd "$APP_DIR" +export HOME=/home/yi + +log() { printf '[deploy] %s\n' "$*"; } +fail() { + log "ERROR: $*" + log "----- last 50 lines of $LOG_FILE -----" + tail -n 50 "$LOG_FILE" 2>/dev/null || log "(no log file)" + exit 1 +} + +# --- 1. Kill old server if PID is alive --- +OLD_PID="" +if [[ -f "$PID_FILE" ]]; then + OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)" +fi +if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then + log "stopping old server pid=$OLD_PID" + kill "$OLD_PID" 2>/dev/null || true + for _ in 1 2 3 4 5; do + if ! kill -0 "$OLD_PID" 2>/dev/null; then break; fi + sleep 1 + done + if kill -0 "$OLD_PID" 2>/dev/null; then + log "old pid $OLD_PID did not exit, sending SIGKILL" + kill -9 "$OLD_PID" 2>/dev/null || true + fi +fi + +# --- 2. Start new server, record PID --- +log "starting new server: $PYTHON_BIN app.py (port 8092)" +setsid nohup "$PYTHON_BIN" app.py > "$LOG_FILE" 2>&1 & +NEW_PID=$! +echo "$NEW_PID" > "$PID_FILE" +log "new pid=$NEW_PID" + +# --- 3. Health check loop --- +healthz_ok=0 +for ((i=1; i<=HEALTHZ_TIMEOUT_S; i++)); do + if ! kill -0 "$NEW_PID" 2>/dev/null; then + fail "server process $NEW_PID died during startup" + fi + code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 2 "$HEALTHZ_URL" 2>/dev/null || echo '000')" + if [[ "$code" == "200" ]]; then + healthz_ok=1 + log "healthz OK after ${i}s" + break + fi + sleep 1 +done + +if [[ "$healthz_ok" -ne 1 ]]; then + kill "$NEW_PID" 2>/dev/null || true + sleep 1 + kill -9 "$NEW_PID" 2>/dev/null || true + rm -f "$PID_FILE" + fail "deploy failed; health check timed out" +fi + +log "deploy succeeded pid=$NEW_PID" +curl -sS -o /dev/null -w 'local:%{http_code}\n' "$HEALTHZ_URL" diff --git a/security.py b/security.py new file mode 100644 index 0000000..0f6b50e --- /dev/null +++ b/security.py @@ -0,0 +1,50 @@ +"""CSRF protection (session-scoped token, no external dependency). + +Only enforced on POST/PUT/DELETE methods. All dashboard APIs are GET-only. +""" + +import hmac +import logging +import secrets + +from flask import abort, request, session + +logger = logging.getLogger(__name__) + + +def get_csrf_token() -> str: + """Return the per-session CSRF token, generating one on first use.""" + token = session.get("_csrf_token") + if not token: + token = secrets.token_hex(32) + session["_csrf_token"] = token + return token + + +def validate_csrf() -> None: + """Abort 403 if the CSRF token doesn't match the session token. + + Token may arrive via (checked in order): + - X-CSRF-Token request header (fetch API) + - csrf_token form field (classic HTML form POST) + - csrf_token JSON body field (fetch JSON POST) + """ + expected = session.get("_csrf_token") + provided = request.headers.get("X-CSRF-Token", "") + if not provided: + provided = request.form.get("csrf_token", "") + if not provided and request.is_json: + body = request.get_json(silent=True) or {} + provided = body.get("csrf_token", "") + if not expected or not provided or not hmac.compare_digest(expected, provided): + logger.warning( + "CSRF validation failed for %s from %s", + request.path, + request.remote_addr, + ) + abort(403) + + +def init_app(app) -> None: + """Expose csrf_token() to Jinja templates as a global.""" + app.jinja_env.globals["csrf_token"] = get_csrf_token diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..c7775ec --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +"""Services package.""" diff --git a/services/gitea.py b/services/gitea.py new file mode 100644 index 0000000..01e64fe --- /dev/null +++ b/services/gitea.py @@ -0,0 +1,29 @@ +"""Gitea API service — read-only queries against local Gitea instance.""" + +import logging +from typing import Any + +import requests + +import config +from extensions import get_gitea_session + +logger = logging.getLogger(__name__) + + +def get_repos() -> dict[str, Any]: + """Fetch all repos from Gitea. Returns {repos: [...], available: bool}.""" + try: + session = get_gitea_session() + resp = session.get( + f"{config.GITEA_API_URL}/repos/search", + params={"limit": 50, "uid": 0}, + timeout=3, + ) + resp.raise_for_status() + data = resp.json() + repos = data.get("data", []) + return {"repos": repos, "available": True} + except (requests.RequestException, ValueError, KeyError) as exc: + logger.debug("Gitea repos fetch failed: %s", exc) + return {"repos": [], "available": False, "error": str(exc)} diff --git a/services/kanban.py b/services/kanban.py new file mode 100644 index 0000000..5abe4ae --- /dev/null +++ b/services/kanban.py @@ -0,0 +1,139 @@ +"""Kanban database service — read-only queries against kanban.db.""" + +import logging +import sqlite3 +from typing import Any + +from extensions import get_db + +logger = logging.getLogger(__name__) + +STATUSES = ("ready", "running", "done", "blocked", "failed") + + +def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]: + """Convert sqlite3.Row to plain dict.""" + return dict(row) + + +def get_tasks(status: str | None = None) -> dict[str, Any]: + """Return tasks list and status counts.""" + try: + db = get_db() + + # Get counts per status + counts: dict[str, int] = {} + for s in STATUSES: + row = db.execute( + "SELECT COUNT(*) as cnt FROM tasks WHERE status = ?", (s,) + ).fetchone() + counts[s] = row["cnt"] if row else 0 + + # Get filtered or all tasks + if status and status in STATUSES: + rows = db.execute( + "SELECT * FROM tasks WHERE status = ? ORDER BY created_at DESC", + (status,), + ).fetchall() + else: + rows = db.execute( + "SELECT * FROM tasks ORDER BY created_at DESC" + ).fetchall() + + tasks = [_row_to_dict(r) for r in rows] + return {"tasks": tasks, "counts": counts} + except sqlite3.Error as exc: + logger.exception("get_tasks failed") + return {"tasks": [], "counts": {s: 0 for s in STATUSES}, "error": str(exc)} + + +def get_recent_tasks(limit: int = 5) -> list[dict]: + """Return the most recent tasks with outcome summary.""" + try: + db = get_db() + rows = db.execute( + "SELECT id, title, status, created_at, result FROM tasks " + "ORDER BY created_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [ + { + "id": r["id"], + "title": r["title"], + "status": r["status"], + "created_at": r["created_at"], + "outcome_summary": (r["result"] or "")[:200], + } + for r in rows + ] + except sqlite3.Error as exc: + logger.exception("get_recent_tasks failed") + return [] + + +def get_task_detail(task_id: str) -> dict[str, Any]: + """Return full task detail with events, comments, runs, attachments.""" + try: + db = get_db() + + task_row = db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + if not task_row: + return {"error": "task not found"} + + task = _row_to_dict(task_row) + + events_rows = db.execute( + "SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at", + (task_id,), + ).fetchall() + events = [_row_to_dict(r) for r in events_rows] + + comments_rows = db.execute( + "SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at", + (task_id,), + ).fetchall() + comments = [_row_to_dict(r) for r in comments_rows] + + runs_rows = db.execute( + "SELECT * FROM task_runs WHERE task_id = ? ORDER BY started_at DESC", + (task_id,), + ).fetchall() + runs = [_row_to_dict(r) for r in runs_rows] + + attachments_rows = db.execute( + "SELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at", + (task_id,), + ).fetchall() + attachments = [_row_to_dict(r) for r in attachments_rows] + + return { + "task": task, + "events": events, + "comments": comments, + "runs": runs, + "attachments": attachments, + } + except sqlite3.Error as exc: + logger.exception("get_task_detail failed") + return {"error": str(exc)} + + +def get_kanban_board() -> dict[str, Any]: + """Return tasks grouped by status for kanban board view.""" + try: + db = get_db() + board: dict[str, list[dict[str, Any]]] = {s: [] for s in STATUSES} + + rows = db.execute( + "SELECT * FROM tasks ORDER BY created_at DESC" + ).fetchall() + for row in rows: + d = _row_to_dict(row) + status = d.get("status", "ready") + if status in board: + board[status].append(d) + + return {"board": board} + except sqlite3.Error as exc: + logger.exception("get_kanban_board failed") + return {"board": {s: [] for s in STATUSES}, "error": str(exc)} diff --git a/services/projects.py b/services/projects.py new file mode 100644 index 0000000..16af252 --- /dev/null +++ b/services/projects.py @@ -0,0 +1,161 @@ +"""Projects service — git info for managed projects.""" + +import logging +import os +import subprocess +from typing import Any + +import config + +logger = logging.getLogger(__name__) + + +def _run_git(cwd: str, args: list[str]) -> str: + """Run a git command in the given directory. Returns stdout or empty string.""" + try: + result = subprocess.run( + ["git"] + args, + cwd=cwd, + capture_output=True, + text=True, + check=False, + timeout=5, + ) + if result.returncode != 0: + return "" + return result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + logger.debug("git %s in %s failed: %s", args, cwd, exc) + return "" + + +def _is_git_repo(path: str) -> bool: + """Check if path is a git repository.""" + return os.path.isdir(os.path.join(path, ".git")) + + +def get_project_info(proj: config.ProjectConfig) -> dict[str, Any]: + """Get git info for a single project.""" + path = proj["path"] + name = proj["name"] + + if not os.path.isdir(path): + return { + "name": name, + "path": path, + "gitea_repo": proj["gitea_repo"], + "status": "not-found", + "branch": "", + "commit_hash": "", + "commit_short": "", + "commit_msg": "", + "commit_time": "", + "commit_time_rel": "", + "dirty_count": 0, + "error": "directory not found", + } + + if not _is_git_repo(path): + return { + "name": name, + "path": path, + "gitea_repo": proj["gitea_repo"], + "status": "not-git-repo", + "branch": "", + "commit_hash": "", + "commit_short": "", + "commit_msg": "", + "commit_time": "", + "commit_time_rel": "", + "dirty_count": 0, + } + + branch = _run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]) + commit_hash = _run_git(path, ["rev-parse", "HEAD"]) + commit_short = commit_hash[:7] if commit_hash else "" + # Format: unix_epoch|subject + log_line = _run_git(path, ["log", "-1", "--format=%ct|%s"]) + commit_time = "" + commit_msg = "" + if "|" in log_line: + parts = log_line.split("|", 1) + commit_time = parts[0] + commit_msg = parts[1] + + # Dirty count + status_output = _run_git(path, ["status", "--porcelain"]) + dirty_count = len(status_output.splitlines()) if status_output else 0 + + return { + "name": name, + "path": path, + "gitea_repo": proj["gitea_repo"], + "status": "ok", + "branch": branch, + "commit_hash": commit_hash, + "commit_short": commit_short, + "commit_msg": commit_msg, + "commit_time": commit_time, + "commit_time_rel": "", # computed in template via |rel filter + "dirty_count": dirty_count, + } + + +def get_all_projects() -> list[dict[str, Any]]: + """Get info for all managed projects.""" + results: list[dict[str, Any]] = [] + for proj in config.MANAGED_PROJECTS: + try: + results.append(get_project_info(proj)) + except Exception as exc: + logger.exception("get_project_info failed for %s", proj["name"]) + results.append({ + "name": proj["name"], + "path": proj["path"], + "gitea_repo": proj["gitea_repo"], + "status": "error", + "error": str(exc), + }) + return results + + +def get_project_detail(name: str) -> dict[str, Any]: + """Get detailed info for a specific project.""" + proj = None + for p in config.MANAGED_PROJECTS: + if p["name"] == name: + proj = p + break + + if proj is None: + return {"error": "project not found"} + + info = get_project_info(proj) + path = proj["path"] + + if info.get("status") != "ok": + return info + + # Recent commits (last 10) + log_output = _run_git(path, ["log", "-10", "--oneline"]) + recent_commits = log_output.splitlines() if log_output else [] + + # Diff stat HEAD~1..HEAD + diff_stat = "" + commit_count_str = _run_git(path, ["rev-list", "--count", "HEAD"]) + try: + commit_count = int(commit_count_str) if commit_count_str else 0 + except ValueError: + commit_count = 0 + + if commit_count >= 2: + diff_stat = _run_git(path, ["diff", "--stat", "HEAD~1..HEAD"]) + + # git status --porcelain + status_porcelain = _run_git(path, ["status", "--porcelain"]) + + info["recent_commits"] = recent_commits + info["diff_stat"] = diff_stat + info["status_porcelain"] = status_porcelain + + return info diff --git a/services/worker.py b/services/worker.py new file mode 100644 index 0000000..6d168c2 --- /dev/null +++ b/services/worker.py @@ -0,0 +1,78 @@ +"""Worker service — process scanning and SOUL.md reading.""" + +import logging +import os +from typing import Any + +import psutil + +import config + +logger = logging.getLogger(__name__) + + +def get_running_processes() -> list[dict[str, Any]]: + """Scan for opencode worker processes (cmdline contains both 'opencode' and 'run').""" + processes: list[dict[str, Any]] = [] + try: + for proc in psutil.process_iter(["pid", "create_time", "cmdline"]): + try: + cmdline = proc.info.get("cmdline") or [] + cmdline_str = " ".join(cmdline) + if "opencode" in cmdline_str and "run" in cmdline_str: + # Truncate cmdline to 200 chars + if len(cmdline_str) > 200: + cmdline_str = cmdline_str[:200] + "..." + + # Calculate elapsed time + create_time = proc.info.get("create_time", 0) + processes.append({ + "pid": proc.info["pid"], + "cmdline": cmdline_str, + "create_time": create_time, + }) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + except Exception as exc: + logger.exception("get_running_processes failed") + return processes + + +def get_current_task() -> dict[str, Any] | None: + """Find the currently running task (status=running) from kanban DB.""" + try: + from extensions import get_db + db = get_db() + row = db.execute( + "SELECT * FROM tasks WHERE status = 'running' ORDER BY started_at DESC LIMIT 1" + ).fetchone() + if row: + return dict(row) + except Exception as exc: + logger.debug("get_current_task failed: %s", exc) + return None + + +def get_soul_excerpt(max_chars: int = 2000) -> str: + """Read first max_chars from SOUL.md.""" + try: + with open(config.SOUL_MD_PATH, "r", encoding="utf-8") as f: + content = f.read(max_chars) + return content + except (FileNotFoundError, PermissionError, OSError) as exc: + logger.debug("SOUL.md read failed: %s", exc) + return f"(SOUL.md not available: {exc})" + + +def get_worker_status() -> dict[str, Any]: + """Aggregate worker status: current task, processes, soul excerpt.""" + return { + "current_task": get_current_task(), + "processes": get_running_processes(), + "soul_excerpt": get_soul_excerpt(500), + } + + +def get_soul_full() -> dict[str, str]: + """Return first 2000 chars of SOUL.md.""" + return {"content": get_soul_excerpt(2000)} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..ee6820f --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,176 @@ +/** + * Hermes Dashboard - minimal ES module for auto-refresh. + * + * Updates elapsed-time spans every 10 seconds and refreshes + * the worker status and tasks sections on the dashboard. + */ + +function updateElapsedTimes() { + const now = Math.floor(Date.now() / 1000); + document.querySelectorAll('.elapsed-time[data-started]').forEach(el => { + const started = parseInt(el.dataset.started, 10); + if (!started) return; + const diff = now - started; + if (diff < 0) { + el.textContent = 'in the future'; + } else if (diff < 60) { + el.textContent = `${diff}s`; + } else if (diff < 3600) { + el.textContent = `${Math.floor(diff / 60)}m ${diff % 60}s`; + } else { + const h = Math.floor(diff / 3600); + const m = Math.floor((diff % 3600) / 60); + el.textContent = `${h}h ${m}m`; + } + }); +} + +async function refreshDashboard() { + try { + const resp = await fetch('/api/workers'); + if (!resp.ok) return; + const data = await resp.json(); + + const container = document.getElementById('worker-status'); + if (!container) return; + + if (data.current_task) { + const t = data.current_task; + let html = `

Current Task: #${t.id} - ${(t.title || '').substring(0, 60)}

`; + html += `

Worker PID: ${t.worker_pid || 'N/A'}

`; + if (t.started_at) { + html += `

Elapsed:

`; + } + container.innerHTML = html; + } else { + container.innerHTML = '

No task currently running

'; + } + + // Refresh process list + const procContainer = document.getElementById('process-list'); + if (procContainer && data.processes) { + if (data.processes.length > 0) { + let html = ''; + for (const proc of data.processes) { + html += ``; + html += ``; + html += ``; + } + html += '
PIDElapsedCommand
${proc.pid}${escapeHtml(proc.cmdline || '')}
'; + procContainer.innerHTML = html; + } else { + procContainer.innerHTML = '

No opencode workers running

'; + } + } + + updateElapsedTimes(); + } catch (_err) { + // Silently ignore refresh errors + } +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function getCsrfToken() { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.content : ''; +} + +async function postJson(url, payload) { + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken(), + }, + body: JSON.stringify(payload), + }); + const data = await resp.json().catch(() => ({})); + return { ok: resp.ok, status: resp.status, data }; +} + +async function submitDispatch(ev) { + ev.preventDefault(); + const form = ev.target; + const out = document.getElementById('dispatch-result'); + const title = form.title.value.trim(); + const body = form.body.value.trim(); + const maxRuntime = parseInt(form.max_runtime.value, 10) || 1800; + const assignee = (form.assignee.value || 'omo-pm').trim(); + + if (!title) { + out.textContent = 'Error: title is required'; + out.style.color = '#f88'; + return false; + } + + out.textContent = 'Dispatching...'; + out.style.color = '#888'; + + const { ok, status, data } = await postJson('/api/dispatch', { + title, + body, + max_runtime: maxRuntime, + assignee, + }); + + if (ok) { + out.textContent = `Dispatched: ${data.task_id} (status=${data.status})`; + out.style.color = '#8f8'; + form.reset(); + setTimeout(() => window.location.reload(), 1500); + } else { + out.textContent = `Error ${status}: ${data.error || 'unknown'}`; + out.style.color = '#f88'; + } + return false; +} + +function initDispatchForm() { + const form = document.getElementById('dispatch-form'); + if (!form) return; + form.addEventListener('submit', submitDispatch); + + const tplSelect = document.getElementById('dispatch-template'); + if (!tplSelect) return; + fetch('/api/dispatch/templates') + .then(r => r.json()) + .then(data => { + const templates = data.templates || []; + for (const t of templates) { + const opt = document.createElement('option'); + opt.value = t.name; + opt.textContent = t.name; + opt.dataset.body = t.default_body || ''; + opt.dataset.description = t.description || ''; + tplSelect.appendChild(opt); + } + tplSelect.addEventListener('change', () => { + const sel = tplSelect.selectedOptions[0]; + if (sel && sel.dataset.body) { + form.body.value = sel.dataset.body; + if (!form.title.value) form.title.value = sel.value; + } + }); + }) + .catch(() => {}); +} + +// Initial update + 10s interval +updateElapsedTimes(); +setInterval(updateElapsedTimes, 10000); + +// Refresh dashboard data every 10s if on the dashboard page +if (document.getElementById('worker-status')) { + setInterval(refreshDashboard, 10000); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initDispatchForm); +} else { + initDispatchForm(); +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..af2d63f --- /dev/null +++ b/templates/base.html @@ -0,0 +1,352 @@ + + + + + + + {% block title %}Hermes Dashboard{% endblock %} + + + +
+

🔥 Hermes Dashboard

+ + + {% block content %}{% endblock %} +
+ + + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..339179d --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,151 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Hermes{% endblock %} + +{% block content %} +
+
+
+

🔧 Worker Status

+
+ {% if worker_status.current_task %} +

Current Task: #{{ worker_status.current_task.id }} - {{ worker_status.current_task.title[:60] }}

+

Worker PID: {{ worker_status.current_task.worker_pid or 'N/A' }}

+ {% if worker_status.current_task.started_at %} +

Elapsed:

+ {% endif %} + {% else %} +

No task currently running

+ {% endif %} +
+
+ +
+

📜 SOUL.md Excerpt

+
{{ worker_status.soul_excerpt[:500] }}
+
+
+ +
+
+

⚙️ Running Processes

+
+ {% if processes %} + + + + + + + + + + {% for proc in processes %} + + + + + + {% endfor %} + +
PIDElapsedCommand
{{ proc.pid }}{{ proc.cmdline }}
+ {% else %} +

No opencode workers running

+ {% endif %} +
+
+
+
+ +

📋 Kanban Board

+
+ {% for status in ['ready', 'running', 'done', 'blocked', 'failed'] %} + + {% endfor %} +
+ +

📤 派活 Dispatch

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +

📦 Project Status

+
+ + + + + + + + + + + + {% for proj in projects %} + + + + + + + + {% endfor %} + +
NameBranchLast CommitTimeDirty
{{ proj.name }}{{ proj.branch or 'N/A' }}{{ proj.commit_short }} {{ proj.commit_msg[:40] }}{% if proj.commit_time %}{{ proj.commit_time|int|rel }}{% else %}N/A{% endif %}{{ proj.dirty_count }}
+
+{% endblock %} diff --git a/templates/project_detail.html b/templates/project_detail.html new file mode 100644 index 0000000..9973865 --- /dev/null +++ b/templates/project_detail.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} + +{% block title %}{{ name }} - Project Detail{% endblock %} + +{% block content %} +

📦 {{ name }}

+ +{% if project.error %} +
+

Error: {{ project.error }}

+
+{% else %} +
+
+
+

Repository Info

+

Path: {{ project.path }}

+

Branch: {{ project.branch or 'N/A' }}

+

Status: {{ project.status }}

+ {% if project.gitea_repo %} +

Gitea: {{ project.gitea_repo }} ↗

+ {% endif %} +
+ +
+

Latest Commit

+ {% if project.commit_hash %} +

Hash: {{ project.commit_hash }}

+

Message: {{ project.commit_msg }}

+

Time: {% if project.commit_time %}{{ project.commit_time|int|dt }} ({{ project.commit_time|int|rel }}){% else %}N/A{% endif %}

+ {% else %} +

No commits found

+ {% endif %} +
+
+ +
+
+

Git Status

+ {% if project.status_porcelain %} +
{{ project.status_porcelain }}
+ {% else %} +

Clean working directory

+ {% endif %} +
+ + {% if project.diff_stat %} +
+

Last Commit Diff Stat

+
{{ project.diff_stat }}
+
+ {% endif %} +
+
+ +
+

Recent Commits (last 10)

+ {% if project.recent_commits %} +
{% for commit in project.recent_commits %}{{ commit }}
+{% endfor %}
+ {% else %} +

No commits found

+ {% endif %} +
+{% endif %} + +

+ ← Back to Projects +

+{% endblock %} diff --git a/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..fd28700 --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}Projects - Hermes{% endblock %} + +{% block content %} +

📦 Managed Projects

+ +
+ + + + + + + + + + + + + + {% for proj in projects %} + + + + + + + + + + {% endfor %} + +
NamePathBranchLast CommitTimeDirtyGitea
{{ proj.name }}{{ proj.path }}{{ proj.branch or 'N/A' }} + {% if proj.commit_short %} + {{ proj.commit_short }} {{ proj.commit_msg[:50] }} + {% else %} + {{ proj.status }} + {% endif %} + {% if proj.commit_time %}{{ proj.commit_time|int|rel }}{% else %}N/A{% endif %}{{ proj.dirty_count }} + {% if proj.gitea_repo %} + + {% endif %} +
+
+{% endblock %} diff --git a/templates/task_detail.html b/templates/task_detail.html new file mode 100644 index 0000000..8d5fa10 --- /dev/null +++ b/templates/task_detail.html @@ -0,0 +1,144 @@ +{% extends "base.html" %} + +{% block title %}Task #{{ task_id }} - Hermes{% endblock %} + +{% block content %} +{% if data.error %} +

Error

+
+

{{ data.error }}

+
+{% else %} +{% set task = data.task %} +

📋 Task #{{ task.id }}: {{ task.title }}

+ +
+
+
+

Task Info

+

Status: {{ task.status }}

+

Assignee: {{ task.assignee or 'N/A' }}

+

Priority: {{ task.priority or 'N/A' }}

+

Workspace: {{ task.workspace_path or 'N/A' }}

+

Created: {% if task.created_at %}{{ task.created_at|int|dt }}{% else %}N/A{% endif %}

+ {% if task.started_at %} +

Started: {{ task.started_at|int|dt }} ({{ task.started_at|int|rel }})

+ {% endif %} + {% if task.completed_at %} +

Completed: {{ task.completed_at|int|dt }} ({{ task.completed_at|int|rel }})

+ {% endif %} +
+ +
+

Description

+ {% if task.body %} +
{{ task.body[:500] }}
+ {% else %} +

No description

+ {% endif %} +
+ + {% if task.result %} +
+

Result

+
{{ task.result }}
+
+ {% endif %} +
+ +
+
+

Timeline ({{ data.events|length }} events)

+ {% if data.events %} + {% for event in data.events %} +
+
+ {{ event.created_at|int|dt }} - {{ event.kind }} +
+
+ {% if event.payload %} +
{{ event.payload }}
+ {% endif %} +
+
+ {% endfor %} + {% else %} +

No events

+ {% endif %} +
+
+
+ +
+

Comments ({{ data.comments|length }})

+ {% if data.comments %} + {% for comment in data.comments %} +
+
+ {{ comment.author or 'Anonymous' }} - {{ comment.created_at|int|dt }} +
+
{{ comment.body }}
+
+ {% endfor %} + {% else %} +

No comments

+ {% endif %} +
+ +
+

Runs ({{ data.runs|length }})

+ {% if data.runs %} + + + + + + + + + + + + + + {% for run in data.runs %} + + + + + + + + + + {% endfor %} + +
IDProfileStatusPIDStartedEndedOutcome
{{ run.id }}{{ run.profile or 'N/A' }}{{ run.status }}{{ run.worker_pid or 'N/A' }}{% if run.started_at %}{{ run.started_at|int|dt }}{% else %}N/A{% endif %}{% if run.ended_at %}{{ run.ended_at|int|dt }}{% else %}N/A{% endif %}{{ run.outcome or 'N/A' }}
+ {% else %} +

No runs

+ {% endif %} +
+ +
+

Attachments ({{ data.attachments|length }})

+ {% if data.attachments %} +
    + {% for attachment in data.attachments %} +
  • + {{ attachment.filename }} + ({{ attachment.content_type }}, {{ attachment.size }} bytes) +
    + Uploaded by {{ attachment.uploaded_by or 'unknown' }} on {{ attachment.created_at|int|dt }} +
  • + {% endfor %} +
+ {% else %} +

No attachments

+ {% endif %} +
+{% endif %} + +

+ ← Back to Tasks +

+{% endblock %} diff --git a/templates/tasks.html b/templates/tasks.html new file mode 100644 index 0000000..f9f5639 --- /dev/null +++ b/templates/tasks.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}Tasks - Hermes{% endblock %} + +{% block content %} +

📋 Tasks

+ + + +
+ + + + + + + + + + + + + {% for task in tasks %} + + + + + + + + + {% endfor %} + +
IDTitleStatusAssigneeStartedCompleted
#{{ task.id }}{{ task.title[:80] }}{{ task.status }}{{ task.assignee or 'N/A' }}{% if task.started_at %}{{ task.started_at|int|rel }}{% else %}N/A{% endif %}{% if task.completed_at %}{{ task.completed_at|int|rel }}{% else %}N/A{% endif %}
+
+{% endblock %}