feat(settings): add read-only SQL console with diagnostic presets

This commit is contained in:
Christian 2026-05-16 13:40:58 +02:00
parent c5478b7e29
commit 1b6b37e96e
4 changed files with 272 additions and 2 deletions

9
RELEASE_NOTES_v2.3.8.md Normal file
View File

@ -0,0 +1,9 @@
# Release Notes: v2.3.8
**Date:** 16. maj 2026
## Summary
- Added secure read-only SQL Console page under /settings/sql
- Added superadmin-protected execute endpoint /settings/sql/execute (SELECT/WITH only)
- Added SQL Console nav link in settings
- Added preset query buttons for telefoni/mission diagnostics
- files: app/settings/backend/views.py, app/settings/frontend/sql_console.html, app/settings/frontend/settings.html

View File

@ -5,13 +5,14 @@ Settings Frontend Views
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import re import re
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, HTTPException, Depends
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db_connection, release_db_connection, execute_query_single from app.core.database import get_db_connection, release_db_connection, execute_query_single, execute_query
from app.core.auth_dependencies import require_superadmin
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
@ -33,6 +34,13 @@ SKIP_COLUMN_LINE_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
SQL_COMMENT_BLOCK_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
SQL_COMMENT_LINE_RE = re.compile(r"--[^\n]*")
SQL_FORBIDDEN_RE = re.compile(
r"\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|comment|copy|vacuum|analyze|refresh|merge)\b",
re.IGNORECASE,
)
def _strip_sql_comments(sql: str) -> str: def _strip_sql_comments(sql: str) -> str:
sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.DOTALL) sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.DOTALL)
@ -291,6 +299,67 @@ class MigrationExecution(BaseModel):
file_name: str file_name: str
class SqlConsoleRequest(BaseModel):
query: str
limit: int = 200
def _sanitize_and_validate_sql(sql: str) -> str:
raw = (sql or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="SQL query is required")
cleaned = SQL_COMMENT_BLOCK_RE.sub("", raw)
cleaned = SQL_COMMENT_LINE_RE.sub("", cleaned).strip()
if not cleaned:
raise HTTPException(status_code=400, detail="SQL query is empty after removing comments")
lowered = cleaned.lower()
if not (lowered.startswith("select") or lowered.startswith("with")):
raise HTTPException(status_code=400, detail="Only SELECT/WITH queries are allowed")
single_statement = cleaned.rstrip(";").strip()
if ";" in single_statement:
raise HTTPException(status_code=400, detail="Only one SQL statement is allowed")
if SQL_FORBIDDEN_RE.search(single_statement):
raise HTTPException(status_code=400, detail="Query contains forbidden SQL keywords")
return single_statement
@router.get("/settings/sql", response_class=HTMLResponse, tags=["Frontend"])
async def sql_console_page(request: Request, _current_user: dict = Depends(require_superadmin)):
return templates.TemplateResponse(
"settings/frontend/sql_console.html",
{
"request": request,
"title": "SQL Console",
},
)
@router.post("/settings/sql/execute", tags=["Frontend"])
async def execute_sql_console_query(payload: SqlConsoleRequest, _current_user: dict = Depends(require_superadmin)):
query = _sanitize_and_validate_sql(payload.query)
limit = payload.limit if isinstance(payload.limit, int) else 200
if limit < 1:
limit = 1
if limit > 1000:
limit = 1000
wrapped_query = f"SELECT * FROM ({query}) AS bmc_sql_console LIMIT %s"
rows = execute_query(wrapped_query, (limit,)) or []
columns = list(rows[0].keys()) if rows else []
return {
"columns": columns,
"rows": rows,
"row_count": len(rows),
"limit": limit,
}
@router.get("/settings/migrations/status", tags=["Frontend"]) @router.get("/settings/migrations/status", tags=["Frontend"])
def migration_statuses(): def migration_statuses():
"""Check migration files against current schema and return per-file color status.""" """Check migration files against current schema and return per-file color status."""

View File

@ -125,6 +125,9 @@
<a class="nav-link" href="/settings/migrations"> <a class="nav-link" href="/settings/migrations">
<i class="bi bi-database me-2"></i>DB Migrationer <i class="bi bi-database me-2"></i>DB Migrationer
</a> </a>
<a class="nav-link" href="/settings/sql">
<i class="bi bi-terminal me-2"></i>SQL Console
</a>
</nav> </nav>
</div> </div>
</div> </div>

View File

