initial: hermes-dashboard snapshot (post overview + git integration)
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user