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) => ( - - ))} - + renderTable = () =>
+ {this.state.header ? ( +
- {column} -
+ + + {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