Phase 1: Activity and Error Log for Agent Tab
Backend: _log_agent_activity, _get_activity_log, _get_error_log. API: GET /api/agents/{id}/activity and /errors. Frontend: Activity and Errors tabs in agent detail overlay. CSS: activity-event-row, error-event-row. Config fix: Z.ai API key.
This commit is contained in:
176
api/routes.py
176
api/routes.py
@@ -58,7 +58,7 @@ from api import agents as _agents
|
||||
|
||||
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
|
||||
import re as _re
|
||||
|
||||
_re_path = _re.compile(r"^(?P<path>/[^?]*)")
|
||||
|
||||
def _normalize_host_port(value: str) -> tuple[str, str | None]:
|
||||
"""Split a host or host:port string into (hostname, port|None).
|
||||
@@ -471,6 +471,14 @@ def handle_get(handler, parsed) -> bool:
|
||||
settings.pop("password_hash", None)
|
||||
return j(handler, settings)
|
||||
|
||||
# ── Logs ──
|
||||
if parsed.path == "/api/logs":
|
||||
return _handle_logs_list(handler)
|
||||
|
||||
if parsed.path.startswith("/api/logs/"):
|
||||
log_name = parsed.path[len("/api/logs/"):]
|
||||
return _handle_logs_read(handler, log_name)
|
||||
|
||||
if parsed.path == "/api/onboarding/status":
|
||||
return j(handler, get_onboarding_status())
|
||||
|
||||
@@ -768,8 +776,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
return j(handler, {"tasks": _mc.get_tasks()})
|
||||
|
||||
if parsed.path == "/api/mc/feed":
|
||||
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0])
|
||||
return j(handler, {"feed": _mc.get_feed(limit=limit)})
|
||||
return j(handler, {"feed": _mc.get_feed(limit=50)})
|
||||
|
||||
# ── Agents API (Rose + Tier-2) ──
|
||||
if parsed.path == "/api/agents":
|
||||
@@ -783,6 +790,75 @@ def handle_get(handler, parsed) -> bool:
|
||||
agent_id = parsed.path.split("/")[-1]
|
||||
return j(handler, _agents.get_agent_config(agent_id))
|
||||
|
||||
# GET /api/agents/{id} — full agent detail
|
||||
if parsed.path == "/api/agents/rose" or parsed.path == "/api/agents/lotus" or \
|
||||
parsed.path == "/api/agents/sunflower" or parsed.path == "/api/agents/forget-me-not" or \
|
||||
parsed.path == "/api/agents/root" or parsed.path == "/api/agents/dandelion" or \
|
||||
parsed.path == "/api/agents/iris" or parsed.path == "/api/agents/ivy":
|
||||
agent_id = parsed.path.split("/")[-1]
|
||||
return j(handler, _agents.get_agent(agent_id))
|
||||
|
||||
# GET /api/agents/{id}/status
|
||||
if parsed.path.startswith("/api/agents/") and parsed.path.endswith("/status"):
|
||||
agent_id = parsed.path.split("/")[-2]
|
||||
return j(handler, _agents.get_agent_status(agent_id))
|
||||
|
||||
# PUT /api/agents/{id}/soul
|
||||
if parsed.path.endswith("/soul") and method == "PUT":
|
||||
agent_id = parsed.path.split("/")[-2]
|
||||
data = read_body(handler)
|
||||
return j(handler, _agents.update_agent_soul(agent_id, data.get("content", "")))
|
||||
|
||||
# PUT /api/agents/{id}/memory
|
||||
if parsed.path.endswith("/memory") and method == "PUT":
|
||||
agent_id = parsed.path.split("/")[-2]
|
||||
data = read_body(handler)
|
||||
return j(handler, _agents.update_agent_memory(agent_id, data.get("content", "")))
|
||||
|
||||
# POST /api/agents/{id}/message
|
||||
if parsed.path.endswith("/message") and method == "POST":
|
||||
agent_id = parsed.path.split("/")[-2]
|
||||
data = read_body(handler)
|
||||
return j(handler, _agents.send_agent_message(agent_id, data))
|
||||
|
||||
# POST /api/agents/{id}/ack/{msg_id}
|
||||
if "/ack/" in parsed.path and method == "POST":
|
||||
parts = parsed.path.split("/")
|
||||
agent_id = parts[2]
|
||||
msg_id = parts[4]
|
||||
return j(handler, _agents.ack_agent_message(agent_id, msg_id))
|
||||
|
||||
# POST /api/agents/{id}/enable | /disable
|
||||
if parsed.path.endswith("/enable") or parsed.path.endswith("/disable"):
|
||||
if method == "POST":
|
||||
agent_id = parsed.path.split("/")[-2]
|
||||
action = parsed.path.split("/")[-1]
|
||||
return j(handler, _agents.set_agent_enabled(agent_id, action == "enable"))
|
||||
|
||||
# GET /api/agents/{id}/inbox (full, with limit query param)
|
||||
if parsed.path.startswith("/api/agents/") and "/inbox" in parsed.path:
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) == 5 and parts[4] == "inbox":
|
||||
agent_id = parts[3]
|
||||
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0])
|
||||
return j(handler, _agents.get_agent_inbox(agent_id, limit=limit))
|
||||
|
||||
# GET /api/agents/{id}/activity
|
||||
if parsed.path.startswith("/api/agents/") and "/activity" in parsed.path:
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) == 5 and parts[4] == "activity":
|
||||
agent_id = parts[3]
|
||||
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0])
|
||||
return j(handler, _agents.get_agent_activity(agent_id, limit=limit))
|
||||
|
||||
# GET /api/agents/{id}/errors
|
||||
if parsed.path.startswith("/api/agents/") and "/errors" in parsed.path:
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) == 5 and parts[4] == "errors":
|
||||
agent_id = parts[3]
|
||||
limit = int(parse_qs(parsed.query).get("limit", ["20"])[0])
|
||||
return j(handler, _agents.get_agent_errors(agent_id, limit=limit))
|
||||
|
||||
# ── Profile API (GET) ──
|
||||
if parsed.path == "/api/profiles":
|
||||
from api.profiles import list_profiles_api, get_active_profile_name
|
||||
@@ -1248,6 +1324,14 @@ def handle_post(handler, parsed) -> bool:
|
||||
except RuntimeError as e:
|
||||
return bad(handler, str(e), 409)
|
||||
|
||||
# ── Logs API ──
|
||||
if parsed.path == "/api/logs":
|
||||
return _handle_logs_list(handler)
|
||||
|
||||
if parsed.path.startswith("/api/logs/"):
|
||||
log_name = parsed.path[len("/api/logs/"):]
|
||||
return _handle_logs_read(handler, log_name)
|
||||
|
||||
# ── Gateway API ──
|
||||
if parsed.path == "/api/gateways":
|
||||
# GET - list all gateways
|
||||
@@ -3158,3 +3242,89 @@ def _handle_session_import(handler, body):
|
||||
SESSIONS.popitem(last=False)
|
||||
s.save()
|
||||
return j(handler, {"ok": True, "session": s.compact() | {"messages": s.messages}})
|
||||
|
||||
|
||||
# ── Logs ──────────────────────────────────────────────────────────────────
|
||||
ALLOWED_LOG_FILES = {
|
||||
"agent.log": "~/.hermes/logs/agent.log",
|
||||
"errors.log": "~/.hermes/logs/errors.log",
|
||||
"gateway.log": "~/.hermes/logs/gateway.log",
|
||||
"gateway.error.log": "~/.hermes/logs/gateway.error.log",
|
||||
"update.log": "~/.hermes/logs/update.log",
|
||||
"webui.log": "~/.hermes/logs/webui.log",
|
||||
"webui-prod.log": "~/.hermes/webui/bootstrap-8787.log",
|
||||
"webui-dev.log": "~/.hermes/webui-dev/bootstrap-8788.log",
|
||||
}
|
||||
|
||||
|
||||
def _handle_logs_list(handler):
|
||||
"""Return list of available log files with metadata."""
|
||||
logs = []
|
||||
for name, rel_path in ALLOWED_LOG_FILES.items():
|
||||
path = Path(rel_path.replace("~", str(Path.home())))
|
||||
if path.exists():
|
||||
stat = path.stat()
|
||||
logs.append({
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"size_human": _human_size(stat.st_size),
|
||||
})
|
||||
else:
|
||||
logs.append({
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"size": 0,
|
||||
"modified": None,
|
||||
"size_human": "0 B",
|
||||
"missing": True,
|
||||
})
|
||||
return j(handler, {"logs": logs})
|
||||
|
||||
|
||||
def _human_size(num_bytes):
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
if num_bytes < 1024:
|
||||
return f"{num_bytes:.1f} {unit}"
|
||||
num_bytes /= 1024
|
||||
return f"{num_bytes:.1f} TB"
|
||||
|
||||
|
||||
def _handle_logs_read(handler, log_name):
|
||||
"""Return last N lines of a log file."""
|
||||
if log_name not in ALLOWED_LOG_FILES:
|
||||
return bad(handler, f"Unknown log file: {log_name}")
|
||||
|
||||
rel_path = ALLOWED_LOG_FILES[log_name]
|
||||
path = Path(rel_path.replace("~", str(Path.home())))
|
||||
|
||||
# Security: resolve and verify path stays within ~/.hermes
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
hermes_root = Path.home() / ".hermes"
|
||||
if not str(resolved).startswith(str(hermes_root)):
|
||||
return bad(handler, "Access denied")
|
||||
except Exception:
|
||||
return bad(handler, "Invalid path")
|
||||
|
||||
if not path.exists():
|
||||
return bad(handler, f"Log file not found: {log_name}")
|
||||
|
||||
# Tail last 1000 lines
|
||||
try:
|
||||
lines = path.read_text(errors="replace").splitlines()
|
||||
tail = lines[-1000:]
|
||||
content = "\n".join(tail)
|
||||
except Exception as e:
|
||||
return bad(handler, f"Cannot read log: {e}")
|
||||
|
||||
stat = path.stat()
|
||||
return j(handler, {
|
||||
"name": log_name,
|
||||
"content": content,
|
||||
"size": stat.st_size,
|
||||
"size_human": _human_size(stat.st_size),
|
||||
"line_count": len(lines),
|
||||
"tail_count": len(tail),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user