diff --git a/maubot/instance.py b/maubot/instance.py index 15ef6ba..3d51bbd 100644 --- a/maubot/instance.py +++ b/maubot/instance.py @@ -30,7 +30,7 @@ from mautrix.types import UserID from .db import DBPlugin from .config import Config from .client import Client -from .loader import PluginLoader +from .loader import PluginLoader, ZippedPluginLoader from .plugin_base import Plugin log = logging.getLogger("maubot.instance") @@ -52,6 +52,8 @@ class PluginInstance: plugin: Plugin config: BaseProxyConfig base_cfg: RecursiveDict[CommentedMap] + inst_db: sql.engine.Engine + inst_db_tables: Dict[str, sql.Table] started: bool def __init__(self, db_instance: DBPlugin): @@ -62,6 +64,8 @@ class PluginInstance: self.loader = None self.client = None self.plugin = None + self.inst_db = None + self.inst_db_tables = None self.base_cfg = None self.cache[self.id] = self @@ -73,8 +77,16 @@ class PluginInstance: "started": self.started, "primary_user": self.primary_user, "config": self.db_instance.config, + "database": self.inst_db is not None, } + def get_db_tables(self) -> Dict[str, sql.Table]: + if not self.inst_db_tables: + metadata = sql.MetaData() + metadata.reflect(self.inst_db) + self.inst_db_tables = metadata.tables + return self.inst_db_tables + def load(self) -> bool: if not self.loader: try: @@ -89,6 +101,9 @@ class PluginInstance: self.log.error(f"Failed to get client for user {self.primary_user}") self.db_instance.enabled = False return False + if self.loader.meta.database: + db_path = os.path.join(self.mb_config["plugin_directories.db"], self.id) + self.inst_db = sql.create_engine(f"sqlite:///{db_path}.db") self.log.debug("Plugin instance dependencies loaded") self.loader.references.add(self) self.client.references.add(self) @@ -105,7 +120,11 @@ class PluginInstance: pass self.db.delete(self.db_instance) self.db.commit() - # TODO delete plugin db + if self.inst_db: + self.inst_db.dispose() + ZippedPluginLoader.trash( + os.path.join(self.mb_config["plugin_directories.db"], f"{self.id}.db"), + reason="deleted") def load_config(self) -> CommentedMap: return yaml.load(self.db_instance.config) @@ -135,12 +154,9 @@ class PluginInstance: except (FileNotFoundError, KeyError): self.base_cfg = None self.config = config_class(self.load_config, lambda: self.base_cfg, self.save_config) - db = None - if self.loader.meta.database: - db_path = os.path.join(self.mb_config["plugin_directories.db"], self.id) - db = sql.create_engine(f"sqlite:///{db_path}.db") self.plugin = cls(client=self.client.client, loop=self.loop, http=self.client.http_client, - instance_id=self.id, log=self.log, config=self.config, database=db) + instance_id=self.id, log=self.log, config=self.config, + database=self.inst_db) try: await self.plugin.start() except Exception: @@ -148,6 +164,7 @@ class PluginInstance: self.db_instance.enabled = False return self.started = True + self.inst_db_tables = None self.log.info(f"Started instance of {self.loader.meta.id} v{self.loader.meta.version} " f"with user {self.client.id}") @@ -162,6 +179,7 @@ class PluginInstance: except Exception: self.log.exception("Failed to stop instance") self.plugin = None + self.inst_db_tables = None @classmethod def get(cls, instance_id: str, db_instance: Optional[DBPlugin] = None diff --git a/maubot/management/api/__init__.py b/maubot/management/api/__init__.py index ceb86ef..c382e31 100644 --- a/maubot/management/api/__init__.py +++ b/maubot/management/api/__init__.py @@ -19,13 +19,7 @@ from asyncio import AbstractEventLoop from ...config import Config from .base import routes, set_config, set_loop from .middleware import auth, error -from .auth import web as _ -from .plugin import web as _ -from .instance import web as _ -from .client import web as _ -from .client_proxy import web as _ -from .client_auth import web as _ -from .dev_open import web as _ +from . import auth, plugin, instance, database, client, client_proxy, client_auth, dev_open from .log import stop_all as stop_log_sockets, init as init_log_listener diff --git a/maubot/management/api/database.py b/maubot/management/api/database.py new file mode 100644 index 0000000..1daa60f --- /dev/null +++ b/maubot/management/api/database.py @@ -0,0 +1,61 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# 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 aiohttp import web +from sqlalchemy import Table, Column + +from ...instance import PluginInstance +from .base import routes +from .responses import resp + + +@routes.get("/instance/{id}/database") +async def get_database(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 + if TYPE_CHECKING: + table: Table + column: Column + return web.json_response({ + table.name: { + "columns": { + column.name: { + "type": str(column.type), + "unique": column.unique or False, + "default": column.default, + "nullable": column.nullable, + "primary": column.primary_key, + "autoincrement": column.autoincrement, + } for column in table.columns + }, + } for table in instance.get_db_tables().values() + }) + + +@routes.get("/instance/{id}/database/{table}") +async def get_table(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 + tables = instance.get_db_tables() diff --git a/maubot/management/api/instance.py b/maubot/management/api/instance.py index ad7f429..80d0c08 100644 --- a/maubot/management/api/instance.py +++ b/maubot/management/api/instance.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from json import JSONDecodeError -from http import HTTPStatus from aiohttp import web diff --git a/maubot/management/api/responses.py b/maubot/management/api/responses.py index 34fd110..e34747e 100644 --- a/maubot/management/api/responses.py +++ b/maubot/management/api/responses.py @@ -152,6 +152,13 @@ class _Response: "errcode": "server_not_found", }, status=HTTPStatus.NOT_FOUND) + @property + def plugin_has_no_database(self) -> web.Response: + return web.json_response({ + "error": "Given plugin does not have a database", + "errcode": "plugin_has_no_database", + }) + @property def method_not_allowed(self) -> web.Response: return web.json_response({ diff --git a/maubot/management/frontend/src/api.js b/maubot/management/frontend/src/api.js index d01952e..773c6fd 100644 --- a/maubot/management/frontend/src/api.js +++ b/maubot/management/frontend/src/api.js @@ -153,6 +153,8 @@ export const getInstance = id => defaultGet(`/instance/${id}`) 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 getPlugins = () => defaultGet("/plugins") export const getPlugin = id => defaultGet(`/plugin/${id}`) export const deletePlugin = id => defaultDelete("plugin", id) @@ -203,6 +205,7 @@ export default { BASE_PATH, login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled, getInstances, getInstance, putInstance, deleteInstance, + getInstanceDatabase, getPlugins, getPlugin, uploadPlugin, deletePlugin, getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient, } diff --git a/maubot/management/frontend/src/pages/dashboard/Instance.js b/maubot/management/frontend/src/pages/dashboard/Instance.js index 1df090b..f68a07b 100644 --- a/maubot/management/frontend/src/pages/dashboard/Instance.js +++ b/maubot/management/frontend/src/pages/dashboard/Instance.js @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import React from "react" -import { NavLink, withRouter } from "react-router-dom" +import { Link, NavLink, Route, Switch, withRouter } from "react-router-dom" import AceEditor from "react-ace" import "brace/mode/yaml" import "brace/theme/github" @@ -23,6 +23,7 @@ import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/P import api from "../../api" import Spinner from "../../components/Spinner" import BaseMainView from "./BaseMainView" +import InstanceDatabase from "./InstanceDatabase" const InstanceListEntry = ({ entry }) => ( @@ -137,47 +138,65 @@ class Instance extends BaseMainView { } render() { - return
- - - this.setState({ enabled })}/> - this.setState({ started })}/> - this.setState({ primary_user: id })}/> - this.setState({ type: id })}/> - - {!this.isNew && - this.setState({ config })} - name="config" value={this.state.config} - editorProps={{ - fontSize: "10pt", - $blockScrolling: true, - }}/>} -
- {!this.isNew && ( - - )} - -
- {this.renderLogButton(`instance.${this.state.id}`)} -
{this.state.error}
-
+ return + + + } + + renderDatabase = () => + + renderMain = () =>
+ + + this.setState({ enabled })}/> + this.setState({ started })}/> + this.setState({ primary_user: id })}/> + this.setState({ type: id })}/> + + {!this.isNew && + this.setState({ config })} + name="config" value={this.state.config} + editorProps={{ + fontSize: "10pt", + $blockScrolling: true, + }}/>} +
+ {!this.isNew && ( + + )} + +
+ {!this.isNew &&
+ {this.props.entry.database && ( + + View database + + )} + +
} +
{this.state.error}
+
} export default withRouter(Instance) diff --git a/maubot/management/frontend/src/pages/dashboard/InstanceDatabase.js b/maubot/management/frontend/src/pages/dashboard/InstanceDatabase.js new file mode 100644 index 0000000..18b5816 --- /dev/null +++ b/maubot/management/frontend/src/pages/dashboard/InstanceDatabase.js @@ -0,0 +1,101 @@ +// maubot - A plugin-based Matrix bot system. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// 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 } 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" + +class InstanceDatabase extends Component { + constructor(props) { + super(props) + this.state = { + tables: null, + sortBy: null, + } + } + + async componentWillMount() { + const tables = new Map(Object.entries(await api.getInstanceDatabase(this.props.instanceID))) + for (const table of tables.values()) { + table.columns = new Map(Object.entries(table.columns)) + for (const column of table.columns.values()) { + column.sort = "desc" + } + } + this.setState({ tables }) + } + + toggleSort(column) { + column.sort = column.sort === "desc" ? "asc" : "desc" + this.forceUpdate() + } + + renderTable = ({ match }) => { + const table = this.state.tables.get(match.params.table) + console.log(table) + return
+ + + + {Array.from(table.columns.entries()).map(([name, column]) => ( + + ))} + + + + +
+ this.toggleSort(column)}> + {name} + {column.sort === "desc" ? : } + +
+
+ } + + renderContent() { + return <> +
+ {Object.keys(this.state.tables).map(key => ( + + {key} + + ))} +
+ + + } + + render() { + return
+
+ + + Back + +
+ {this.state.tables + ? this.renderContent() + : } +
+ } +} + +export default InstanceDatabase diff --git a/maubot/management/frontend/src/pages/dashboard/index.js b/maubot/management/frontend/src/pages/dashboard/index.js index 28321ef..aa07154 100644 --- a/maubot/management/frontend/src/pages/dashboard/index.js +++ b/maubot/management/frontend/src/pages/dashboard/index.js @@ -184,9 +184,9 @@ class Dashboard extends Component { -
-
this.setState({ sidebarOpen: !this.state.sidebarOpen })}> +
this.setState({ sidebarOpen: !this.state.sidebarOpen })}> +
diff --git a/maubot/management/frontend/src/res/chevron-left.svg b/maubot/management/frontend/src/res/chevron-left.svg new file mode 100644 index 0000000..0cdead5 --- /dev/null +++ b/maubot/management/frontend/src/res/chevron-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/maubot/management/frontend/src/res/sort-down.svg b/maubot/management/frontend/src/res/sort-down.svg new file mode 100644 index 0000000..23f0fdd --- /dev/null +++ b/maubot/management/frontend/src/res/sort-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/maubot/management/frontend/src/res/sort-up.svg b/maubot/management/frontend/src/res/sort-up.svg new file mode 100644 index 0000000..5554514 --- /dev/null +++ b/maubot/management/frontend/src/res/sort-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/maubot/management/frontend/src/style/base/elements.sass b/maubot/management/frontend/src/style/base/elements.sass index fc0b24f..67d6d75 100644 --- a/maubot/management/frontend/src/style/base/elements.sass +++ b/maubot/management/frontend/src/style/base/elements.sass @@ -62,13 +62,13 @@ > button, > .button flex: 1 - &:first-of-type + &:first-child margin-right: .5rem - &:last-of-type + &:last-child margin-left: .5rem - &:first-of-type:last-of-type + &:first-child:last-child margin: 0 =vertical-button-group() diff --git a/maubot/management/frontend/src/style/pages/dashboard.sass b/maubot/management/frontend/src/style/pages/dashboard.sass index d04cbba..67ff779 100644 --- a/maubot/management/frontend/src/style/pages/dashboard.sass +++ b/maubot/management/frontend/src/style/pages/dashboard.sass @@ -76,13 +76,14 @@ @import client/index @import instance + @import instance-database @import plugin > div margin: 2rem 4rem @media screen and (max-width: 50rem) - margin: 2rem 1rem + margin: 1rem > div.not-found, > div.home text-align: center @@ -95,10 +96,13 @@ margin: 1rem .5rem width: calc(100% - 1rem) - button.open-log + button.open-log, a.open-database +button +main-color-button + a.open-database + +link-button + div.error +notification($error) margin: 1rem .5rem diff --git a/maubot/management/frontend/src/style/pages/instance-database.sass b/maubot/management/frontend/src/style/pages/instance-database.sass new file mode 100644 index 0000000..fb0df77 --- /dev/null +++ b/maubot/management/frontend/src/style/pages/instance-database.sass @@ -0,0 +1,78 @@ +// maubot - A plugin-based Matrix bot system. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +> div.instance-database + margin: 0 + + > div.topbar + background-color: $primary-light + + display: flex + justify-items: center + align-items: center + + > a + display: flex + justify-items: center + align-items: center + text-decoration: none + user-select: none + + height: 2.5rem + width: 100% + + > *:not(.topbar) + margin: 2rem 4rem + + @media screen and (max-width: 50rem) + margin: 1rem + + > div.tables + display: flex + flex-wrap: wrap + + > a + +link-button + color: black + flex: 1 + + border-bottom: 2px solid $primary + + padding: .25rem + margin: .25rem + + &:hover + background-color: $primary-light + border-bottom: 2px solid $primary-dark + + &.active + background-color: $primary + + > div.table + table + font-family: "Fira Code", monospace + width: 100% + box-sizing: border-box + + > thead + font-weight: bold + + > tr > td > span + align-items: center + justify-items: center + display: flex + cursor: pointer + user-select: none diff --git a/maubot/management/frontend/src/style/pages/topbar.sass b/maubot/management/frontend/src/style/pages/topbar.sass index c241974..26618e2 100644 --- a/maubot/management/frontend/src/style/pages/topbar.sass +++ b/maubot/management/frontend/src/style/pages/topbar.sass @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -.topbar +> .topbar background-color: $primary display: flex @@ -28,7 +28,10 @@ // Hamburger menu based on "Pure CSS Hamburger fold-out menu" codepen by Erik Terwan (MIT license) // https://codepen.io/erikterwan/pen/EVzeRP -.hamburger +> .topbar + user-select: none + +> .topbar > .hamburger display: block user-select: none cursor: pointer @@ -42,6 +45,7 @@ background: white border-radius: 3px + user-select: none z-index: 1