Add ability to query databases
This commit is contained in:
75
app.py
75
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 "<app>.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)
|
||||
|
||||
@@ -553,6 +553,44 @@
|
||||
</div>
|
||||
</section>
|
||||
{% 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>
|
||||
|
||||
<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
|
||||
document.querySelectorAll('.terminal-body').forEach(el => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
|
||||
Reference in New Issue
Block a user