initial: hermes-dashboard snapshot (post overview + git integration)

This commit is contained in:
hermes-dashboard
2026-08-17 19:51:51 +08:00
commit 1e5e30ffcb
22 changed files with 2333 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.pyc
*.pyo
*.db
.env
*.pid
*.log
venv/
node_modules/
*.egg-info/
dist/
build/
.omo/

123
app.py Normal file
View File

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

201
config.py Normal file
View File

@@ -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. 标记需要后续呼应的伏笔
## 验收
- [ ] 初稿完成
- [ ] 字数达标
- [ ] 剧情连贯性检查通过
""",
},
]

54
extensions.py Normal file
View File

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

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
flask>=3.0
psutil>=7.0
requests>=2.32

6
routes/__init__.py Normal file
View File

@@ -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"]

313
routes/api.py Normal file
View File

@@ -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/<task_id>")
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/<task_id>/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/<task_id>/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/<name>")
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

99
routes/pages.py Normal file
View File

@@ -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/<name>")
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/<task_id>")
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)

81
scripts/deploy.sh Executable file
View File

@@ -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"

50
security.py Normal file
View File

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

1
services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Services package."""

29
services/gitea.py Normal file
View File

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

139
services/kanban.py Normal file
View File

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

161
services/projects.py Normal file
View File

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

78
services/worker.py Normal file
View File

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

176
static/js/main.js Normal file
View File

@@ -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 = `<p><strong>Current Task:</strong> #${t.id} - ${(t.title || '').substring(0, 60)}</p>`;
html += `<p><strong>Worker PID:</strong> ${t.worker_pid || 'N/A'}</p>`;
if (t.started_at) {
html += `<p><strong>Elapsed:</strong> <span class="elapsed-time" data-started="${t.started_at}"></span></p>`;
}
container.innerHTML = html;
} else {
container.innerHTML = '<p class="text-muted">No task currently running</p>';
}
// Refresh process list
const procContainer = document.getElementById('process-list');
if (procContainer && data.processes) {
if (data.processes.length > 0) {
let html = '<table><thead><tr><th>PID</th><th>Elapsed</th><th>Command</th></tr></thead><tbody>';
for (const proc of data.processes) {
html += `<tr><td>${proc.pid}</td>`;
html += `<td><span class="elapsed-time" data-started="${proc.create_time || 0}"></span></td>`;
html += `<td class="text-small">${escapeHtml(proc.cmdline || '')}</td></tr>`;
}
html += '</tbody></table>';
procContainer.innerHTML = html;
} else {
procContainer.innerHTML = '<p class="text-muted">No opencode workers running</p>';
}
}
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();
}

352
templates/base.html Normal file
View File

