From 08840b3bc2c6e6b720170a9fc276f681122f1e54 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Wed, 24 Dec 2025 10:13:25 +1100 Subject: [PATCH] Add ability to query databases --- app.py | 75 +++++++++++++++++++++++++++++- templates/admin.html | 108 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index 88d8b46..ea09530 100644 --- a/app.py +++ b/app.py @@ -215,6 +215,56 @@ def get_container_detail(container_name: str) -> dict: except Exception as e: return {"error": str(e)} +def run_sql_query(container_name: str, query: str) -> dict: + """ + Execute a SQL query inside a database container using docker exec. + Supports Postgres and MySQL/MariaDB. + """ + try: + if "postgres" in container_name: + # Postgres: use psql with CSV-like output + # -A: unaligned, -F: separator, -t: tuples only (no headers) -> let's keep headers for now + cmd = [DOCKER, "exec", container_name, "psql", "-U", "postgres", "-c", query, "-A", "-F", ","] + elif "mysql" in container_name or "mariadb" in container_name: + # MySQL: use mysql with tab-separated output + cmd = [DOCKER, "exec", container_name, "mysql", "-u", "root", "-e", query, "-B"] + else: + return {"error": "Unsupported database type"} + + out = sh(cmd) + + # Parse output into rows and columns + lines = out.splitlines() + if not lines: + return {"columns": [], "rows": [], "message": "Query executed successfully (no results)"} + + if "postgres" in container_name: + import csv + from io import StringIO + reader = csv.reader(StringIO(out)) + rows = list(reader) + if not rows: return {"columns": [], "rows": []} + # psql -A can include a footer like "(1 row)", filter that out + if rows[-1] and rows[-1][0].startswith("(") and rows[-1][0].endswith(")"): + rows = rows[:-1] + columns = rows[0] + data = rows[1:] + else: + # MySQL is tab-separated by default with -B + rows = [line.split("\t") for line in lines] + columns = rows[0] + data = rows[1:] + + return { + "columns": columns, + "rows": data, + "count": len(data) + } + except subprocess.CalledProcessError as e: + return {"error": e.output or str(e)} + except Exception as e: + return {"error": str(e)} + def is_app_web_container(name: str) -> bool: # Dokku apps typically have containers like ".web.1" return name.endswith(".web.1") and not name.startswith("dokku.") @@ -349,9 +399,11 @@ def collect(): def collect_admin_data(): """ Collects logs and detailed container info for the admin dashboard. + Also identifies database containers for the SQL interface. """ ps_rows = docker_ps_all() apps = [] + databases = [] for r in ps_rows: name = r["name"] @@ -364,12 +416,19 @@ def collect_admin_data(): "logs": get_container_logs(name, lines=50), "detail": get_container_detail(name) }) + elif classify_infra(name) and ("postgres" in name or "mysql" in name): + databases.append({ + "name": name, + "type": "postgres" if "postgres" in name else "mysql" + }) - # Sort by app name + # Sort apps.sort(key=lambda x: x["app"]) + databases.sort(key=lambda x: x["name"]) return { "apps": apps, + "databases": databases, } def parse_human_bytes(s: str) -> int: @@ -443,3 +502,17 @@ def admin(): def api_container_detail(container_name): detail = get_container_detail(container_name) return jsonify(detail) + +# API endpoint for SQL queries +@app.post("/api/sql/query") +@login_required +def api_sql_query(): + data = request.json + container = data.get("container") + query = data.get("query") + + if not container or not query: + return jsonify({"error": "Missing container or query"}), 400 + + result = run_sql_query(container, query) + return jsonify(result) diff --git a/templates/admin.html b/templates/admin.html index 2c27989..e537709 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -553,6 +553,44 @@ {% endfor %} + + +
+
+

SQL Query Interface

+ [^ BACK_TO_TOP] +
+ +
+
+
Query Console
+
+ + +
+ +
+ + +
+