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 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."""
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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