diff --git a/maubot/management/api/instance_database.py b/maubot/management/api/instance_database.py
index 9830728..7a0eb55 100644
--- a/maubot/management/api/instance_database.py
+++ b/maubot/management/api/instance_database.py
@@ -13,11 +13,13 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import TYPE_CHECKING
+from typing import Union, TYPE_CHECKING
from datetime import datetime
from aiohttp import web
-from sqlalchemy import Table, Column, asc, desc
+from sqlalchemy import Table, Column, asc, desc, exc
+from sqlalchemy.orm import Query
+from sqlalchemy.engine.result import ResultProxy, RowProxy
from ...instance import PluginInstance
from .base import routes
@@ -70,15 +72,55 @@ async def get_table(request: web.Request) -> web.Response:
table = tables[request.match_info.get("table", "")]
except KeyError:
return resp.table_not_found
- db = instance.inst_db
try:
order = [tuple(order.split(":")) for order in request.query.getall("order")]
- order = [(asc if sort == "asc" else desc)(table.columns[column])
+ order = [(asc if sort.lower() == "asc" else desc)(table.columns[column])
if sort else table.columns[column]
for column, sort in order]
except KeyError:
order = []
limit = int(request.query.get("limit", 100))
- query = table.select().order_by(*order).limit(limit)
- data = [[check_type(value) for value in row] for row in db.execute(query)]
+ return execute_query(instance, table.select().order_by(*order).limit(limit))
+
+
+@routes.post("/instance/{id}/database/query")
+async def query(request: web.Request) -> web.Response:
+ instance_id = request.match_info.get("id", "")
+ instance = PluginInstance.get(instance_id, None)
+ if not instance:
+ return resp.instance_not_found
+ elif not instance.inst_db:
+ return resp.plugin_has_no_database
+ data = await request.json()
+ try:
+ sql_query = data["query"]
+ except KeyError:
+ return resp.query_missing
+ return execute_query(instance, sql_query,
+ rows_as_dict=data.get("rows_as_dict", False))
+
+
+def execute_query(instance: PluginInstance, sql_query: Union[str, Query],
+ rows_as_dict: bool = False) -> web.Response:
+ try:
+ res: ResultProxy = instance.inst_db.execute(sql_query)
+ except exc.IntegrityError as e:
+ return resp.sql_integrity_error(e, sql_query)
+ except exc.OperationalError as e:
+ return resp.sql_operational_error(e, sql_query)
+ data = {
+ "ok": True,
+ "query": str(sql_query),
+ }
+ if res.returns_rows:
+ row: RowProxy
+ data["rows"] = [({key: check_type(value) for key, value in row.items()}
+ if rows_as_dict
+ else [check_type(value) for value in row])
+ for row in res]
+ data["columns"] = res.keys()
+ else:
+ data["rowcount"] = res.rowcount
+ if res.is_insert:
+ data["inserted_primary_key"] = res.inserted_primary_key
return web.json_response(data)
diff --git a/maubot/management/api/responses.py b/maubot/management/api/responses.py
index ce227b5..31fc864 100644
--- a/maubot/management/api/responses.py
+++ b/maubot/management/api/responses.py
@@ -16,7 +16,7 @@
from http import HTTPStatus
from aiohttp import web
-
+from sqlalchemy.exc import OperationalError, IntegrityError
class _Response:
@property
@@ -82,6 +82,33 @@ class _Response:
"errcode": "username_or_password_missing",
}, status=HTTPStatus.BAD_REQUEST)
+ @property
+ def query_missing(self) -> web.Response:
+ return web.json_response({
+ "error": "Query missing",
+ "errcode": "query_missing",
+ }, status=HTTPStatus.BAD_REQUEST)
+
+ @staticmethod
+ def sql_operational_error(error: OperationalError, query: str) -> web.Response:
+ return web.json_response({
+ "ok": False,
+ "query": query,
+ "error": str(error.orig),
+ "full_error": str(error),
+ "errcode": "sql_operational_error",
+ }, status=HTTPStatus.BAD_REQUEST)
+
+ @staticmethod
+ def sql_integrity_error(error: IntegrityError, query: str) -> web.Response:
+ return web.json_response({
+ "ok": False,
+ "query": query,
+ "error": str(error.orig),
+ "full_error": str(error),
+ "errcode": "sql_integrity_error",
+ }, status=HTTPStatus.BAD_REQUEST)
+
@property
def bad_auth(self) -> web.Response:
return web.json_response({
diff --git a/maubot/management/frontend/src/api.js b/maubot/management/frontend/src/api.js
index 64bf5a5..aee7fd4 100644
--- a/maubot/management/frontend/src/api.js
+++ b/maubot/management/frontend/src/api.js
@@ -154,8 +154,14 @@ export const putInstance = (instance, id) => defaultPut("instance", instance, id
export const deleteInstance = id => defaultDelete("instance", id)
export const getInstanceDatabase = id => defaultGet(`/instance/${id}/database`)
-export const getInstanceDatabaseTable = (id, table, query = []) =>
- defaultGet(`/instance/${id}/database/${table}?${query.join("&")}`)
+export const queryInstanceDatabase = async (id, query) => {
+ const resp = await fetch(`${BASE_PATH}/instance/${id}/database/query`, {
+ headers: getHeaders(),
+ body: JSON.stringify({ query }),
+ method: "POST",
+ })
+ return await resp.json()
+}
export const getPlugins = () => defaultGet("/plugins")
export const getPlugin = id => defaultGet(`/plugin/${id}`)
@@ -207,7 +213,7 @@ export default {
BASE_PATH,
login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
getInstances, getInstance, putInstance, deleteInstance,
- getInstanceDatabase, getInstanceDatabaseTable,
+ getInstanceDatabase, queryInstanceDatabase,
getPlugins, getPlugin, uploadPlugin, deletePlugin,
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient,
}
diff --git a/maubot/management/frontend/src/pages/dashboard/InstanceDatabase.js b/maubot/management/frontend/src/pages/dashboard/InstanceDatabase.js
index e37fb8c..a1e9a06 100644
--- a/maubot/management/frontend/src/pages/dashboard/InstanceDatabase.js
+++ b/maubot/management/frontend/src/pages/dashboard/InstanceDatabase.js
@@ -14,21 +14,38 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
import React, { Component } from "react"
-import { NavLink, Link, Route, withRouter } from "react-router-dom"
+import { NavLink, Link, withRouter } from "react-router-dom"
import { ReactComponent as ChevronLeft } from "../../res/chevron-left.svg"
import { ReactComponent as OrderDesc } from "../../res/sort-down.svg"
import { ReactComponent as OrderAsc } from "../../res/sort-up.svg"
import api from "../../api"
import Spinner from "../../components/Spinner"
+Map.prototype.map = function(func) {
+ const res = []
+ for (const [key, value] of this) {
+ res.push(func(value, key, this))
+ }
+ return res
+}
+
class InstanceDatabase extends Component {
constructor(props) {
super(props)
this.state = {
tables: null,
- tableContent: null,
+ header: null,
+ content: null,
+ query: "",
+ selectedTable: null,
+
+ error: null,
+
+ prevQuery: null,
+ rowCount: null,
+ insertedPrimaryKey: null,
}
- this.sortBy = []
+ this.order = new Map()
}
async componentWillMount() {
@@ -47,8 +64,8 @@ class InstanceDatabase extends Component {
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
- this.sortBy = []
- this.setState({ tableContent: null })
+ this.order = new Map()
+ this.setState({ header: null, content: null })
this.checkLocationTable()
}
}
@@ -57,106 +74,147 @@ class InstanceDatabase extends Component {
const prefix = `/instance/${this.props.instanceID}/database/`
if (this.props.location.pathname.startsWith(prefix)) {
const table = this.props.location.pathname.substr(prefix.length)
- this.reloadContent(table)
+ this.setState({ selectedTable: table })
+ this.buildSQLQuery(table)
}
}
- getSortQuery(table) {
- const sort = []
- for (const column of this.sortBy) {
- sort.push(`order=${column.name}:${column.sort}`)
+ getSortQueryParams(table) {
+ const order = []
+ for (const [column, sort] of Array.from(this.order.entries()).reverse()) {
+ order.push(`order=${column}:${sort}`)
}
- return sort
+ return order
}
- async reloadContent(name) {
- const table = this.state.tables.get(name)
- const query = this.getSortQuery(table)
- query.push("limit=100")
+ buildSQLQuery(table = this.state.selectedTable) {
+ let query = `SELECT * FROM ${table}`
+
+ if (this.order.size > 0) {
+ const order = Array.from(this.order.entries()).reverse()
+ .map(([column, sort]) => `${column} ${sort}`)
+ query += ` ORDER BY ${order.join(", ")}`
+ }
+
+ query += " LIMIT 100"
+ this.setState({ query }, this.reloadContent)
+ }
+
+ reloadContent = async () => {
+ this.setState({ loading: true })
+ const res = await api.queryInstanceDatabase(this.props.instanceID, this.state.query)
this.setState({
- tableContent: await api.getInstanceDatabaseTable(
- this.props.instanceID, table.name, query),
+ loading: false,
+ prevQuery: null,
+ rowCount: null,
+ insertedPrimaryKey: null,
+ error: null,
})
+ if (!res.ok) {
+ this.setState({
+ error: res.error,
+ })
+ this.buildSQLQuery()
+ } else if (res.rows) {
+ this.setState({
+ header: res.columns,
+ content: res.rows,
+ })
+ } else {
+ this.setState({
+ prevQuery: res.query,
+ rowCount: res.rowcount,
+ insertedPrimaryKey: res.insertedPrimaryKey,
+ })
+ this.buildSQLQuery()
+ }
}
- toggleSort(tableName, column) {
- const index = this.sortBy.indexOf(column)
- if (index >= 0) {
- this.sortBy.splice(index, 1)
- }
- switch (column.sort) {
+ toggleSort(column) {
+ const oldSort = this.order.get(column) || "auto"
+ this.order.delete(column)
+ switch (oldSort) {
+ case "auto":
+ this.order.set(column, "DESC")
+ break
+ case "DESC":
+ this.order.set(column, "ASC")
+ break
+ case "ASC":
default:
- column.sort = "desc"
- this.sortBy.unshift(column)
- break
- case "desc":
- column.sort = "asc"
- this.sortBy.unshift(column)
- break
- case "asc":
- column.sort = null
break
}
- this.forceUpdate()
- this.reloadContent(tableName)
+ this.buildSQLQuery()
}
- renderTableHead = table =>
-
- {Array.from(table.columns.entries()).map(([name, column]) => (
-
- this.toggleSort(table.name, column)}>
- {name}
- {column.sort === "desc" ?
- :
- column.sort === "asc"
- ?
- : null}
-
- |
- ))}
-
-
+ getSortIcon(column) {
+ switch (this.order.get(column)) {
+ case "DESC":
+ return
+ case "ASC":
+ return
+ default:
+ return null
+ }
+ }
- renderTable = ({ match }) => {
- const table = this.state.tables.get(match.params.table)
- return
- {this.state.tableContent ? (
-
- {this.renderTableHead(table)}
-
- {this.state.tableContent.map(row => (
-
- {row.map((column, index) => (
-
- {column}
- |
- ))}
-
+ renderTable = () =>
+ {this.state.header ? (
+
+
+
+ {this.state.header.map(column => (
+
+ this.toggleSort(column)}>
+ {column}
+ {this.getSortIcon(column)}
+
+ |
))}
-
-
- ) : <>
-
- {this.renderTableHead(table)}
-
-
- >}
-
-
- }
+
+
+
+ {this.state.content.map((row, index) => (
+
+ {row.map((column, index) => (
+
+ {column}
+ |
+ ))}
+
+ ))}
+
+
+ ) : this.state.loading ?
: null}
+
renderContent() {
return <>
- {Array.from(this.state.tables.keys()).map(key => (
-
- {key}
+ {this.state.tables.map((_, tbl) => (
+
+ {tbl}
))}
-
+
+ this.setState({ query: evt.target.value })}/>
+
+
+ {this.state.error &&
+ {this.state.error}
+
}
+ {this.state.prevQuery &&
+
+ Executed {this.state.prevQuery} -
+ affected {this.state.rowCount} rows.
+
+ {this.state.insertedPrimaryKey &&
+ Inserted primary key: {this.state.insertedPrimaryKey}
+
}
+
}
+ {this.renderTable()}
>
}
diff --git a/maubot/management/frontend/src/style/pages/instance-database.sass b/maubot/management/frontend/src/style/pages/instance-database.sass
index 6f27035..428dac9 100644
--- a/maubot/management/frontend/src/style/pages/instance-database.sass
+++ b/maubot/management/frontend/src/style/pages/instance-database.sass
@@ -61,6 +61,25 @@
&.active
background-color: $primary
+ > div.query
+ display: flex
+
+ > input
+ +input
+ font-family: "Fira Code", monospace
+ flex: 1
+ margin-right: .5rem
+
+ > button
+ +button
+ +main-color-button
+
+ > div.prev-query
+ +notification($primary, $primary-light)
+
+ span.query
+ font-family: "Fira Code", monospace
+
> div.table
overflow-x: auto
overflow-y: hidden