minimal sql console API and UI

This commit is contained in:
Gani Georgiev
2026-05-20 16:22:51 +03:00
parent db853850ab
commit b022e138d4
21 changed files with 984 additions and 21 deletions
+1 -1
View File
@@ -197,7 +197,7 @@ export function pageCollections(route) {
t.div(
{ className: "page-content full-height" },
t.header(
{ className: "page-header compact flex-nowrap" },
{ className: "page-header flex-nowrap" },
t.nav(
{ className: "breadcrumbs" },
t.div(null, "Collections"),
+1
View File
@@ -35,3 +35,4 @@
@import "./collectionsOverview.css";
@import "./erd.css";
@import "./hideControls.css";
@import "./sqlConsole.css";
+1
View File
@@ -919,6 +919,7 @@ hr {
.alert {
display: block;
width: 100%;
flex-shrink: 0;
align-content: center;
min-height: var(--lgBtnHeight);
padding: max(5px, calc(var(--inputPadding) - 5px)) max(10px, var(--inputPadding));
+1
View File
@@ -1131,6 +1131,7 @@ button {
&:empty::before {
content: attr(data-placeholder);
color: var(--surfaceTxtDisabledColor);
white-space: normal;
}
}
+5 -10
View File
@@ -174,16 +174,14 @@
column-gap: var(--smSpacing);
row-gap: calc(var(--smSpacing) - 5px);
width: 100%;
flex-shrink: 0;
align-items: center;
margin: -5px 0 calc(var(--spacing) - 5px);
min-height: var(--btnHeight);
margin: -10px 0 calc(var(--spacing) - 10px);
.searchbar {
width: auto;
flex-grow: 1;
}
&.compact {
margin-top: -10px;
margin-bottom: calc(var(--spacing) - 10px);
}
.page-header-secondary-btns,
.page-header-primary-btns {
display: inline-flex;
@@ -206,10 +204,6 @@
}
@media (max-width: 950px) {
gap: 5px;
flex-grow: 1;
.flex-nowrap & {
flex-grow: 0;
}
.btn {
flex-grow: 1;
flex-basis: 0;
@@ -219,7 +213,6 @@
}
@media (max-width: 550px) {
gap: 5px;
flex-grow: 0;
.api-preview-btn {
display: none;
}
@@ -228,6 +221,7 @@
width: auto;
aspect-ratio: 1 / 1;
border-radius: 50%;
min-width: 0;
i + .txt {
display: none;
}
@@ -241,6 +235,7 @@
align-items: center;
width: auto;
z-index: 10;
gap: var(--smSpacing);
color: var(--surfaceTxtHintColor);
font-size: var(--smFontSize);
line-height: var(--smLineHeight);
+32
View File
@@ -0,0 +1,32 @@
.field.sql-console-field {
.input {
resize: vertical;
height: 250px;
min-height: 150px;
max-height: 400px;
@media (max-height: 650px) {
height: 150px;
}
}
}
.sql-console-history-dropdown {
width: 450px;
&.no-items {
width: 300px;
}
.dropdown-item {
gap: 5px;
padding: 5px;
font-size: 0.9em;
line-height: 1.2;
font-family: var(--monospaceFontFamily);
.query {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
}
}
+17
View File
@@ -317,6 +317,22 @@ table {
min-width: 900px;
border-radius: 0;
}
table > :first-child > tr:first-child {
> :first-child {
border-top-left-radius: 0;
}
> :last-child {
border-top-right-radius: 0;
}
}
table > :last-child > tr:last-child {
> :first-child {
border-bottom-left-radius: 0;
}
> :last-child {
border-bottom-right-radius: 0;
}
}
tr {
animation: fadeIn var(--animationSpeed);
}
@@ -342,6 +358,7 @@ table {
td,
th {
height: 59px;
border-radius: 0;
}
}
-1
View File
@@ -15,7 +15,6 @@
line-height: var(--smLineHeight);
font-family: var(--baseFontFamily);
scrollbar-width: thin;
margin: 3px;
padding: 3px 5px;
pointer-events: none;
opacity: 0;
+2
View File
@@ -165,6 +165,8 @@ export function logsList(logsSettings) {
return; // nothing to download
}
console.log(data.bulkSelected);
if (selected.length == 1) {
return app.utils.downloadJSON(
selected[0],
+2
View File
@@ -5,6 +5,7 @@ import { pageApplicationSettings } from "@/settings/application/pageApplicationS
import { pageBackupsSettings } from "@/settings/backups/pageBackupsSettings";
import { pageCronsSettings } from "@/settings/crons/pageCronsSettings";
import { pageMailSettings } from "@/settings/mail/pageMailSettings";
import { pageSQLConsole } from "@/settings/sql/pageSQLConsole";
import { pageStorageSettings } from "@/settings/storage/pageStorageSettings";
import { pageExportCollections } from "@/settings/sync/pageExportCollections";
import { pageImportCollections } from "@/settings/sync/pageImportCollections";
@@ -170,3 +171,4 @@ app.routes.superuserOnly("#/settings/backups", pageBackupsSettings);
app.routes.superuserOnly("#/settings/crons", pageCronsSettings);
app.routes.superuserOnly("#/settings/export-collections", pageExportCollections);
app.routes.superuserOnly("#/settings/import-collections", pageImportCollections);
app.routes.superuserOnly("#/settings/sql", pageSQLConsole);
+486
View File
@@ -0,0 +1,486 @@
import { settingsSidebar } from "../settingsSidebar";
const SQL_HISTORY_STORAGE_KEY = "pbSQLConsoleHistory";
export function pageSQLConsole(route) {
app.store.title = "SQL console";
const uniqueId = "sql_console_" + app.utils.randomString();
const editorId = uniqueId + "editor";
const requestKey = uniqueId + "executeSQL";
const defaultMaxRows = 250;
const pageData = store({
askedForConfirmationAtLeastOnce: false,
isExecuting: false,
maxRows: defaultMaxRows,
query: "",
result: {},
sort: {},
errorMsg: "",
executedHistory: app.utils.getLocalHistory(SQL_HISTORY_STORAGE_KEY, []),
get sortedResultRows() {
// no client-side sort
if (typeof pageData.sort?.index == "undefined") {
const rows = pageData.result?.rows || [];
return pageData.maxRows >= rows.length ? rows : rows.slice(0, pageData.maxRows);
}
const isAsc = !!pageData.sort?.asc;
const sorted = pageData.result?.rows?.toSorted((rowA, rowB) => {
const valA = rowA[pageData.sort.index];
const valB = rowB[pageData.sort.index];
if (isAsc) {
if (valA == valB) {
return 0;
}
if (valA == null) {
return -1;
}
if (valB == null) {
return 1;
}
return valA.localeCompare(valB);
}
if (valA == valB) {
return 0;
}
if (valA == null) {
return 1;
}
if (valB == null) {
return -1;
}
return valB.localeCompare(valA);
}) || [];
return pageData.maxRows >= sorted.length ? sorted : sorted.slice(0, pageData.maxRows);
},
get totalRemainingRows() {
return (pageData.result?.rows?.length << 0) - pageData.sortedResultRows.length;
},
});
function needConfirmation(query) {
if (!query?.trim()) {
return false;
}
// if the hideControls option is enabled ask for confirmation
// at least once no matter of the query
if (
!pageData.askedForConfirmationAtLeastOnce
&& !!app.store.settings?.meta?.hideControls
) {
return true;
}
query = query?.replace(/[\s\;]/gm, " ").toUpperCase() + " ";
return !![
"INSERT ",
"CREATE ",
"UPDATE ",
"DELETE ",
"DROP ",
"DETACH ",
"PRAGMA ",
].find((p) => query.includes(p));
}
async function executeSQLWithConfirm() {
if (!needConfirmation(pageData.query)) {
return executeSQL();
}
pageData.askedForConfirmationAtLeastOnce = true;
return app.modals.confirm(
t.div(
{ className: "txt-center" },
t.h6(
null,
"Be careful and continue only if you really know what you are doing because, depending on the query, the operation could break your application and may not be reversible.",
),
),
() => executeSQL(),
null,
{ yesButton: "Execute", noButton: "Cancel" },
);
}
async function executeSQL() {
pageData.isExecuting = true;
pageData.maxRows = defaultMaxRows;
pageData.result = {};
pageData.sort = {};
pageData.errorMsg = "";
const query = pageData.query.trim();
if (!query) {
app.pb.cancelRequest(requestKey);
pageData.isExecuting = false;
return;
}
try {
// @todo add method to JS SDK
pageData.result = await app.pb.send("/api/sql", {
method: "POST",
body: { query },
requestKey: requestKey,
});
addToHistory(query);
pageData.isExecuting = false;
} catch (err) {
if (!err?.isAbort) {
pageData.isExecuting = false;
pageData.errorMsg = err?.response?.message || err?.message || "Failed to execute query.";
}
}
}
function removeFromHistory(query) {
function looseNormalize(str) {
return str.replace(/[\s\;]/gm, "").toUpperCase();
}
const normalized = looseNormalize(query);
for (let i = pageData.executedHistory.length - 1; i >= 0; i--) {
if (
pageData.executedHistory[i] == query
|| looseNormalize(pageData.executedHistory[i]) == normalized
) {
pageData.executedHistory.splice(i, 1);
}
}
}
function addToHistory(query) {
removeFromHistory(query);
pageData.executedHistory.unshift(pageData.query);
if (pageData.executedHistory.length > 10) {
pageData.executedHistory.splice(10);
}
}
function downloadCSV() {
if (!pageData.sortedResultRows.length) {
return;
}
const data = [pageData.result.columns.map((c) => c.name)].concat(pageData.sortedResultRows);
const name = "export_" + app.utils.toLocalDatetime(new Date()).replace(/[\-\:\. ]/g, "_") + ".csv";
app.utils.downloadCSV(data, name);
}
function toggleSort(index) {
if (pageData.sort?.index == index) {
pageData.sort = {
index: index,
asc: !pageData.sort.asc,
};
} else {
pageData.sort = {
index: index,
asc: true,
};
}
}
const watchers = [
watch(() => JSON.stringify(pageData.executedHistory), (newVal, oldVal) => {
if (typeof oldVal == "undefined") {
return;
}
window.localStorage.setItem(SQL_HISTORY_STORAGE_KEY, newVal);
}),
];
return t.div(
{
pbEvent: "pageSQLConsole",
className: "page",
onunmount: () => {
app.pb.cancelRequest(requestKey);
watchers.forEach((w) => w?.unwatch());
},
},
settingsSidebar(),
t.div(
{ className: "page-content full-height" },
t.header(
{ className: "page-header" },
t.nav(
{ className: "breadcrumbs" },
t.div({ className: "breadcrumb-item" }, "Settings"),
t.div({ className: "breadcrumb-item" }, () => app.store.title),
),
t.div(
{ className: "page-header-secondary-btns" },
t.button(
{
type: "button",
className: "btn circle transparent secondary",
ariaDescription: app.attrs.tooltip("Recently executed queries", "right"),
"html-popovertarget": "sql-console-history-dropdown",
},
t.i({ className: "ri-history-line", ariaHidden: true }),
),
t.div(
{
id: "sql-console-history-dropdown",
className: () =>
`dropdown left sql-console-history-dropdown ${
!pageData.executedHistory.length ? "no-items" : ""
}`,
popover: "auto",
},
() => {
if (!pageData.executedHistory.length) {
return t.span({ className: "txt txt-hint p-5" }, "No recently executed queries.");
}
return pageData.executedHistory.map((item) => {
return t.button(
{
role: "button",
className: "dropdown-item",
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
pageData.query = item;
document.getElementById(editorId)?.click();
},
},
t.span(
{ className: "query" },
() => app.utils.truncate(item, 500),
),
t.small(
{
role: "button",
className: "remove-btn link-hint m-l-auto p-l-5 p-r-5",
title: "Clear",
onauxclick: (e) => {
e.stopPropagation();
return false;
},
onclick: (e) => {
e.stopPropagation();
removeFromHistory(item);
return false;
},
},
t.i({ className: "ri-close-line", ariaHidden: true }),
),
);
});
},
),
),
t.div(
{ className: "page-header-primary-btns" },
t.button(
{
type: "button",
className: () => `btn expanded-lg ${pageData.isExecuting ? "loading" : ""}`,
disabled: () => pageData.isExecuting,
onclick: () => executeSQLWithConfirm(),
},
t.i({ className: "ri-play-large-line", ariaHidden: true }),
t.span({ className: "txt" }, "Execute"),
),
),
),
t.div(
{ className: "field sql-console-field" },
app.components.codeEditor({
id: editorId,
language: "sql",
required: true,
name: "query",
placeholder: "e.g. EXPLAIN QUERY PLAN SELECT * from users WHERE verified=true",
value: () => pageData.query,
oninput: (val) => pageData.query = val,
onblur: (val) => pageData.query = val.trim(),
}),
),
t.div(
{ className: "flex field-help m-b-sm" },
t.button(
{
type: "button",
className: "link-hint m-l-auto",
"html-popovertarget": uniqueId + "caveats_dropdown",
},
() => "SQL console caveats",
),
t.div(
{
id: uniqueId + "caveats_dropdown",
className: "dropdown sm query-caveats-dropdown",
popover: "auto",
},
t.ul(
null,
t.li(null, "The returned rows are limited up to 1000."),
t.li(null, "The executed queries have a max timeout of 3 minutes."),
t.li(null, "The data is returned as byte strings without any additional formatting."),
t.li(null, "Multiple queries are supported but only the result of the last one is returned."),
),
),
),
// error alert
t.div(
{
hidden: () => pageData.isExecuting || !pageData.errorMsg,
className: "alert danger m-b-sm",
},
t.pre(null, () => pageData.errorMsg),
),
// success alert
t.div(
{
hidden: () =>
pageData.isExecuting || pageData.errorMsg || pageData.result?.columns?.length
|| app.utils.isEmpty(pageData.result),
className: "alert success m-b-sm",
},
t.p({ className: "txt-bold" }, "Query executed successfully!"),
t.p(null, "Affected rows: ", () => pageData.result?.affectedRows || 0),
),
// rows
t.div(
{
hidden: () => pageData.isExecuting || !pageData.result?.columns?.length,
className: "page-table-wrapper",
},
t.table(
{ className: "sql-console-table responsive-table optimize" },
t.thead(
{ className: "sticky" },
t.tr(null, () => {
return pageData.result?.columns?.map((col, i) => {
return t.th({
textContent: col.name,
className: () => {
let classes = "sort-handle";
if (pageData.sort?.index == i) {
classes += pageData.sort.asc ? " asc" : " desc";
}
return classes;
},
onclick: () => toggleSort(i),
});
});
}),
),
t.tbody(
null,
() => {
if (!pageData.sortedResultRows.length) {
return t.tr(
null,
t.td(
{ colSpan: pageData.result?.columns?.length || 1, className: "txt-center" },
t.span({ className: "txt-hint" }, "No rows found."),
),
);
}
return pageData.sortedResultRows.map((rowData) => {
return t.tr(
null,
() => {
return pageData.result?.columns?.map((col, j) => {
const val = rowData[j];
return t.td(
{ "html-data-name": col.name },
val == null ? "NULL" : app.utils.truncate(val, 2000),
);
});
},
);
});
},
// load more btn
t.tr(
{
hidden: () => (
pageData.isExecuting
|| !pageData.result?.rows?.length
|| pageData.result.rows.length <= pageData.sortedResultRows.length
),
},
t.td(
{ colSpan: 99 },
t.button(
{
type: "button",
className: "btn lg secondary load-more-btn",
onclick: () => {
pageData.maxRows = pageData.result?.rows?.length || defaultMaxRows;
},
},
t.span({
className: "txt",
textContent: () => `Load remaining (${pageData.totalRemainingRows})`,
}),
),
),
),
),
),
),
t.footer(
{ className: "page-footer" },
t.span(
{
className: () => `exec-time ${pageData.isExecuting ? "faded" : ""}`,
},
"Time: ",
() => (pageData.result?.execTime || 0) + "ms",
),
t.span(
{
hidden: () => !pageData.result?.columns?.length,
className: () => `total-count ${pageData.isExecuting ? "faded" : ""}`,
},
"Rows: ",
() => pageData.result?.rows?.length || 0,
() => {
if (!pageData.result?.rows?.length) {
return;
}
return [
" (",
t.span({
role: "button",
className: "link-hint",
textContent: "Export as CSV",
onclick: downloadCSV,
}),
")",
];
},
),
app.components.credits(),
),
),
);
}
+8 -1
View File
@@ -115,6 +115,13 @@ window.app.store = store({
label: "Import collections",
},
],
Debug: [
{
href: "#/settings/sql",
icon: "ri-terminal-box-line",
label: "SQL console",
},
],
},
predefinedAccentColors: [
@@ -205,7 +212,7 @@ window.app.store = store({
addOrUpdateCollection(collection) {
const index = app.store.collections.findIndex((c) => c.id == collection.id);
if (index >= 0) {
if (app.store.activeCollection.id == collection.id) {
if (app.store.activeCollection?.id == collection.id) {
app.store._activeCollectionIdOrName = collection.id;
}
+23
View File
@@ -844,6 +844,29 @@ const utils = {
utils.download(url, name);
},
/**
* Downloads a CSV file created from the provide array data.
*
* @param {Array} arr The JS array to serialize as CSV
* @param {string} name The result file name.
*/
downloadCSV(arr, name) {
name = name.endsWith(".csv") ? name : name + ".csv";
// seriaze data
let content = arr.map((row) => {
return row.map((v) => "\"" + ("" + v).replaceAll("\"", "\"\"") + "\"").join(",");
}).join("\n");
const blob = new Blob([content], {
type: "text/csv;charset=utf-8;",
});
const url = window.URL.createObjectURL(blob);
utils.download(url, name);
},
/**
* Returns a normalized API URL address that is used in the API example docs.
*