Merge pull request #39 from maubot/database-explorer

Add basic instance database explorer
This commit is contained in:
Tulir Asokan 2018-12-31 22:47:25 +02:00 committed by GitHub
commit 316cb5d7a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 2272 additions and 1284 deletions

View File

@ -48,6 +48,19 @@ registration_secrets:
admins: admins:
root: "" root: ""
# API feature switches.
api_features:
login: true
plugin: true
plugin_upload: true
instance: true
instance_database: true
client: true
client_proxy: true
client_auth: true
dev_open: true
log: true
# Python logging configuration. # Python logging configuration.
# #
# See section 16.7.2 of the Python documentation for more info: # See section 16.7.2 of the Python documentation for more info:

View File

@ -26,7 +26,7 @@ from .server import MaubotServer
from .client import Client, init as init_client_class from .client import Client, init as init_client_class
from .loader.zip import init as init_zip_loader from .loader.zip import init as init_zip_loader
from .instance import init as init_plugin_instance_class from .instance import init as init_plugin_instance_class
from .management.api import init as init_mgmt_api, stop as stop_mgmt_api, init_log_listener from .management.api import init as init_mgmt_api
from .__meta__ import __version__ from .__meta__ import __version__
parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.", parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.",
@ -43,7 +43,13 @@ config.load()
config.update() config.update()
logging.config.dictConfig(copy.deepcopy(config["logging"])) logging.config.dictConfig(copy.deepcopy(config["logging"]))
stop_log_listener = None
if config["api_features.log"]:
from .management.api.log import init as init_log_listener, stop_all as stop_log_listener
init_log_listener() init_log_listener()
log = logging.getLogger("maubot.init") log = logging.getLogger("maubot.init")
log.info(f"Initializing maubot {__version__}") log.info(f"Initializing maubot {__version__}")
@ -88,8 +94,9 @@ except KeyboardInterrupt:
loop.run_until_complete(asyncio.gather(*[client.stop() for client in Client.cache.values()], loop.run_until_complete(asyncio.gather(*[client.stop() for client in Client.cache.values()],
loop=loop)) loop=loop))
db_session.commit() db_session.commit()
if stop_log_listener is not None:
log.debug("Closing websockets") log.debug("Closing websockets")
loop.run_until_complete(stop_mgmt_api()) loop.run_until_complete(stop_log_listener())
log.debug("Stopping server") log.debug("Stopping server")
try: try:
loop.run_until_complete(asyncio.wait_for(server.stop(), 5, loop=loop)) loop.run_until_complete(asyncio.wait_for(server.stop(), 5, loop=loop))

View File

@ -56,6 +56,16 @@ class Config(BaseFileConfig):
password = self._new_token() password = self._new_token()
base["admins"][username] = bcrypt.hashpw(password.encode("utf-8"), base["admins"][username] = bcrypt.hashpw(password.encode("utf-8"),
bcrypt.gensalt()).decode("utf-8") bcrypt.gensalt()).decode("utf-8")
copy("api_features.login")
copy("api_features.plugin")
copy("api_features.plugin_upload")
copy("api_features.instance")
copy("api_features.instance_database")
copy("api_features.client")
copy("api_features.client_proxy")
copy("api_features.client_auth")
copy("api_features.dev_open")
copy("api_features.log")
copy("logging") copy("logging")
def is_admin(self, user: str) -> bool: def is_admin(self, user: str) -> bool:

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,17 @@ 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
and self.mb_config["api_features.instance_database"]),
} }
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 +102,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 +121,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 +155,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 +165,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 +180,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

