Add beginning of database explorer

This commit is contained in:
Tulir Asokan 2018-12-27 21:31:32 +02:00
parent 0a39c1365d
commit dc22f35d08
16 changed files with 357 additions and 66 deletions

View File

@ -30,7 +30,7 @@ from mautrix.types import UserID
from .db import DBPlugin from .db import DBPlugin
from .config import Config from .config import Config
from .client import Client from .client import Client
from .loader import PluginLoader from .loader import PluginLoader, ZippedPluginLoader
from .plugin_base import Plugin from .plugin_base import Plugin
log = logging.getLogger("maubot.instance") log = logging.getLogger("maubot.instance")
@ -52,6 +52,8 @@ class PluginInstance:
plugin: Plugin plugin: Plugin
config: BaseProxyConfig config: BaseProxyConfig
base_cfg: RecursiveDict[CommentedMap] base_cfg: RecursiveDict[CommentedMap]
inst_db: sql.engine.Engine
inst_db_tables: Dict[str, sql.Table]
started: bool started: bool
def __init__(self, db_instance: DBPlugin): def __init__(self, db_instance: DBPlugin):
@ -62,6 +64,8 @@ class PluginInstance:
self.loader = None self.loader = None
self.client = None self.client = None
self.plugin = None self.plugin = None
self.inst_db = None
self.inst_db_tables = None
self.base_cfg = None self.base_cfg = None
self.cache[self.id] = self self.cache[self.id] = self
@ -73,8 +77,16 @@ class PluginInstance:
"started": self.started, "started": self.started,
"primary_user": self.primary_user, "primary_user": self.primary_user,
"config": self.db_instance.config, "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: def load(self) -> bool:
if not self.loader: if not self.loader:
try: try:
@ -89,6 +101,9 @@ class PluginInstance:
self.log.error(f"Failed to get client for user {self.primary_user}") self.log.error(f"Failed to get client for user {self.primary_user}")
self.db_instance.enabled = False self.db_instance.enabled = False
return 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.log.debug("Plugin instance dependencies loaded")
self.loader.references.add(self) self.loader.references.add(self)
self.client.references.add(self) self.client.references.add(self)
@ -105,7 +120,11 @@ class PluginInstance:
pass pass
self.db.delete(self.db_instance) self.db.delete(self.db_instance)
self.db.commit() 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: def load_config(self) -> CommentedMap:
return yaml.load(self.db_instance.config) return yaml.load(self.db_instance.config)
@ -135,12 +154,9 @@ class PluginInstance:
except (FileNotFoundError, KeyError): except (FileNotFoundError, KeyError):
self.base_cfg = None self.base_cfg = None
self.config = config_class(self.load_config, lambda: self.base_cfg, self.save_config) 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, 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: try:
await self.plugin.start() await self.plugin.start()
except Exception: except Exception:
@ -148,6 +164,7 @@ class PluginInstance:
self.db_instance.enabled = False self.db_instance.enabled = False
return return
self.started = True self.started = True
self.inst_db_tables = None
self.log.info(f"Started instance of {self.loader.meta.id} v{self.loader.meta.version} " self.log.info(f"Started instance of {self.loader.meta.id} v{self.loader.meta.version} "
f"with user {self.client.id}") f"with user {self.client.id}")
@ -162,6 +179,7 @@ class PluginInstance:
except Exception: except Exception:
self.log.exception("Failed to stop instance") self.log.exception("Failed to stop instance")
self.plugin = None self.plugin = None
self.inst_db_tables = None
@classmethod @classmethod
def get(cls, instance_id: str, db_instance: Optional[DBPlugin] = None def get(cls, instance_id: str, db_instance: Optional[DBPlugin] = None

View File

@ -19,13 +19,7 @@ from asyncio import AbstractEventLoop
from ...config import Config from ...config import Config
from .base import routes, set_config, set_loop from .base import routes, set_config, set_loop
from .middleware import auth, error from .middleware import auth, error
from .auth import web as _ from . import auth, plugin, instance, database, client, client_proxy, client_auth, dev_open
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 .log import stop_all as stop_log_sockets, init as init_log_listener from .log import stop_all as stop_log_sockets, init as init_log_listener

View File

@ -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 <https://www.gnu.org/licenses/>.
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()

View File

@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from json import JSONDecodeError from json import JSONDecodeError
from http import HTTPStatus
from aiohttp import web from aiohttp import web

View File

@ -152,6 +152,13 @@ class _Response:
"errcode": "server_not_found", "errcode": "server_not_found",
}, status=HTTPStatus.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 @property
def method_not_allowed(self) -> web.Response: def method_not_allowed(self) -> web.Response:
return web.json_response({ return web.json_response({

View File

@ -153,6 +153,8 @@ export const getInstance = id => defaultGet(`/instance/${id}`)
export const putInstance = (instance, id) => defaultPut("instance", instance, id) export const putInstance = (instance, id) => defaultPut("instance", instance, id)
export const deleteInstance = id => defaultDelete("instance", id) export const deleteInstance = id => defaultDelete("instance", id)
export const getInstanceDatabase = id => defaultGet(`/instance/${id}/database`)
export const getPlugins = () => defaultGet("/plugins") export const getPlugins = () => defaultGet("/plugins")
export const getPlugin = id => defaultGet(`/plugin/${id}`) export const getPlugin = id => defaultGet(`/plugin/${id}`)
export const deletePlugin = id => defaultDelete("plugin", id) export const deletePlugin = id => defaultDelete("plugin", id)
@ -203,6 +205,7 @@ export default {
BASE_PATH, BASE_PATH,
login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled, login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
getInstances, getInstance, putInstance, deleteInstance, getInstances, getInstance, putInstance, deleteInstance,
getInstanceDatabase,
getPlugins, getPlugin, uploadPlugin, deletePlugin, getPlugins, getPlugin, uploadPlugin, deletePlugin,
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient, getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient,
} }

View File

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React from "react" 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 AceEditor from "react-ace"
import "brace/mode/yaml" import "brace/mode/yaml"
import "brace/theme/github" import "brace/theme/github"
@ -23,6 +23,7 @@ import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/P
import api from "../../api" import api from "../../api"
import Spinner from "../../components/Spinner" import Spinner from "../../components/Spinner"
import BaseMainView from "./BaseMainView" import BaseMainView from "./BaseMainView"
import InstanceDatabase from "./InstanceDatabase"
const InstanceListEntry = ({ entry }) => ( const InstanceListEntry = ({ entry }) => (
<NavLink className="instance entry" to={`/instance/${entry.id}`}> <NavLink className="instance entry" to={`/instance/${entry.id}`}>
@ -137,7 +138,15 @@ class Instance extends BaseMainView {
} }
render() { render() {
return <div className="instance"> return <Switch>
<Route path="/instance/:id/database" render={this.renderDatabase}/>
<Route render={this.renderMain}/>
</Switch>
}
renderDatabase = () => <InstanceDatabase instanceID={this.props.entry.id}/>
renderMain = () => <div className="instance">
<PrefTable> <PrefTable>
<PrefInput rowName="ID" type="text" name="id" value={this.state.id} <PrefInput rowName="ID" type="text" name="id" value={this.state.id}
placeholder="fancybotinstance" onChange={this.inputChange} placeholder="fancybotinstance" onChange={this.inputChange}
@ -174,10 +183,20 @@ class Instance extends BaseMainView {
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")} {this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
</button> </button>
</div> </div>
{this.renderLogButton(`instance.${this.state.id}`)} {!this.isNew && <div className="buttons">
{this.props.entry.database && (
<Link className="button open-database"
to={`/instance/${this.state.id}/database`}>
View database
</Link>
)}
<button className="open-log"
onClick={() => this.props.openLog(`instance.${this.state.id}`)}>
View logs
</button>
</div>}
<div className="error">{this.state.error}</div> <div className="error">{this.state.error}</div>
</div> </div>
}
} }
export default withRouter(Instance) export default withRouter(Instance)

View File

@ -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 <https://www.gnu.org/licenses/>.
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 <div className="table">
<table>
<thead>
<tr>
{Array.from(table.columns.entries()).map(([name, column]) => (
<td key={name}>
<span onClick={() => this.toggleSort(column)}>
{name}
{column.sort === "desc" ? <OrderDesc/> : <OrderAsc/>}
</span>
</td>
))}
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
}
renderContent() {
return <>
<div className="tables">
{Object.keys(this.state.tables).map(key => (
<NavLink key={key} to={`/instance/${this.props.instanceID}/database/${key}`}>
{key}
</NavLink>
))}
</div>
<Route path={`/instance/${this.props.instanceID}/database/:table`}
render={this.renderTable}/>
</>
}
render() {
return <div className="instance-database">
<div className="topbar">
<Link className="topbar" to={`/instance/${this.props.instanceID}`}>
<ChevronLeft/>
Back
</Link>
</div>
{this.state.tables
? this.renderContent()
: <Spinner className="maubot-loading"/>}
</div>
}
}
export default InstanceDatabase

View File

@ -184,9 +184,9 @@ class Dashboard extends Component {
</div> </div>
</nav> </nav>
<div className="topbar"> <div className="topbar"
<div className={`hamburger ${this.state.sidebarOpen ? "active" : ""}`}
onClick={evt => this.setState({ sidebarOpen: !this.state.sidebarOpen })}> onClick={evt => this.setState({ sidebarOpen: !this.state.sidebarOpen })}>
<div className={`hamburger ${this.state.sidebarOpen ? "active" : ""}`}>
<span/><span/><span/> <span/><span/><span/>
</div> </div>
</div> </div>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 183 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 152 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7 14l5-5 5 5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 152 B

View File

@ -62,13 +62,13 @@
> button, > .button > button, > .button
flex: 1 flex: 1
&:first-of-type &:first-child
margin-right: .5rem margin-right: .5rem
&:last-of-type &:last-child
margin-left: .5rem margin-left: .5rem
&:first-of-type:last-of-type &:first-child:last-child
margin: 0 margin: 0
=vertical-button-group() =vertical-button-group()

View File

@ -76,13 +76,14 @@
@import client/index @import client/index
@import instance @import instance
@import instance-database
@import plugin @import plugin
> div > div
margin: 2rem 4rem margin: 2rem 4rem
@media screen and (max-width: 50rem) @media screen and (max-width: 50rem)
margin: 2rem 1rem margin: 1rem
> div.not-found, > div.home > div.not-found, > div.home
text-align: center text-align: center
@ -95,10 +96,13 @@
margin: 1rem .5rem margin: 1rem .5rem
width: calc(100% - 1rem) width: calc(100% - 1rem)
button.open-log button.open-log, a.open-database
+button +button
+main-color-button +main-color-button
a.open-database
+link-button
div.error div.error
+notification($error) +notification($error)
margin: 1rem .5rem margin: 1rem .5rem

View File

@ -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 <https://www.gnu.org/licenses/>.
> 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

View File

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
.topbar > .topbar
background-color: $primary background-color: $primary
display: flex display: flex
@ -28,7 +28,10 @@
// Hamburger menu based on "Pure CSS Hamburger fold-out menu" codepen by Erik Terwan (MIT license) // Hamburger menu based on "Pure CSS Hamburger fold-out menu" codepen by Erik Terwan (MIT license)
// https://codepen.io/erikterwan/pen/EVzeRP // https://codepen.io/erikterwan/pen/EVzeRP
.hamburger > .topbar
user-select: none
> .topbar > .hamburger
display: block display: block
user-select: none user-select: none
cursor: pointer cursor: pointer
@ -42,6 +45,7 @@
background: white background: white
border-radius: 3px border-radius: 3px
user-select: none
z-index: 1 z-index: 1