Add ability to query databases

This commit is contained in:
Peter Stockings
2025-12-24 10:13:25 +11:00
parent 40cb631975
commit 08840b3bc2
2 changed files with 182 additions and 1 deletions

75
app.py
View File

@@ -215,6 +215,56 @@ def get_container_detail(container_name: str) -> dict:
except Exception as e: except Exception as e:
return {"error": str(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: def is_app_web_container(name: str) -> bool:
# Dokku apps typically have containers like "<app>.web.1" # Dokku apps typically have containers like "<app>.web.1"
return name.endswith(".web.1") and not name.startswith("dokku.") return name.endswith(".web.1") and not name.startswith("dokku.")
@@ -349,9 +399,11 @@ def collect():
def collect_admin_data(): def collect_admin_data():
""" """
Collects logs and detailed container info for the admin dashboard. Collects logs and detailed container info for the admin dashboard.
Also identifies database containers for the SQL interface.
""" """
ps_rows = docker_ps_all() ps_rows = docker_ps_all()
apps = [] apps = []
databases = []
for r in ps_rows: for r in ps_rows:
name = r["name"] name = r["name"]
@@ -364,12 +416,19 @@ def collect_admin_data():
"logs": get_container_logs(name, lines=50), "logs": get_container_logs(name, lines=50),
"detail": get_container_detail(name) "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"]) apps.sort(key=lambda x: x["app"])
databases.sort(key=lambda x: x["name"])
return { return {
"apps": apps, "apps": apps,
"databases": databases,
} }
def parse_human_bytes(s: str) -> int: def parse_human_bytes(s: str) -> int:
@@ -443,3 +502,17 @@ def admin():
def api_container_detail(container_name): def api_container_detail(container_name):
detail = get_container_detail(container_name) detail = get_container_detail(container_name)
return jsonify(detail) 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)

View File

@@ -553,6 +553,44 @@
</div> </div>
</section> </section>
{% endfor %} {% endfor %}
<!-- SQL Command Center -->
<section id="sql-center" class="station" data-label="[ STATION: SQL_COMMAND_CENTER ]">
<div class="station-header">
<h2>SQL Query Interface</h2>
<a href="#top" class="back-to-top">[^ BACK_TO_TOP]</a>
</div>
<div class="grid" style="grid-template-columns: 1fr;">
<div class="panel">
<div class="panel-title">Query Console</div>
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<select id="sql-db-select"
style="background: #000; color: #00ff88; border: 1px solid #30363d; padding: 8px; font-family: inherit; font-size: 12px; flex: 1;">
<option value="">-- SELECT DATABASE CONTAINER --</option>
{% for db in data.databases %}
<option value="{{ db.name }}">{{ db.name }} [{{ db.type|upper }}]</option>
{% endfor %}
</select>
<button onclick="runQuery()"
style="background: #00ff8822; color: #00ff88; border: 1px solid #00ff88; padding: 0 20px; font-family: inherit; font-size: 11px; font-weight: 700; cursor: pointer;">EXECUTE_QUERY</button>
</div>
<textarea id="sql-editor" spellcheck="false" placeholder="SELECT * FROM table_name LIMIT 10;"
style="width: 100%; height: 120px; background: #000; color: #c9d1d9; border: 1px solid #30363d; padding: 12px; font-family: 'JetBrains Mono', monospace; font-size: 13px; resize: vertical;"></textarea>
</div>
<div id="sql-results-panel" class="panel" style="display: none;">
<div class="panel-title">Query Results</div>
<div id="sql-status" style="font-size: 11px; margin-bottom: 10px; color: #8b949e;"></div>
<div style="overflow-x: auto; max-height: 500px;">
<table id="sql-results-table" style="width: 100%; border-collapse: collapse; font-size: 11px;">
<thead id="sql-thead"></thead>
<tbody id="sql-tbody"></tbody>
</table>
</div>
</div>
</div>
</section>
</div> </div>
<script> <script>
@@ -567,6 +605,76 @@
}); });
} }
async function runQuery() {
const container = document.getElementById('sql-db-select').value;
const query = document.getElementById('sql-editor').value;
const resultsPanel = document.getElementById('sql-results-panel');
const status = document.getElementById('sql-status');
const thead = document.getElementById('sql-thead');
const tbody = document.getElementById('sql-tbody');
if (!container || !query) {
alert('Please select a database and enter a query.');
return;
}
resultsPanel.style.display = 'block';
status.innerHTML = '<span style="color: #00d9ff;">EXECUTING...</span>';
thead.innerHTML = '';
tbody.innerHTML = '';
try {
const response = await fetch('/api/sql/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ container, query })
});
const result = await response.json();
if (result.error) {
status.innerHTML = `<span style="color: #ff5555;">ERROR: ${result.error}</span>`;
return;
}
status.innerHTML = `<span style="color: #00ff88;">SUCCESS: ${result.count || 0} rows found.</span>`;
if (result.columns && result.columns.length > 0) {
const headerRow = document.createElement('tr');
result.columns.forEach(col => {
const th = document.createElement('th');
th.style.padding = '8px';
th.style.textAlign = 'left';
th.style.border = '1px solid #30363d';
th.style.background = 'rgba(0, 217, 255, 0.1)';
th.style.color = '#00d9ff';
th.innerText = col;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
}
if (result.rows && result.rows.length > 0) {
result.rows.forEach(row => {
const tr = document.createElement('tr');
row.forEach(cell => {
const td = document.createElement('td');
td.style.padding = '8px';
td.style.border = '1px solid #30363d';
td.innerText = cell;
tr.appendChild(td);
});
tbody.appendChild(tr);
});
} else if (result.message) {
status.innerHTML = `<span style="color: #00ff88;">${result.message}</span>`;
}
} catch (err) {
status.innerHTML = `<span style="color: #ff5555;">CONNECTION ERROR: ${err.message}</span>`;
}
}
// Auto-scroll logic // Auto-scroll logic
document.querySelectorAll('.terminal-body').forEach(el => { document.querySelectorAll('.terminal-body').forEach(el => {
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;