@ -15,27 +15,24 @@
# 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 aiohttp import web from aiohttp import web
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop
import importlib
from ...config import Config from ...config import Config
from .base import routes, set_config, set_loop from .base import routes, get_config, set_config, set_loop
from .middleware import auth, error from .middleware import auth, error
from .auth import web as _
from .plugin import web as _
from .instance import web as _ @routes.get("/features")
from .client import web as _ def features(_: web.Request) -> web.Response:
from .client_proxy import web as _ return web.json_response(get_config()["api_features"])
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
def init(cfg: Config, loop: AbstractEventLoop) -> web.Application: def init(cfg: Config, loop: AbstractEventLoop) -> web.Application:
set_config(cfg) set_config(cfg)
set_loop(loop) set_loop(loop)
for pkg, enabled in cfg["api_features"].items():
if enabled:
importlib.import_module(f"maubot.management.api.{pkg}")
app = web.Application(loop=loop, middlewares=[auth, error], client_max_size=100 * 1024 * 1024) app = web.Application(loop=loop, middlewares=[auth, error], client_max_size=100 * 1024 * 1024)
app.add_routes(routes) app.add_routes(routes)
return app return app
async def stop() -> None:
await stop_log_sockets()

View File

@ -15,7 +15,6 @@
# 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 typing import Optional from typing import Optional
from time import time from time import time
import json
from aiohttp import web from aiohttp import web
@ -71,22 +70,3 @@ async def ping(request: web.Request) -> web.Response:
if not get_config().is_admin(user): if not get_config().is_admin(user):
return resp.invalid_token return resp.invalid_token
return resp.pong(user) return resp.pong(user)
@routes.post("/auth/login")
async def login(request: web.Request) -> web.Response:
try:
data = await request.json()
except json.JSONDecodeError:
return resp.body_not_json
secret = data.get("secret")
if secret and get_config()["server.unshared_secret"] == secret:
user = data.get("user") or "root"
return resp.logged_in(create_token(user))
username = data.get("username")
password = data.get("password")
if get_config().check_password(username, password):
return resp.logged_in(create_token(username))
return resp.bad_auth

View File

@ -14,6 +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/>.
from aiohttp import web, client as http from aiohttp import web, client as http
from ...client import Client from ...client import Client
from .base import routes from .base import routes
from .responses import resp from .responses import resp

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

@ -0,0 +1,126 @@
# 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 Union, TYPE_CHECKING
from datetime import datetime
from aiohttp import web
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
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()
})
def check_type(val):
if isinstance(val, datetime):
return val.isoformat()
return val
@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()
try:
table = tables[request.match_info.get("table", "")]
except KeyError:
return resp.table_not_found
try:
order = [tuple(order.split(":")) for order in request.query.getall("order")]
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))
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)

View File

@ -0,0 +1,40 @@
# 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 json
from aiohttp import web
from .base import routes, get_config
from .responses import resp
from .auth import create_token
@routes.post("/auth/login")
async def login(request: web.Request) -> web.Response:
try:
data = await request.json()
except json.JSONDecodeError:
return resp.body_not_json
secret = data.get("secret")
if secret and get_config()["server.unshared_secret"] == secret:
user = data.get("user") or "root"
return resp.logged_in(create_token(user))
username = data.get("username")
password = data.get("password")
if get_config().check_password(username, password):
return resp.logged_in(create_token(username))
return resp.bad_auth

View File

