190 lines
7.8 KiB
HTML
190 lines
7.8 KiB
HTML
|
|
{% 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 %}
|