#!/usr/bin/env python3 """server.py — Live AI OS Dashboard: read-only API + static server (stdlib only). Binds 127.0.0.1 ONLY (public via Caddy HTTPS hoặc SSH tunnel). LUÔN enforce basicauth (kể cả khi đi qua SSH tunnel không có Caddy). Chỉ trả PROJECTION đã chắt lọc — không body, không PII, không chat_id, không secret — nên kể cả lọt auth cũng không rò gì nhạy cảm. Không có endpoint ghi. Env (systemd EnvironmentFile=/opt/ai-os/instance/dashboard.env): DASH_USER, DASH_PASS_HASH (sha256 hex của mật khẩu), DASH_PORT (mặc định 20129) """ import os import sys import json import hmac import base64 import hashlib import re import html as html_mod import html import sqlite3 import subprocess import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer HERE = os.path.dirname(os.path.abspath(__file__)) # .../dashboard/api DASH = os.path.dirname(HERE) # .../dashboard PROD = os.path.dirname(DASH) # .../products/ceo (= bundle root runtime) STATIC = os.path.realpath(os.path.join(DASH, "static")) USERS_INDEX = os.path.join(PROD, "users", "_index.json") FILES_ROOT = os.path.realpath("/") # root filesystem cho route /files/ # Danh sách các đường dẫn bị cấm đọc qua /files/ (whitelist-negative) FILES_BLOCKED = [ "auth.json", ".env", "google_token.json", ".git", "__pycache__", "node_modules", ".key", ".pem", "credentials.json", "token.pickle", "dashboard/static/fonts/", # không cần thiết "state.db", # database files ] DASH_USER = os.environ.get("DASH_USER", "admin") DASH_PASS_HASH = os.environ.get("DASH_PASS_HASH", "") # sha256 hex; rỗng = chặn hết (an toàn) PORT = int(os.environ.get("DASH_PORT", "20129")) CTYPE = {".html": "text/html; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png"} # Các extension cho phép đọc raw RAW_EXT = {".md", ".txt", ".yaml", ".yml", ".json", ".csv", ".toml", ".ini", ".cfg", ".conf", ".log", ".xml", ".html", ".sh", ".py", ".js", ".css", ".env.example"} def _lib(script, *args, timeout=20): """Chạy 1 lib CLI của bundle từ cwd=PROD → parse JSON (read-only helpers).""" try: p = subprocess.run([sys.executable, os.path.join(PROD, "lib", script), *args], cwd=PROD, capture_output=True, text=True, encoding="utf-8", timeout=timeout) return json.loads(p.stdout) if p.stdout.strip() else {} except Exception as e: return {"ok": False, "error": str(e)} KANBAN_CANDIDATES = [os.environ.get("HERMES_KANBAN_DB"), "/root/.hermes/kanban/boards/nv-office/kanban.db", os.path.expanduser("~/.hermes/kanban.db"), "/root/.hermes/kanban.db"] KANBAN_COL = [("todo", "Cần làm", ("triage", "todo", "ready")), ("running", "Đang chạy", ("running",)), ("blocked", "Tắc", ("blocked",)), ("done", "Xong", ("done",))] def _kanban(group_by="status", sort="priority"): """Mirror CHỈ-ĐỌC bảng Kanban của Hermes (~/.hermes/kanban.db). Chỉ lấy field an toàn (title/assignee/status/priority) — không body/secret. Tạo & kéo thẻ làm qua Telegram /kanban.""" path = next((p for p in KANBAN_CANDIDATES if p and os.path.isfile(p)), None) if not path: return {"ok": True, "available": False, "columns": []} try: con = sqlite3.connect("file:%s?mode=ro" % path, uri=True, timeout=2) con.row_factory = sqlite3.Row if sort == "title_asc": order_clause = "ORDER BY title ASC" elif sort == "title_desc": order_clause = "ORDER BY title DESC" elif sort == "newest": order_clause = "ORDER BY created_at DESC" else: # priority DESC, created_at DESC order_clause = "ORDER BY priority DESC, created_at DESC" rows = con.execute("SELECT id,title,assignee,status,priority FROM tasks " "WHERE status != 'archived' " + order_clause).fetchall() con.close() except Exception as e: return {"ok": False, "available": False, "error": str(e), "columns": []} if group_by == "assignee": # Group by assignee assignees = sorted(list(set([r["assignee"] or "Unassigned" for r in rows]))) bucket = {a: [] for a in assignees} for r in rows: key = r["assignee"] or "Unassigned" bucket[key].append({"id": r["id"], "title": r["title"] or "(không tên)", "assignee": r["assignee"] or "", "status": r["status"], "priority": int(r["priority"] or 0)}) cols = [{"key": k, "title": k, "tasks": bucket[k]} for k in assignees] else: # Default status grouping bucket = {k: [] for k, _, _ in KANBAN_COL} for r in rows: key = next((k for k, _, sts in KANBAN_COL if r["status"] in sts), "todo") bucket[key].append({"id": r["id"], "title": r["title"] or "(không tên)", "assignee": r["assignee"] or "", "status": r["status"], "priority": int(r["priority"] or 0)}) cols = [{"key": k, "title": t, "tasks": bucket[k]} for k, t, _ in KANBAN_COL] return {"ok": True, "available": True, "total": len(rows), "columns": cols} def _team(): try: d = json.load(open(USERS_INDEX, encoding="utf-8")) members = [{"name": u.get("display_name") or uid, "role": u.get("org_role", ""), "last_seen": u.get("last_seen", "")} for uid, u in (d.get("users") or {}).items()] return {"ok": True, "members": members} except Exception: return {"ok": True, "members": []} def _api(path, query=""): if path == "/api/health": return {"ok": True} if path == "/api/mentors": return _lib("mentors.py", "list") if path == "/api/mentor": mid = urllib.parse.parse_qs(query).get("id", [""])[0] return _lib("mentors.py", "detail", mid) if path == "/api/usecases": return _lib("advisor_tools.py", "prioritize") if path == "/api/customer-health": return _lib("advisor_tools.py", "health") if path == "/api/kb-graph": return _lib("kb_graph.py", "graph") if path == "/api/kb-note": nid = urllib.parse.parse_qs(query).get("path", [""])[0] return _lib("kb_graph.py", "note", nid) if path == "/api/phases": return _lib("growth.py", "phases") if path == "/api/startup": return _lib("startup_kit.py", "status") if path == "/api/plan": return _lib("startup_kit.py", "plan") if path == "/api/profile": return _lib("startup_kit.py", "profile", "--no-md") if path == "/api/checklist": return _lib("cd_checklist.py", "status") if path == "/api/kb": return _lib("kb.py", "status") if path == "/api/skills": try: return json.load(open(os.path.join(PROD, "content", "skills_catalog.json"), encoding="utf-8")) except Exception: return {"ok": True, "groups": []} if path == "/api/guides": try: return json.load(open(os.path.join(PROD, "content", "startup_kit", "guides.json"), encoding="utf-8")) except Exception: return {} if path == "/api/process": try: return json.load(open(os.path.join(PROD, "content", "customer_development.json"), encoding="utf-8")) except Exception: return {"steps": []} if path == "/api/activity": return {"ok": True, "summary": _lib("activity.py", "summary"), "recent": _lib("activity.py", "recent", "--limit", "20").get("events", [])} if path == "/api/team": return _team() if path == "/api/task-detail": tid = urllib.parse.parse_qs(query).get("id", [""])[0] if not tid: return {"ok": False, "error": "missing id"} path_db = next((p for p in KANBAN_CANDIDATES if p and os.path.isfile(p)), None) if not path_db: return {"ok": False, "error": "db not found"} try: con = sqlite3.connect("file:%s?mode=ro" % path_db, uri=True, timeout=2) con.row_factory = sqlite3.Row # Lấy toàn bộ thông tin task row = con.execute("SELECT * FROM tasks WHERE id = ?", (tid,)).fetchone() if not row: con.close() return {"ok": False, "error": "not found"} task_data = dict(row) # Lấy toàn bộ comments của task này comments = con.execute("SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY created_at ASC", (tid,)).fetchall() comment_list = [{"author": c["author"], "body": c["body"], "created_at": c["created_at"]} for c in comments] # Lấy toàn bộ run history của task này runs = con.execute("SELECT id, profile, status, started_at, ended_at, outcome, summary, metadata, error FROM task_runs WHERE task_id = ? ORDER BY started_at DESC", (tid,)).fetchall() run_list = [] for r in runs: run_list.append({ "id": r["id"], "profile": r["profile"], "status": r["status"], "started_at": r["started_at"], "ended_at": r["ended_at"], "outcome": r["outcome"], "summary": r["summary"], "metadata": r["metadata"], "error": r["error"] }) # Fallback logic: Nếu t.result bị trống, lấy summary của run completed mới nhất làm result if not task_data.get("result"): for r in run_list: if r["status"] == "completed" and r["summary"]: task_data["result"] = r["summary"] break elif r["status"] == "completed" and r["outcome"]: task_data["result"] = r["outcome"] break con.close() # Đọc log file của worker nếu có log_content = "" log_path = f"/root/.hermes/kanban/boards/nv-office/logs/{tid}.log" if os.path.isfile(log_path): try: with open(log_path, "r", encoding="utf-8", errors="replace") as lf: # Lấy 50KB cuối của file log để tránh quá tải lf.seek(0, 2) fsize = lf.tell() if fsize > 50000: lf.seek(fsize - 50000) log_content = lf.read() # Đảm bảo không bị đứt dòng đầu tiên sau seek if "\n" in log_content: log_content = log_content[log_content.index("\n")+1:] else: lf.seek(0) log_content = lf.read() except Exception as log_err: log_content = f"Lỗi đọc log: {str(log_err)}" else: log_content = "Không tìm thấy file log hoặc task chưa được chạy." return { "ok": True, "task": task_data, "comments": comment_list, "runs": run_list, "log": log_content } except Exception as e: return {"ok": False, "error": str(e)} if path == "/api/kanban": qp = urllib.parse.parse_qs(query) group_by = qp.get("group_by", ["status"])[0] sort = qp.get("sort", ["priority"])[0] return _kanban(group_by, sort) if path == "/api/kpi": return {"ok": True, "available": False, "reason": "Chưa có nguồn dữ liệu KPI — sẽ bật khi kích hoạt skill KPI."} return None def _md_to_html(md_text): """Minimal Markdown renderer for .md files, producing nice HTML.""" lines = md_text.split("\n") out = ['
'.format(html_mod.escape(lang)))
in_code = True
continue
if in_code:
out.append(html_mod.escape(line) + "\n")
continue
stripped = line.strip()
if not stripped:
if in_table is None:
out.append('')
continue
if stripped.startswith("# "):
out.append("
{}
".format(html_mod.escape(stripped[2:])))
elif stripped.startswith("## "):
out.append("{}
".format(html_mod.escape(stripped[3:])))
elif stripped.startswith("### "):
out.append("{}
".format(html_mod.escape(stripped[4:])))
elif stripped in ("---", "***", "___"):
out.append("
")
elif stripped.startswith("|"):
cells = [c.strip() for c in stripped.split("|") if c.strip()]
if in_table is None:
out.append("")
in_table = "header"
elif in_table == "header":
if cells and all(c.startswith(":") or c.startswith("-") for c in cells):
out.append(" ")
in_table = True
else:
out.append(" ")
in_table = True
else:
out.append(" ")
for c in cells:
if c.startswith(":") or c.startswith("-"):
continue
out.append("{} ".format(html_mod.escape(c)))
out.append(" ")
elif in_table is not None and in_table is not True:
# We were in a table header but hit a non-table row
in_table = True
out.append("")
out.append("{} ".format(html_mod.escape(stripped)))
out.append(" ")
elif stripped.startswith("- ") or stripped.startswith("* "):
out.append("{} ".format(html_mod.escape(stripped[2:])))
elif re.match(r"^\d+[.)]\s", stripped):
content = re.sub(r"^\d+[.)]\s", "", stripped)
out.append("{} ".format(html_mod.escape(content)))
else:
rendered = html_mod.escape(line)
rendered = re.sub(r"\*\*(.+?)\*\*", r"\1", rendered)
rendered = re.sub(r"\*(.+?)\*", r"\1", rendered)
rendered = re.sub(r"`(.+?)`", r"\1", rendered)
rendered = re.sub(r"\[(.+?)\]\((.+?)\)", r'\1', rendered)
out.append(rendered + "\n")
if in_code:
out.append("")
if in_table is not None:
out.append("
")
out.append("