@@ -0,0 +1,352 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}Hermes Dashboard{% endblock %}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', Courier, monospace;
background: #1a1a1a;
color: #e0e0e0;
line-height: 1.6;
padding: 20px;
}
a {
color: #4a9eff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1, h2, h3 {
color: #fff;
margin-bottom: 15px;
}
h1 {
font-size: 24px;
border-bottom: 2px solid #333;
padding-bottom: 10px;
margin-bottom: 20px;
}
h2 {
font-size: 20px;
margin-top: 20px;
}
h3 {
font-size: 16px;
margin-top: 15px;
}
nav {
margin-bottom: 30px;
padding: 10px 0;
border-bottom: 1px solid #333;
}
nav a {
margin-right: 20px;
padding: 5px 10px;
background: #2a2a2a;
border-radius: 3px;
}
nav a:hover {
background: #3a3a3a;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.card {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 5px;
padding: 15px;
margin-bottom: 20px;
}
.card h3 {
margin-top: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th, td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #3a3a3a;
}
th {
background: #333;
color: #fff;
font-weight: bold;
}
tr:hover {
background: #2f2f2f;
}
.btn {
display: inline-block;
padding: 6px 12px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 3px;
color: #e0e0e0;
cursor: pointer;
text-decoration: none;
margin-right: 5px;
}
.btn:hover {
background: #4a4a4a;
text-decoration: none;
}
.btn-primary {
background: #2a5a9a;
border-color: #3a6aaa;
}
.btn-primary:hover {
background: #3a6aaa;
}
.status-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
}
.status-ready {
background: #2a5a2a;
color: #8f8;
}
.status-running {
background: #5a5a2a;
color: #ff8;
}
.status-done {
background: #2a5a2a;
color: #8f8;
}
.status-blocked {
background: #5a2a2a;
color: #f88;
}
.status-failed {
background: #5a2a2a;
color: #f88;
}
.kanban-board {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 15px;
margin-top: 20px;
}
.kanban-column {
background: #252525;
border: 1px solid #3a3a3a;
border-radius: 5px;
padding: 10px;
min-height: 200px;
}
.kanban-column h3 {
text-align: center;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 2px solid #3a3a3a;
}
.kanban-card {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 3px;
padding: 10px;
margin-bottom: 10px;
cursor: pointer;
}
.kanban-card:hover {
background: #333;
border-color: #4a9eff;
}
.kanban-card-title {
font-size: 14px;
margin-bottom: 5px;
word-wrap: break-word;
}
.kanban-card-meta {
font-size: 11px;
color: #888;
}
pre {
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 3px;
padding: 10px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 20px;
}
.text-muted {
color: #888;
}
.text-small {
font-size: 12px;
}
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
.mb-10 {
margin-bottom: 10px;
}
.mb-20 {
margin-bottom: 20px;
}
.filter-buttons {
margin: 15px 0;
}
.filter-buttons .btn {
margin-right: 10px;
}
.filter-buttons .btn.active {
background: #2a5a9a;
border-color: #3a6aaa;
}
.timeline-item {
border-left: 2px solid #3a3a3a;
padding-left: 15px;
margin-left: 10px;
margin-bottom: 15px;
}
.timeline-item-header {
font-size: 12px;
color: #888;
margin-bottom: 5px;
}
.timeline-item-content {
background: #2a2a2a;
padding: 10px;
border-radius: 3px;
}
.comment {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 3px;
padding: 10px;
margin-bottom: 10px;
}
.comment-header {
font-size: 12px;
color: #888;
margin-bottom: 5px;
}
.attachment-list {
list-style: none;
}
.attachment-list li {
padding: 5px 0;
border-bottom: 1px solid #3a3a3a;
}
.attachment-list li:last-child {
border-bottom: none;
}
@media (max-width: 1200px) {
.kanban-board {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.kanban-board {
grid-template-columns: 1fr;
}
.grid-2, .grid-3 {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<h1>🔥 Hermes Dashboard</h1>
<nav>
<a href="/">Dashboard</a>
<a href="/projects">Projects</a>
<a href="/tasks">Tasks</a>
</nav>
{% block content %}{% endblock %}
</div>
<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>

151
templates/dashboard.html Normal file
View File

@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}Dashboard - Hermes{% endblock %}
{% block content %}
<div class="grid-2">
<div>
<div class="card">
<h3>🔧 Worker Status</h3>
<div id="worker-status">
{% if worker_status.current_task %}
<p><strong>Current Task:</strong> #{{ worker_status.current_task.id }} - {{ worker_status.current_task.title[:60] }}</p>
<p><strong>Worker PID:</strong> {{ worker_status.current_task.worker_pid or 'N/A' }}</p>
{% if worker_status.current_task.started_at %}
<p><strong>Elapsed:</strong> <span class="elapsed-time" data-started="{{ worker_status.current_task.started_at }}"></span></p>
{% endif %}
{% else %}
<p class="text-muted">No task currently running</p>
{% endif %}
</div>
</div>
<div class="card">
<h3>📜 SOUL.md Excerpt</h3>
<pre>{{ worker_status.soul_excerpt[:500] }}</pre>
</div>
</div>
<div>
<div class="card">
<h3>⚙️ Running Processes</h3>
<div id="process-list">
{% if processes %}
<table>
<thead>
<tr>
<th>PID</th>
<th>Elapsed</th>
<th>Command</th>
</tr>
</thead>
<tbody>
{% for proc in processes %}
<tr>
<td>{{ proc.pid }}</td>
<td><span class="elapsed-time" data-started="{{ proc.create_time }}"></span></td>
<td class="text-small">{{ proc.cmdline }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">No opencode workers running</p>
{% endif %}
</div>
</div>
</div>
</div>
<h2>📋 Kanban Board</h2>
<div class="kanban-board" id="kanban-board">
{% for status in ['ready', 'running', 'done', 'blocked', 'failed'] %}
<div class="kanban-column">
<h3>{{ status|upper }} ({{ board.get(status, [])|length }})</h3>
{% for task in board.get(status, []) %}
<a href="/tasks/{{ task.id }}" style="text-decoration: none;">
<div class="kanban-card">
<div class="kanban-card-title">#{{ task.id }} {{ task.title[:60] }}</div>
<div class="kanban-card-meta">
{% if task.status == 'running' and task.started_at %}
<span class="elapsed-time" data-started="{{ task.started_at }}"></span>
{% elif task.status == 'done' and task.completed_at %}
{{ task.completed_at|rel }}
{% endif %}
{% if task.assignee %}
<br>{{ task.assignee }}
{% endif %}
</div>
</div>
</a>
{% endfor %}
</div>
{% endfor %}
</div>
<h2 class="mt-20">📤 派活 Dispatch</h2>
<div class="card">
<form id="dispatch-form">
<div style="margin-bottom:10px;">
<label for="dispatch-template">Template:</label>
<select id="dispatch-template" style="width:100%;padding:6px;background:#1a1a1a;color:#e0e0e0;border:1px solid #3a3a3a;border-radius:3px;">
<option value="">-- choose a template (optional) --</option>
</select>
</div>
<div style="margin-bottom:10px;">
<label for="dispatch-title">Title (required):</label>
<input type="text" id="dispatch-title" name="title" required maxlength="200"
style="width:100%;padding:6px;background:#1a1a1a;color:#e0e0e0;border:1px solid #3a3a3a;border-radius:3px;"
placeholder="e.g. Fix CSRF in blog-app">
</div>
<div style="margin-bottom:10px;">
<label for="dispatch-body">Body (markdown):</label>
<textarea id="dispatch-body" name="body" rows="6"
style="width:100%;padding:6px;background:#1a1a1a;color:#e0e0e0;border:1px solid #3a3a3a;border-radius:3px;font-family:inherit;"
placeholder="## 背景&#10;...&#10;## 任务&#10;...&#10;## 验收&#10;- [ ] ..."></textarea>
</div>
<div class="grid-2" style="margin-bottom:10px;">
<div>
<label for="dispatch-assignee">Assignee:</label>
<input type="text" id="dispatch-assignee" name="assignee" value="omo-pm" maxlength="64"
style="width:100%;padding:6px;background:#1a1a1a;color:#e0e0e0;border:1px solid #3a3a3a;border-radius:3px;">
</div>
<div>
<label for="dispatch-max-runtime">Max runtime (seconds, 60-7200):</label>
<input type="number" id="dispatch-max-runtime" name="max_runtime" value="1800" min="60" max="7200"
style="width:100%;padding:6px;background:#1a1a1a;color:#e0e0e0;border:1px solid #3a3a3a;border-radius:3px;">
</div>
</div>
<div>
<button type="submit" class="btn btn-primary">🚀 Dispatch</button>
<span id="dispatch-result" class="text-small" style="margin-left:10px;"></span>
</div>
</form>
</div>
<h2 class="mt-20">📦 Project Status</h2>
<div class="card">
<table>
<thead>
<tr>
<th>Name</th>
<th>Branch</th>
<th>Last Commit</th>
<th>Time</th>
<th>Dirty</th>
</tr>
</thead>
<tbody>
{% for proj in projects %}
<tr>
<td><a href="/projects/{{ proj.name }}">{{ proj.name }}</a></td>
<td>{{ proj.branch or 'N/A' }}</td>
<td class="text-small">{{ proj.commit_short }} {{ proj.commit_msg[:40] }}</td>
<td>{% if proj.commit_time %}{{ proj.commit_time|int|rel }}{% else %}N/A{% endif %}</td>
<td>{{ proj.dirty_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}{{ name }} - Project Detail{% endblock %}
{% block content %}
<h2>📦 {{ name }}</h2>
{% if project.error %}
<div class="card">
<p class="text-muted">Error: {{ project.error }}</p>
</div>
{% else %}
<div class="grid-2">
<div>
<div class="card">
<h3>Repository Info</h3>
<p><strong>Path:</strong> <span class="text-small">{{ project.path }}</span></p>
<p><strong>Branch:</strong> {{ project.branch or 'N/A' }}</p>
<p><strong>Status:</strong> {{ project.status }}</p>
{% if project.gitea_repo %}
<p><strong>Gitea:</strong> <a href="http://localhost:3000/admin/{{ project.gitea_repo }}" target="_blank">{{ project.gitea_repo }} ↗</a></p>
{% endif %}
</div>
<div class="card">
<h3>Latest Commit</h3>
{% if project.commit_hash %}
<p><strong>Hash:</strong> <span class="text-small">{{ project.commit_hash }}</span></p>
<p><strong>Message:</strong> {{ project.commit_msg }}</p>
<p><strong>Time:</strong> {% if project.commit_time %}{{ project.commit_time|int|dt }} ({{ project.commit_time|int|rel }}){% else %}N/A{% endif %}</p>
{% else %}
<p class="text-muted">No commits found</p>
{% endif %}
</div>
</div>
<div>
<div class="card">
<h3>Git Status</h3>
{% if project.status_porcelain %}
<pre>{{ project.status_porcelain }}</pre>
{% else %}
<p class="text-muted">Clean working directory</p>
{% endif %}
</div>
{% if project.diff_stat %}
<div class="card">
<h3>Last Commit Diff Stat</h3>
<pre>{{ project.diff_stat }}</pre>
</div>
{% endif %}
</div>
</div>
<div class="card mt-20">
<h3>Recent Commits (last 10)</h3>
{% if project.recent_commits %}
<pre>{% for commit in project.recent_commits %}{{ commit }}
{% endfor %}</pre>
{% else %}
<p class="text-muted">No commits found</p>
{% endif %}
</div>
{% endif %}
<p class="mt-20">
<a href="/projects" class="btn">← Back to Projects</a>
</p>
{% endblock %}

46
templates/projects.html Normal file
View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Projects - Hermes{% endblock %}
{% block content %}
<h2>📦 Managed Projects</h2>
<div class="card">
<table>
<thead>
<tr>
<th>Name</th>
<th>Path</th>
<th>Branch</th>
<th>Last Commit</th>
<th>Time</th>
<th>Dirty</th>
<th>Gitea</th>
</tr>
</thead>
<tbody>
{% for proj in projects %}
<tr>
<td><a href="/projects/{{ proj.name }}">{{ proj.name }}</a></td>
<td class="text-small">{{ proj.path }}</td>
<td>{{ proj.branch or 'N/A' }}</td>
<td class="text-small">
{% if proj.commit_short %}
{{ proj.commit_short }} {{ proj.commit_msg[:50] }}
{% else %}
<span class="text-muted">{{ proj.status }}</span>
{% endif %}
</td>
<td>{% if proj.commit_time %}{{ proj.commit_time|int|rel }}{% else %}N/A{% endif %}</td>
<td>{{ proj.dirty_count }}</td>
<td>
{% if proj.gitea_repo %}
<a href="http://localhost:3000/admin/{{ proj.gitea_repo }}" target="_blank"></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

144
templates/task_detail.html Normal file
View File

@@ -0,0 +1,144 @@
{% extends "base.html" %}
{% block title %}Task #{{ task_id }} - Hermes{% endblock %}
{% block content %}
{% if data.error %}
<h2>Error</h2>
<div class="card">
<p class="text-muted">{{ data.error }}</p>
</div>
{% else %}
{% set task = data.task %}
<h2>📋 Task #{{ task.id }}: {{ task.title }}</h2>
<div class="grid-2">
<div>
<div class="card">
<h3>Task Info</h3>
<p><strong>Status:</strong> <span class="status-badge status-{{ task.status }}">{{ task.status }}</span></p>
<p><strong>Assignee:</strong> {{ task.assignee or 'N/A' }}</p>
<p><strong>Priority:</strong> {{ task.priority or 'N/A' }}</p>
<p><strong>Workspace:</strong> <span class="text-small">{{ task.workspace_path or 'N/A' }}</span></p>
<p><strong>Created:</strong> {% if task.created_at %}{{ task.created_at|int|dt }}{% else %}N/A{% endif %}</p>
{% if task.started_at %}
<p><strong>Started:</strong> {{ task.started_at|int|dt }} ({{ task.started_at|int|rel }})</p>
{% endif %}
{% if task.completed_at %}
<p><strong>Completed:</strong> {{ task.completed_at|int|dt }} ({{ task.completed_at|int|rel }})</p>
{% endif %}
</div>
<div class="card">
<h3>Description</h3>
{% if task.body %}
<pre>{{ task.body[:500] }}</pre>
{% else %}
<p class="text-muted">No description</p>
{% endif %}
</div>
{% if task.result %}
<div class="card">
<h3>Result</h3>
<pre>{{ task.result }}</pre>
</div>
{% endif %}
</div>
<div>
<div class="card">
<h3>Timeline ({{ data.events|length }} events)</h3>
{% if data.events %}
{% for event in data.events %}
<div class="timeline-item">
<div class="timeline-item-header">
{{ event.created_at|int|dt }} - {{ event.kind }}
</div>
<div class="timeline-item-content">
{% if event.payload %}
<pre class="text-small">{{ event.payload }}</pre>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted">No events</p>
{% endif %}
</div>
</div>
</div>
<div class="card mt-20">
<h3>Comments ({{ data.comments|length }})</h3>
{% if data.comments %}
{% for comment in data.comments %}
<div class="comment">
<div class="comment-header">
<strong>{{ comment.author or 'Anonymous' }}</strong> - {{ comment.created_at|int|dt }}
</div>
<div>{{ comment.body }}</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted">No comments</p>
{% endif %}
</div>
<div class="card mt-20">
<h3>Runs ({{ data.runs|length }})</h3>
{% if data.runs %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Profile</th>
<th>Status</th>
<th>PID</th>
<th>Started</th>
<th>Ended</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
{% for run in data.runs %}
<tr>
<td>{{ run.id }}</td>
<td>{{ run.profile or 'N/A' }}</td>
<td><span class="status-badge status-{{ run.status }}">{{ run.status }}</span></td>
<td>{{ run.worker_pid or 'N/A' }}</td>
<td>{% if run.started_at %}{{ run.started_at|int|dt }}{% else %}N/A{% endif %}</td>
<td>{% if run.ended_at %}{{ run.ended_at|int|dt }}{% else %}N/A{% endif %}</td>
<td>{{ run.outcome or 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">No runs</p>
{% endif %}
</div>
<div class="card mt-20">
<h3>Attachments ({{ data.attachments|length }})</h3>
{% if data.attachments %}
<ul class="attachment-list">
{% for attachment in data.attachments %}
<li>
<strong>{{ attachment.filename }}</strong>
<span class="text-muted">({{ attachment.content_type }}, {{ attachment.size }} bytes)</span>
<br>
<span class="text-small">Uploaded by {{ attachment.uploaded_by or 'unknown' }} on {{ attachment.created_at|int|dt }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">No attachments</p>
{% endif %}
</div>
{% endif %}
<p class="mt-20">
<a href="/tasks" class="btn">← Back to Tasks</a>
</p>
{% endblock %}

43
templates/tasks.html Normal file
View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Tasks - Hermes{% endblock %}
{% block content %}
<h2>📋 Tasks</h2>
<div class="filter-buttons">
<a href="/tasks" class="btn {% if not current_status %}active{% endif %}">All ({{ counts.get('ready', 0) + counts.get('running', 0) + counts.get('done', 0) + counts.get('blocked', 0) + counts.get('failed', 0) }})</a>
<a href="/tasks?status=ready" class="btn {% if current_status == 'ready' %}active{% endif %}">Ready ({{ counts.get('ready', 0) }})</a>
<a href="/tasks?status=running" class="btn {% if current_status == 'running' %}active{% endif %}">Running ({{ counts.get('running', 0) }})</a>
<a href="/tasks?status=done" class="btn {% if current_status == 'done' %}active{% endif %}">Done ({{ counts.get('done', 0) }})</a>
<a href="/tasks?status=blocked" class="btn {% if current_status == 'blocked' %}active{% endif %}">Blocked ({{ counts.get('blocked', 0) }})</a>
<a href="/tasks?status=failed" class="btn {% if current_status == 'failed' %}active{% endif %}">Failed ({{ counts.get('failed', 0) }})</a>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Status</th>
<th>Assignee</th>
<th>Started</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td><a href="/tasks/{{ task.id }}">#{{ task.id }}</a></td>
<td>{{ task.title[:80] }}</td>
<td><span class="status-badge status-{{ task.status }}">{{ task.status }}</span></td>
<td>{{ task.assignee or 'N/A' }}</td>
<td>{% if task.started_at %}{{ task.started_at|int|rel }}{% else %}N/A{% endif %}</td>
<td>{% if task.completed_at %}{{ task.completed_at|int|rel }}{% else %}N/A{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}