feat(settings): add read-only SQL console with diagnostic presets
This commit is contained in:
parent
c5478b7e29
commit
1b6b37e96e
9
RELEASE_NOTES_v2.3.8.md
Normal file
9
RELEASE_NOTES_v2.3.8.md
Normal 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
|
||||
@ -5,13 +5,14 @@ Settings Frontend Views
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import re
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
|
||||
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()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
@ -33,6 +34,13 @@ SKIP_COLUMN_LINE_RE = re.compile(
|
||||
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:
|
||||
sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.DOTALL)
|
||||
@ -291,6 +299,67 @@ class MigrationExecution(BaseModel):
|
||||
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"])
|
||||
def migration_statuses():
|
||||
"""Check migration files against current schema and return per-file color status."""
|
||||
|
||||
@ -125,6 +125,9 @@
|
||||
<a class="nav-link" href="/settings/migrations">
|
||||
<i class="bi bi-database me-2"></i>DB Migrationer
|
||||
</a>
|
||||
<a class="nav-link" href="/settings/sql">
|
||||
<i class="bi bi-terminal me-2"></i>SQL Console
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
189
app/settings/frontend/sql_console.html
Normal file
189
app/settings/frontend/sql_console.html
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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 %}
|
||||
Loading…
Reference in New Issue
Block a user