@ -13,18 +13,13 @@
# #
# 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 io import BytesIO
from time import time
import traceback import traceback
import os.path
import re
from aiohttp import web from aiohttp import web
from packaging.version import Version
from ...loader import PluginLoader, ZippedPluginLoader, MaubotZipImportError from ...loader import PluginLoader, MaubotZipImportError
from .responses import resp from .responses import resp
from .base import routes, get_config from .base import routes
@routes.get("/plugins") @routes.get("/plugins")
@ -67,85 +62,3 @@ async def reload_plugin(request: web.Request) -> web.Response:
return resp.plugin_reload_error(str(e), traceback.format_exc()) return resp.plugin_reload_error(str(e), traceback.format_exc())
await plugin.start_instances() await plugin.start_instances()
return resp.ok return resp.ok
@routes.put("/plugin/{id}")
async def put_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info.get("id", None)
content = await request.read()
file = BytesIO(content)
try:
pid, version = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc())
if pid != plugin_id:
return resp.pid_mismatch
plugin = PluginLoader.id_cache.get(plugin_id, None)
if not plugin:
return await upload_new_plugin(content, pid, version)
elif isinstance(plugin, ZippedPluginLoader):
return await upload_replacement_plugin(plugin, content, version)
else:
return resp.unsupported_plugin_loader
@routes.post("/plugins/upload")
async def upload_plugin(request: web.Request) -> web.Response:
content = await request.read()
file = BytesIO(content)
try:
pid, version = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc())
plugin = PluginLoader.id_cache.get(pid, None)
if not plugin:
return await upload_new_plugin(content, pid, version)
elif not request.query.get("allow_override"):
return resp.plugin_exists
elif isinstance(plugin, ZippedPluginLoader):
return await upload_replacement_plugin(plugin, content, version)
else:
return resp.unsupported_plugin_loader
async def upload_new_plugin(content: bytes, pid: str, version: Version) -> web.Response:
path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp")
with open(path, "wb") as p:
p.write(content)
try:
plugin = ZippedPluginLoader.get(path)
except MaubotZipImportError as e:
ZippedPluginLoader.trash(path)
return resp.plugin_import_error(str(e), traceback.format_exc())
return resp.created(plugin.to_dict())
async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
new_version: Version) -> web.Response:
dirname = os.path.dirname(plugin.path)
old_filename = os.path.basename(plugin.path)
if str(plugin.meta.version) in old_filename:
replacement = (new_version if plugin.meta.version != new_version
else f"{new_version}-ts{int(time())}")
filename = re.sub(f"{re.escape(str(plugin.meta.version))}(-ts[0-9]+)?",
replacement, old_filename)
else:
filename = old_filename.rstrip(".mbp")
filename = f"{filename}-v{new_version}.mbp"
path = os.path.join(dirname, filename)
with open(path, "wb") as p:
p.write(content)
old_path = plugin.path
await plugin.stop_instances()
try:
await plugin.reload(new_path=path)
except MaubotZipImportError as e:
try:
await plugin.reload(new_path=old_path)
await plugin.start_instances()
except MaubotZipImportError:
pass
return resp.plugin_import_error(str(e), traceback.format_exc())
await plugin.start_instances()
ZippedPluginLoader.trash(old_path, reason="update")
return resp.updated(plugin.to_dict())

View File

@ -0,0 +1,109 @@
# 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 io import BytesIO
from time import time
import traceback
import os.path
import re
from aiohttp import web
from packaging.version import Version
from ...loader import PluginLoader, ZippedPluginLoader, MaubotZipImportError
from .responses import resp
from .base import routes, get_config
@routes.put("/plugin/{id}")
async def put_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info.get("id", None)
content = await request.read()
file = BytesIO(content)
try:
pid, version = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc())
if pid != plugin_id:
return resp.pid_mismatch
plugin = PluginLoader.id_cache.get(plugin_id, None)
if not plugin:
return await upload_new_plugin(content, pid, version)
elif isinstance(plugin, ZippedPluginLoader):
return await upload_replacement_plugin(plugin, content, version)
else:
return resp.unsupported_plugin_loader
@routes.post("/plugins/upload")
async def upload_plugin(request: web.Request) -> web.Response:
content = await request.read()
file = BytesIO(content)
try:
pid, version = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc())
plugin = PluginLoader.id_cache.get(pid, None)
if not plugin:
return await upload_new_plugin(content, pid, version)
elif not request.query.get("allow_override"):
return resp.plugin_exists
elif isinstance(plugin, ZippedPluginLoader):
return await upload_replacement_plugin(plugin, content, version)
else:
return resp.unsupported_plugin_loader
async def upload_new_plugin(content: bytes, pid: str, version: Version) -> web.Response:
path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp")
with open(path, "wb") as p:
p.write(content)
try:
plugin = ZippedPluginLoader.get(path)
except MaubotZipImportError as e:
ZippedPluginLoader.trash(path)
return resp.plugin_import_error(str(e), traceback.format_exc())
return resp.created(plugin.to_dict())
async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
new_version: Version) -> web.Response:
dirname = os.path.dirname(plugin.path)
old_filename = os.path.basename(plugin.path)
if str(plugin.meta.version) in old_filename:
replacement = (new_version if plugin.meta.version != new_version
else f"{new_version}-ts{int(time())}")
filename = re.sub(f"{re.escape(str(plugin.meta.version))}(-ts[0-9]+)?",
replacement, old_filename)
else:
filename = old_filename.rstrip(".mbp")
filename = f"{filename}-v{new_version}.mbp"
path = os.path.join(dirname, filename)
with open(path, "wb") as p:
p.write(content)
old_path = plugin.path
await plugin.stop_instances()
try:
await plugin.reload(new_path=path)
except MaubotZipImportError as e:
try:
await plugin.reload(new_path=old_path)
await plugin.start_instances()
except MaubotZipImportError:
pass
return resp.plugin_import_error(str(e), traceback.format_exc())
await plugin.start_instances()
ZippedPluginLoader.trash(old_path, reason="update")
return resp.updated(plugin.to_dict())