@ -0,0 +1,189 @@
{% extends "shared/frontend/base.html" %}
{% block title %}SQL Console - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="mb-1"><i class="bi bi-database me-2"></i>SQL Console</h2>
<div class="text-muted small">Read-only SQL for superadmin. Kun SELECT/WITH queries.</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Presets</label>
<div class="d-flex flex-wrap gap-2" id="sqlPresets">
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="serverTime">Server time</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="telefoniLatest">Telefoni latest</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="telefoniCount">Telefoni count</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="missionLatest">Mission latest</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="sourceCompare">Source compare</button>
</div>
</div>
<div class="mb-3">
<label for="sqlQuery" class="form-label">SQL query</label>
<textarea id="sqlQuery" class="form-control" rows="8" spellcheck="false" placeholder="SELECT * FROM telefoni_opkald ORDER BY started_at DESC"></textarea>
</div>
<div class="row g-3 align-items-end">
<div class="col-md-2">
<label for="sqlLimit" class="form-label">Limit</label>
<input id="sqlLimit" type="number" class="form-control" value="200" min="1" max="1000" />
</div>
<div class="col-md-10 d-flex gap-2 justify-content-end">
<button id="runSqlBtn" class="btn btn-primary">
<i class="bi bi-play-fill me-1"></i>Run Query
</button>
</div>
</div>
<div id="sqlStatus" class="alert alert-info py-2 mt-3 mb-0">Ready</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-striped align-middle mb-0">
<thead id="sqlResultHead"></thead>
<tbody id="sqlResultBody">
<tr><td class="text-muted">No results yet</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const SQL_PRESETS = {
serverTime: {
query: 'SELECT now() AS server_time',
limit: 50,
},
telefoniLatest: {
query: `SELECT id, callid, direction, ekstern_nummer, started_at, ended_at, duration_sec\nFROM telefoni_opkald\nORDER BY started_at DESC\nLIMIT 50`,
limit: 50,
},
telefoniCount: {
query: 'SELECT COUNT(*) AS total_calls, MAX(started_at) AS latest_started_at FROM telefoni_opkald',
limit: 50,
},
missionLatest: {
query: `SELECT call_id, state, caller_number, contact_name, company_name, started_at, ended_at\nFROM mission_call_state\nORDER BY started_at DESC\nLIMIT 50`,
limit: 50,
},
sourceCompare: {
query: `WITH telefoni AS (\n SELECT 'telefoni_opkald'::text AS source, COUNT(*)::bigint AS total_calls, MAX(started_at) AS latest_started_at\n FROM telefoni_opkald\n), mission AS (\n SELECT 'mission_call_state'::text AS source, COUNT(*)::bigint AS total_calls, MAX(started_at) AS latest_started_at\n FROM mission_call_state\n)\nSELECT * FROM telefoni\nUNION ALL\nSELECT * FROM mission`,
limit: 50,
},
};
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function renderResults(data) {
const head = document.getElementById('sqlResultHead');
const body = document.getElementById('sqlResultBody');
const columns = Array.isArray(data.columns) ? data.columns : [];
const rows = Array.isArray(data.rows) ? data.rows : [];
if (columns.length === 0) {
head.innerHTML = '';
body.innerHTML = '<tr><td class="text-muted">No rows returned</td></tr>';
return;
}
head.innerHTML = '<tr>' + columns.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr>';
if (rows.length === 0) {
body.innerHTML = `<tr><td colspan="${columns.length}" class="text-muted">No rows returned</td></tr>`;
return;
}
body.innerHTML = rows.map((row) => {
return '<tr>' + columns.map((c) => {
const value = row[c];
if (value === null || value === undefined) return '<td class="text-muted">NULL</td>';
if (typeof value === 'object') return `<td>${escapeHtml(JSON.stringify(value))}</td>`;
return `<td>${escapeHtml(String(value))}</td>`;
}).join('') + '</tr>';
}).join('');
}
async function runSqlQuery() {
const query = (document.getElementById('sqlQuery').value || '').trim();
const limitInput = Number(document.getElementById('sqlLimit').value || 200);
const limit = Number.isFinite(limitInput) ? limitInput : 200;
const status = document.getElementById('sqlStatus');
const button = document.getElementById('runSqlBtn');
status.className = 'alert alert-info py-2 mt-3 mb-0';
status.textContent = 'Running query...';
button.disabled = true;
try {
const response = await fetch('/settings/sql/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ query, limit }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail || 'Failed to run query';
status.className = 'alert alert-danger py-2 mt-3 mb-0';
status.textContent = message;
return;
}
const data = await response.json();
renderResults(data);
status.className = 'alert alert-success py-2 mt-3 mb-0';
status.textContent = `Rows returned: ${data.row_count} (limit ${data.limit})`;
} catch (error) {
status.className = 'alert alert-danger py-2 mt-3 mb-0';
status.textContent = 'Network or server error while running query';
} finally {
button.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', () => {
const queryEl = document.getElementById('sqlQuery');
const limitEl = document.getElementById('sqlLimit');
document.getElementById('runSqlBtn').addEventListener('click', runSqlQuery);
queryEl.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
runSqlQuery();
}
});
document.querySelectorAll('#sqlPresets [data-preset]').forEach((button) => {
button.addEventListener('click', () => {
const key = button.getAttribute('data-preset');
const preset = SQL_PRESETS[key];
if (!preset) return;
queryEl.value = preset.query;
limitEl.value = String(preset.limit || 50);
queryEl.focus();
});
});
queryEl.value = SQL_PRESETS.serverTime.query;
limitEl.value = String(SQL_PRESETS.serverTime.limit);
});
</script>
{% endblock %}