diff --git a/ada b/ada index 4714945..d6bdbec 100755 --- a/ada +++ b/ada @@ -694,6 +694,7 @@ usage() { ${GREEN}ada enable${RST} Enable a service ${GREEN}ada disable${RST} Disable a service ${GREEN}ada watch${RST} Start supervisor loop (foreground) + ${GREEN}ada dashboard${RST} [-p PORT] Launch web dashboard (default: 7070) ${BOLD}EXAMPLES${RST} ada start all Start all enabled services @@ -800,6 +801,18 @@ case "${cmd}" in do_watch ;; + dashboard|dash|d) + port=7070 + while [[ $# -gt 0 ]]; do + case "$1" in + -p|--port) port="$2"; shift 2 ;; + *) die "unknown option: $1 (try: ada dashboard -p 8080)" ;; + esac + done + info "starting dashboard on ${BOLD}http://0.0.0.0:${port}${RST}" + exec python3 "${SCRIPT_DIR}/ada-dashboard.py" "${port}" + ;; + version|--version|-v) printf 'ada v%s\n' "${ADA_VERSION}" ;; diff --git a/ada-dashboard.py b/ada-dashboard.py new file mode 100644 index 0000000..4a6d50f --- /dev/null +++ b/ada-dashboard.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""ada dashboard — lightweight web UI for ATS tasks + ada service status + log tails.""" + +import glob +import http.server +import json +import os +import subprocess +import sys +import urllib.parse +import urllib.request + +ATS_URL = os.environ.get("ATS_URL", "https://ats.difflab.ai") +ADA_HOME = os.path.expanduser("~/.ada") +SERVICES_JSON = os.path.join(os.path.dirname(os.path.abspath(__file__)), "services.json") +LOG_DIRS = [os.path.join(ADA_HOME, "logs"), "/tmp"] +LOG_TAIL_LINES = 80 +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 7070 + + +def get_services(): + """Read ada services and their live status.""" + try: + with open(SERVICES_JSON) as f: + services = json.load(f) + except Exception: + return [] + + result = [] + for svc in services: + name = svc.get("name", "") + pid_file = os.path.join(ADA_HOME, "pids", f"{name}.pid") + state_file = os.path.join(ADA_HOME, "state", f"{name}.json") + log_file = os.path.join(ADA_HOME, "logs", f"{name}.log") + + pid = None + running = False + if os.path.isfile(pid_file): + try: + pid = int(open(pid_file).read().strip()) + os.kill(pid, 0) + running = True + except (ValueError, ProcessLookupError, PermissionError): + pid = None + + state = {} + if os.path.isfile(state_file): + try: + state = json.load(open(state_file)) + except Exception: + pass + + uptime = None + started_at = state.get("started_at") + if running and started_at: + import time + uptime = int(time.time()) - int(started_at) + + status = "running" if running else ("disabled" if not svc.get("enabled", True) else "stopped") + if state.get("crash_loop"): + status = "crash-loop" + + result.append({ + "name": name, + "enabled": svc.get("enabled", True), + "pid": pid, + "status": status, + "uptime": uptime, + "restart_count": state.get("restart_count", 0), + "has_log": os.path.isfile(log_file), + }) + return result + + +def get_ats_tasks(limit=50, status=None): + """Fetch tasks from ATS API.""" + params = {"limit": str(limit)} + if status: + params["status"] = status + url = f"{ATS_URL}/tasks?{urllib.parse.urlencode(params)}" + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read()) + except Exception as e: + return {"error": str(e), "tasks": []} + + +def get_log_files(): + """List available log files from /tmp and ~/.ada/logs.""" + logs = [] + for d in LOG_DIRS: + for path in sorted(glob.glob(os.path.join(d, "*.log"))): + try: + stat = os.stat(path) + logs.append({ + "path": path, + "name": os.path.basename(path), + "dir": d, + "size": stat.st_size, + "mtime": stat.st_mtime, + }) + except OSError: + pass + logs.sort(key=lambda x: x["mtime"], reverse=True) + return logs + + +def tail_file(path, lines=LOG_TAIL_LINES): + """Return last N lines of a file.""" + try: + result = subprocess.run( + ["tail", "-n", str(lines), path], + capture_output=True, text=True, timeout=3 + ) + return result.stdout + except Exception as e: + return f"Error reading log: {e}" + + +DASHBOARD_HTML = r""" + + + + +Ada Dashboard + + + +
+

Ada Dashboard

+ Refreshes every 5s +
+
+
+ +
+
+

Services

+ 0 +
+
+
Loading...
+
+
+ + +
+
+

ATS Tasks

+ 0 +
+
+ + + + + +
+
+
Loading...
+
+
+ + +
+
+

Logs

+
+
+ + + +
+
+
Select a log file above to view its tail.
+
+
+
+
+ + + + +""" + + +class DashboardHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + # Suppress default access logs for cleanliness + pass + + def send_json(self, data, status=200): + body = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def send_html(self, html, status=200): + body = html.encode() + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + params = urllib.parse.parse_qs(parsed.query) + + if path == "/": + self.send_html(DASHBOARD_HTML) + + elif path == "/api/services": + self.send_json(get_services()) + + elif path == "/api/tasks": + limit = params.get("limit", ["50"])[0] + status = params.get("status", [None])[0] + data = get_ats_tasks(limit=int(limit), status=status) + self.send_json(data) + + elif path == "/api/logs": + self.send_json(get_log_files()) + + elif path == "/api/logs/tail": + log_path = params.get("path", [None])[0] + if not log_path: + self.send_json({"error": "missing path param"}, 400) + return + # Security: only allow files ending in .log under known dirs + real = os.path.realpath(log_path) + allowed = any(real.startswith(os.path.realpath(d)) for d in LOG_DIRS) + if not allowed or not real.endswith(".log"): + self.send_json({"error": "access denied"}, 403) + return + content = tail_file(real) + self.send_json({"path": real, "content": content}) + + else: + self.send_response(404) + self.end_headers() + + +def main(): + server = http.server.HTTPServer(("0.0.0.0", PORT), DashboardHandler) + print(f"Ada Dashboard running on http://0.0.0.0:{PORT}") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down.") + server.server_close() + + +if __name__ == "__main__": + main()