View File

@ -16,7 +16,7 @@
from http import HTTPStatus from http import HTTPStatus
from aiohttp import web from aiohttp import web
from sqlalchemy.exc import OperationalError, IntegrityError
class _Response: class _Response:
@property @property
@ -82,6 +82,33 @@ class _Response:
"errcode": "username_or_password_missing", "errcode": "username_or_password_missing",
}, status=HTTPStatus.BAD_REQUEST) }, 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 @property
def bad_auth(self) -> web.Response: def bad_auth(self) -> web.Response:
return web.json_response({ return web.json_response({
@ -152,6 +179,20 @@ 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
def table_not_found(self) -> web.Response:
return web.json_response({
"error": "Given table not found in plugin database",
"errcode": "table_not_found",
})
@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

@ -6,6 +6,7 @@
"node-sass": "^4.9.4", "node-sass": "^4.9.4",
"react": "^16.6.0", "react": "^16.6.0",
"react-ace": "^6.2.0", "react-ace": "^6.2.0",
"react-contextmenu": "^2.10.0",
"react-dom": "^16.6.0", "react-dom": "^16.6.0",
"react-json-tree": "^0.11.0", "react-json-tree": "^0.11.0",
"react-router-dom": "^4.3.1", "react-router-dom": "^4.3.1",

View File

@ -61,7 +61,12 @@ export async function login(username, password) {
return await resp.json() return await resp.json()
} }
let features = null
export async function ping() { export async function ping() {
if (!features) {
await remoteGetFeatures()
}
const response = await fetch(`${BASE_PATH}/auth/ping`, { const response = await fetch(`${BASE_PATH}/auth/ping`, {
method: "POST", method: "POST",
headers: getHeaders(), headers: getHeaders(),
@ -75,6 +80,12 @@ export async function ping() {
throw json throw json
} }
export const remoteGetFeatures = async () => {
features = await defaultGet("/features")
}
export const getFeatures = () => features
export async function openLogSocket() { export async function openLogSocket() {
let protocol = window.location.protocol === "https:" ? "wss:" : "ws:" let protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const url = `${protocol}//${window.location.host}${BASE_PATH}/logs` const url = `${protocol}//${window.location.host}${BASE_PATH}/logs`
@ -153,6 +164,16 @@ 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 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 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)
@ -201,8 +222,11 @@ export const deleteClient = id => defaultDelete("client", id)
export default { export default {
BASE_PATH, BASE_PATH,
login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled, login, ping, getFeatures, remoteGetFeatures,
openLogSocket,
debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
getInstances, getInstance, putInstance, deleteInstance, getInstances, getInstance, putInstance, deleteInstance,
getInstanceDatabase, queryInstanceDatabase,
getPlugins, getPlugin, uploadPlugin, deletePlugin, getPlugins, getPlugin, uploadPlugin, deletePlugin,
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient, getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient,
} }

View File

@ -44,6 +44,14 @@ class Login extends Component {
} }
render() { render() {
if (!api.getFeatures().login) {
return <div className="login-wrapper">
<div className="login errored">
<h1>Maubot Manager</h1>
<div className="error">Login has been disabled in the maubot config.</div>
</div>
</div>
}
return <div className="login-wrapper"> return <div className="login-wrapper">
<div className={`login ${this.state.error && "errored"}`}> <div className={`login ${this.state.error && "errored"}`}>
<h1>Maubot Manager</h1> <h1>Maubot Manager</h1>

View File

@ -33,6 +33,8 @@ class Main extends Component {
async componentWillMount() { async componentWillMount() {
if (localStorage.accessToken) { if (localStorage.accessToken) {
await this.ping() await this.ping()
} else {
await api.remoteGetFeatures()
} }
this.setState({ pinged: true }) this.setState({ pinged: true })
} }

View File

@ -1,5 +1,6 @@
import React, { Component } from "react" import React, { Component } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import api from "../../api"
class BaseMainView extends Component { class BaseMainView extends Component {
constructor(props) { constructor(props) {
@ -74,7 +75,7 @@ class BaseMainView extends Component {
</div> </div>
) )
renderLogButton = (filter) => !this.isNew && <div className="buttons"> renderLogButton = (filter) => !this.isNew && api.getFeatures().log && <div className="buttons">
<button className="open-log" onClick={() => this.props.openLog(filter)}>View logs</button> <button className="open-log" onClick={() => this.props.openLog(filter)}>View logs</button>
</div> </div>
} }

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}
@ -148,13 +157,24 @@ class Instance extends BaseMainView {
<PrefSwitch rowName="Running" <PrefSwitch rowName="Running"
active={this.state.started} origActive={this.props.entry.started} active={this.state.started} origActive={this.props.entry.started}
onToggle={started => this.setState({ started })}/> onToggle={started => this.setState({ started })}/>
{api.getFeatures().client ? (
<PrefSelect rowName="Primary user" options={this.clientOptions} <PrefSelect rowName="Primary user" options={this.clientOptions}
isSearchable={false} value={this.selectedClientEntry} isSearchable={false} value={this.selectedClientEntry}
origValue={this.props.entry.primary_user} origValue={this.props.entry.primary_user}
onChange={({ id }) => this.setState({ primary_user: id })}/> onChange={({ id }) => this.setState({ primary_user: id })}/>
) : (
<PrefInput rowName="Primary user" type="text" name="primary_user"
value={this.state.primary_user} placeholder="@user:example.com"
onChange={this.inputChange}/>
)}
{api.getFeatures().plugin ? (
<PrefSelect rowName="Type" options={this.typeOptions} isSearchable={false} <PrefSelect rowName="Type" options={this.typeOptions} isSearchable={false}
value={this.selectedPluginEntry} origValue={this.props.entry.type} value={this.selectedPluginEntry} origValue={this.props.entry.type}
onChange={({ id }) => this.setState({ type: id })}/> onChange={({ id }) => this.setState({ type: id })}/>
) : (
<PrefInput rowName="Type" type="text" name="type" value={this.state.type}
placeholder="xyz.maubot.example" onChange={this.inputChange}/>
)}
</PrefTable> </PrefTable>
{!this.isNew && {!this.isNew &&
<AceEditor mode="yaml" theme="github" onChange={config => this.setState({ config })} <AceEditor mode="yaml" theme="github" onChange={config => this.setState({ config })}
@ -174,10 +194,21 @@ 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>
)}
{api.getFeatures().log &&
<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,325 @@
// 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, withRouter } from "react-router-dom"
import { ContextMenu, ContextMenuTrigger, MenuItem } from "react-contextmenu"
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,
header: null,
content: null,
query: "",
selectedTable: null,
error: null,
prevQuery: null,
rowCount: null,
insertedPrimaryKey: null,
}
this.order = new Map()
}
async componentWillMount() {
const tables = new Map(Object.entries(await api.getInstanceDatabase(this.props.instanceID)))
for (const [name, table] of tables) {
table.name = name
table.columns = new Map(Object.entries(table.columns))
for (const [columnName, column] of table.columns) {
column.name = columnName
column.sort = null
}
}
this.setState({ tables })
this.checkLocationTable()
}
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
this.order = new Map()
this.setState({ header: null, content: null })
this.checkLocationTable()
}
}
checkLocationTable() {
const prefix = `/instance/${this.props.instanceID}/database/`
if (this.props.location.pathname.startsWith(prefix)) {
const table = this.props.location.pathname.substr(prefix.length)
this.setState({ selectedTable: table })
this.buildSQLQuery(table)
}
}
getSortQueryParams(table) {
const order = []
for (const [column, sort] of Array.from(this.order.entries()).reverse()) {
order.push(`order=${column}:${sort}`)
}
return order
}
buildSQLQuery(table = this.state.selectedTable, resetContent = true) {
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(resetContent))
}
reloadContent = async (resetContent = true) => {
this.setState({ loading: true })
const res = await api.queryInstanceDatabase(this.props.instanceID, this.state.query)
this.setState({ loading: false })
if (resetContent) {
this.setState({
prevQuery: null,
rowCount: null,
insertedPrimaryKey: null,
error: null,
})
}
if (!res.ok) {
this.setState({
error: res.error,
})
} 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(this.state.selectedTable, false)
}
}
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:
break
}
this.buildSQLQuery()
}
getSortIcon(column) {
switch (this.order.get(column)) {
case "DESC":
return <OrderDesc/>
case "ASC":
return <OrderAsc/>
default:
return null
}
}
getColumnInfo(columnName) {
const table = this.state.tables.get(this.state.selectedTable)
if (!table) {
return null
}
const column = table.columns.get(columnName)
if (!column) {
return null
}
if (column.primary) {
return <span className="meta">&nbsp;(pk)</span>
} else if (column.unique) {
return <span className="meta">&nbsp;(u)</span>
}
return null
}
getColumnType(columnName) {
const table = this.state.tables.get(this.state.selectedTable)
if (!table) {
return null
}
const column = table.columns.get(columnName)
if (!column) {
return null
}
return column.type
}
deleteRow = async (_, data) => {
const values = this.state.content[data.row]
const keys = this.state.header
const condition = []
for (const [index, key] of Object.entries(keys)) {
const val = values[index]
condition.push(`${key}='${this.sqlEscape(val.toString())}'`)
}
const query = `DELETE FROM ${this.state.selectedTable} WHERE ${condition.join(" AND ")}`
const res = await api.queryInstanceDatabase(this.props.instanceID, query)
this.setState({
prevQuery: `DELETE FROM ${this.state.selectedTable} ...`,
rowCount: res.rowcount,
})
await this.reloadContent(false)
}
editCell = async (evt, data) => {
console.log("Edit", data)
}
collectContextMeta = props => ({
row: props.row,
col: props.col,
})
sqlEscape = str => str.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, char => {
switch (char) {
case "\0":
return "\\0"
case "\x08":
return "\\b"
case "\x09":
return "\\t"
case "\x1a":
return "\\z"
case "\n":
return "\\n"
case "\r":
return "\\r"
case "\"":
case "'":
case "\\":
case "%":
return "\\" + char
default:
return char
}
})
renderTable = () => <div className="table">
{this.state.header ? <>
<table>
<thead>
<tr>
{this.state.header.map(column => (
<td key={column}>
<span onClick={() => this.toggleSort(column)}
title={this.getColumnType(column)}>
<strong>{column}</strong>
{this.getColumnInfo(column)}
{this.getSortIcon(column)}
</span>
</td>
))}
</tr>
</thead>
<tbody>
{this.state.content.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, colIndex) => (
<ContextMenuTrigger key={colIndex} id="database_table_menu"
renderTag="td" row={rowIndex} col={colIndex}
collect={this.collectContextMeta}>
{cell}
</ContextMenuTrigger>
))}
</tr>
))}
</tbody>
</table>
<ContextMenu id="database_table_menu">
<MenuItem onClick={this.deleteRow}>Delete row</MenuItem>
<MenuItem disabled onClick={this.editCell}>Edit cell</MenuItem>
</ContextMenu>
</> : this.state.loading ? <Spinner/> : null}
</div>
renderContent() {
return <>
<div className="tables">
{this.state.tables.map((_, tbl) => (
<NavLink key={tbl} to={`/instance/${this.props.instanceID}/database/${tbl}`}>
{tbl}
</NavLink>
))}
</div>
<div className="query">
<input type="text" value={this.state.query} name="query"
onChange={evt => this.setState({ query: evt.target.value })}/>
<button type="submit" onClick={this.reloadContent}>Query</button>
</div>
{this.state.error && <div className="error">
{this.state.error}
</div>}
{this.state.prevQuery && <div className="prev-query">
<p>
Executed <span className="query">{this.state.prevQuery}</span> -
affected <strong>{this.state.rowCount} rows</strong>.
</p>
{this.state.insertedPrimaryKey && <p className="inserted-primary-key">
Inserted primary key: {this.state.insertedPrimaryKey}
</p>}
</div>}
{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 withRouter(InstanceDatabase)

View File

@ -78,6 +78,7 @@ class Plugin extends BaseMainView {
<PrefInput rowName="Version" type="text" value={this.state.version} <PrefInput rowName="Version" type="text" value={this.state.version}
disabled={true}/> disabled={true}/>
</PrefTable>} </PrefTable>}
{api.getFeatures().plugin_upload &&
<div className={`upload-box ${this.state.uploading ? "uploading" : ""}`}> <div className={`upload-box ${this.state.uploading ? "uploading" : ""}`}>
<UploadButton className="upload"/> <UploadButton className="upload"/>
<input className="file-selector" type="file" accept="application/zip+mbp" <input className="file-selector" type="file" accept="application/zip+mbp"
@ -85,7 +86,7 @@ class Plugin extends BaseMainView {
onDragEnter={evt => evt.target.parentElement.classList.add("drag")} onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/> onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
{this.state.uploading && <Spinner/>} {this.state.uploading && <Spinner/>}
</div> </div>}
{!this.isNew && <div className="buttons"> {!this.isNew && <div className="buttons">
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`} <button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
onClick={this.delete} disabled={this.loading || this.hasInstances} onClick={this.delete} disabled={this.loading || this.hasInstances}

View File

@ -54,19 +54,33 @@ class Dashboard extends Component {
api.getInstances(), api.getClients(), api.getPlugins(), api.getInstances(), api.getClients(), api.getPlugins(),
api.updateDebugOpenFileEnabled()]) api.updateDebugOpenFileEnabled()])
const instances = {} const instances = {}
if (api.getFeatures().instance) {
for (const instance of instanceList) { for (const instance of instanceList) {
instances[instance.id] = instance instances[instance.id] = instance
} }
}
const clients = {} const clients = {}
if (api.getFeatures().client) {
for (const client of clientList) { for (const client of clientList) {
clients[client.id] = client clients[client.id] = client
} }
}
const plugins = {} const plugins = {}
if (api.getFeatures().plugin) {
for (const plugin of pluginList) { for (const plugin of pluginList) {
plugins[plugin.id] = plugin plugins[plugin.id] = plugin
} }
}
this.setState({ instances, clients, plugins }) this.setState({ instances, clients, plugins })
await this.enableLogs()
}
async enableLogs() {
if (api.getFeatures().log) {
return
}
const logs = await api.openLogSocket() const logs = await api.openLogSocket()
const processEntry = (entry) => { const processEntry = (entry) => {
@ -119,9 +133,13 @@ class Dashboard extends Component {
} }
renderView(field, type, id) { renderView(field, type, id) {
const typeName = field.slice(0, -1)
if (!api.getFeatures()[typeName]) {
return this.renderDisabled(typeName)
}
const entry = this.state[field][id] const entry = this.state[field][id]
if (!entry) { if (!entry) {
return this.renderNotFound(field.slice(0, -1)) return this.renderNotFound(typeName)
} }
return React.createElement(type, { return React.createElement(type, {
entry, entry,
@ -145,6 +163,12 @@ class Dashboard extends Component {
</div> </div>
) )
renderDisabled = (thing = "path") => (
<div className="not-found">
The {thing} API has been disabled in the maubot config.
</div>
)
renderMain() { renderMain() {
return <div className={`dashboard ${this.state.sidebarOpen ? "sidebar-open" : ""}`}> return <div className={`dashboard ${this.state.sidebarOpen ? "sidebar-open" : ""}`}>
<Link to="/" className="title"> <Link to="/" className="title">
@ -157,36 +181,36 @@ class Dashboard extends Component {
<nav className="sidebar"> <nav className="sidebar">
<div className="buttons"> <div className="buttons">
<button className="open-log" onClick={this.openLog}> {api.getFeatures().log && <button className="open-log" onClick={this.openLog}>
<span>View logs</span> <span>View logs</span>
</button> </button>}
</div> </div>
<div className="instances list"> {api.getFeatures().instance && <div className="instances list">
<div className="title"> <div className="title">
<h2>Instances</h2> <h2>Instances</h2>
<Link to="/new/instance"><Plus/></Link> <Link to="/new/instance"><Plus/></Link>
</div> </div>
{this.renderList("instances", Instance.ListEntry)} {this.renderList("instances", Instance.ListEntry)}
</div> </div>}
<div className="clients list"> {api.getFeatures().client && <div className="clients list">
<div className="title"> <div className="title">
<h2>Clients</h2> <h2>Clients</h2>
<Link to="/new/client"><Plus/></Link> <Link to="/new/client"><Plus/></Link>
</div> </div>
{this.renderList("clients", Client.ListEntry)} {this.renderList("clients", Client.ListEntry)}
</div> </div>}
<div className="plugins list"> {api.getFeatures().plugin && <div className="plugins list">
<div className="title"> <div className="title">
<h2>Plugins</h2> <h2>Plugins</h2>
<Link to="/new/plugin"><Plus/></Link> {api.getFeatures().plugin_upload && <Link to="/new/plugin"><Plus/></Link>}
</div> </div>
{this.renderList("plugins", Plugin.ListEntry)} {this.renderList("plugins", Plugin.ListEntry)}
</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

@ -14,6 +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 lib/spinner @import lib/spinner
@import lib/contextmenu
@import base/vars @import base/vars
@import base/body @import base/body

View File

@ -0,0 +1,80 @@
.react-contextmenu {
background-color: #fff;
background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, .15);
border-radius: .25rem;
color: #373a3c;
font-size: 16px;
margin: 2px 0 0;
min-width: 160px;
outline: none;
opacity: 0;
padding: 5px 0;
pointer-events: none;
text-align: left;
transition: opacity 250ms ease !important;
}
.react-contextmenu.react-contextmenu--visible {
opacity: 1;
pointer-events: auto;
z-index: 9999;
}
.react-contextmenu-item {
background: 0 0;
border: 0;
color: #373a3c;
cursor: pointer;
font-weight: 400;
line-height: 1.5;
padding: 3px 20px;
text-align: inherit;
white-space: nowrap;
}
.react-contextmenu-item.react-contextmenu-item--active,
.react-contextmenu-item.react-contextmenu-item--selected {
color: #fff;
background-color: #20a0ff;
border-color: #20a0ff;
text-decoration: none;
}
.react-contextmenu-item.react-contextmenu-item--disabled,
.react-contextmenu-item.react-contextmenu-item--disabled:hover {
background-color: transparent;
border-color: rgba(0, 0, 0, .15);
color: #878a8c;
}
.react-contextmenu-item--divider {
border-bottom: 1px solid rgba(0, 0, 0, .15);
cursor: inherit;
margin-bottom: 3px;
padding: 2px 0;
}
.react-contextmenu-item--divider:hover {
background-color: transparent;
border-color: rgba(0, 0, 0, .15);
}
.react-contextmenu-item.react-contextmenu-submenu {
padding: 0;
}
.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item {
}
.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item:after {
content: "";
display: inline-block;
position: absolute;
right: 7px;
}
.example-multiple-targets::after {
content: attr(data-count);
display: block;
}

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,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/>.
> 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.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
p
margin: 0
> div.table
overflow-x: auto
overflow-y: hidden
table
font-family: "Fira Code", monospace
width: 100%
box-sizing: border-box
> thead
> 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

File diff suppressed because it is too large Load Diff