initial: hermes-dashboard snapshot (post overview + git integration)
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal 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
123
app.py
Normal 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
201
config.py
Normal 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
54
extensions.py
Normal 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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0
|
||||||
|
psutil>=7.0
|
||||||
|
requests>=2.32
|
||||||
6
routes/__init__.py
Normal file
6
routes/__init__.py
Normal 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
313
routes/api.py
Normal 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
99
routes/pages.py
Normal 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
81
scripts/deploy.sh
Executable 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
50
security.py
Normal 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
1
services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Services package."""
|
||||||
29
services/gitea.py
Normal file
29
services/gitea.py
Normal 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
139
services/kanban.py
Normal 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
161
services/projects.py
Normal 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
78
services/worker.py
Normal 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
176
static/js/main.js
Normal 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
352
templates/base.html
Normal 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
151
templates/dashboard.html
Normal 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="## 背景 ... ## 任务 ... ## 验收 - [ ] ..."></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 %}
|
||||||
70
templates/project_detail.html
Normal file
70
templates/project_detail.html
Normal 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
46
templates/projects.html
Normal 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
144
templates/task_detail.html
Normal 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
43
templates/tasks.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user