Merge pull request #39 from maubot/database-explorer
Add basic instance database explorer
This commit is contained in:
commit
316cb5d7a7
@ -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:
|
||||||
|
@ -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"]))
|
||||||
init_log_listener()
|
|
||||||
|
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()
|
||||||
|
|
||||||
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()
|
||||||
log.debug("Closing websockets")
|
if stop_log_listener is not None:
|
||||||
loop.run_until_complete(stop_mgmt_api())
|
log.debug("Closing websockets")
|
||||||
|
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))
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
app = web.Application(loop=loop, middlewares=[auth, error], client_max_size=100*1024*1024)
|
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.add_routes(routes)
|
app.add_routes(routes)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
async def stop() -> None:
|
|
||||||
await stop_log_sockets()
|
|
||||||
|
@ -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
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
126
maubot/management/api/instance_database.py
Normal file
126
maubot/management/api/instance_database.py
Normal 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)
|
40
maubot/management/api/login.py
Normal file
40
maubot/management/api/login.py
Normal 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
|
@ -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())
|
|
||||||
|
109
maubot/management/api/plugin_upload.py
Normal file
109
maubot/management/api/plugin_upload.py
Normal 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())
|
@ -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({
|
||||||
|
@ -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",
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
}
|
}
|
||||||
|
@ -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,47 +138,77 @@ class Instance extends BaseMainView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <div className="instance">
|
return <Switch>
|
||||||
<PrefTable>
|
<Route path="/instance/:id/database" render={this.renderDatabase}/>
|
||||||
<PrefInput rowName="ID" type="text" name="id" value={this.state.id}
|
<Route render={this.renderMain}/>
|
||||||
placeholder="fancybotinstance" onChange={this.inputChange}
|
</Switch>
|
||||||
disabled={!this.isNew} fullWidth={true} className="id"/>
|
}
|
||||||
<PrefSwitch rowName="Enabled"
|
|
||||||
active={this.state.enabled} origActive={this.props.entry.enabled}
|
renderDatabase = () => <InstanceDatabase instanceID={this.props.entry.id}/>
|
||||||
onToggle={enabled => this.setState({ enabled })}/>
|
|
||||||
<PrefSwitch rowName="Running"
|
renderMain = () => <div className="instance">
|
||||||
active={this.state.started} origActive={this.props.entry.started}
|
<PrefTable>
|
||||||
onToggle={started => this.setState({ started })}/>
|
<PrefInput rowName="ID" type="text" name="id" value={this.state.id}
|
||||||
|
placeholder="fancybotinstance" onChange={this.inputChange}
|
||||||
|
disabled={!this.isNew} fullWidth={true} className="id"/>
|
||||||
|
<PrefSwitch rowName="Enabled"
|
||||||
|
active={this.state.enabled} origActive={this.props.entry.enabled}
|
||||||
|
onToggle={enabled => this.setState({ enabled })}/>
|
||||||
|
<PrefSwitch rowName="Running"
|
||||||
|
active={this.state.started} origActive={this.props.entry.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 })}/>
|
||||||
</PrefTable>
|
) : (
|
||||||
{!this.isNew &&
|
<PrefInput rowName="Type" type="text" name="type" value={this.state.type}
|
||||||
<AceEditor mode="yaml" theme="github" onChange={config => this.setState({ config })}
|
placeholder="xyz.maubot.example" onChange={this.inputChange}/>
|
||||||
name="config" value={this.state.config}
|
)}
|
||||||
editorProps={{
|
</PrefTable>
|
||||||
fontSize: "10pt",
|
{!this.isNew &&
|
||||||
$blockScrolling: true,
|
<AceEditor mode="yaml" theme="github" onChange={config => this.setState({ config })}
|
||||||
}}/>}
|
name="config" value={this.state.config}
|
||||||
<div className="buttons">
|
editorProps={{
|
||||||
{!this.isNew && (
|
fontSize: "10pt",
|
||||||
<button className="delete" onClick={this.delete} disabled={this.loading}>
|
$blockScrolling: true,
|
||||||
{this.state.deleting ? <Spinner/> : "Delete"}
|
}}/>}
|
||||||
</button>
|
<div className="buttons">
|
||||||
)}
|
{!this.isNew && (
|
||||||
<button className={`save ${this.isValid ? "" : "disabled-bg"}`}
|
<button className="delete" onClick={this.delete} disabled={this.loading}>
|
||||||
onClick={this.save} disabled={this.loading || !this.isValid}>
|
{this.state.deleting ? <Spinner/> : "Delete"}
|
||||||
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
{this.renderLogButton(`instance.${this.state.id}`)}
|
<button className={`save ${this.isValid ? "" : "disabled-bg"}`}
|
||||||
<div className="error">{this.state.error}</div>
|
onClick={this.save} disabled={this.loading || !this.isValid}>
|
||||||
|
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
{!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>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(Instance)
|
export default withRouter(Instance)
|
||||||
|
@ -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"> (pk)</span>
|
||||||
|
} else if (column.unique) {
|
||||||
|
return <span className="meta"> (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)
|
@ -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}
|
||||||
|
@ -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 = {}
|
||||||
for (const instance of instanceList) {
|
if (api.getFeatures().instance) {
|
||||||
instances[instance.id] = instance
|
for (const instance of instanceList) {
|
||||||
|
instances[instance.id] = instance
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const clients = {}
|
const clients = {}
|
||||||
for (const client of clientList) {
|
if (api.getFeatures().client) {
|
||||||
clients[client.id] = client
|
for (const client of clientList) {
|
||||||
|
clients[client.id] = client
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const plugins = {}
|
const plugins = {}
|
||||||
for (const plugin of pluginList) {
|
if (api.getFeatures().plugin) {
|
||||||
plugins[plugin.id] = plugin
|
for (const plugin of pluginList) {
|
||||||
|
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>
|
||||||
|
1
maubot/management/frontend/src/res/chevron-left.svg
Normal file
1
maubot/management/frontend/src/res/chevron-left.svg
Normal 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 |
1
maubot/management/frontend/src/res/sort-down.svg
Normal file
1
maubot/management/frontend/src/res/sort-down.svg
Normal 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 |
1
maubot/management/frontend/src/res/sort-up.svg
Normal file
1
maubot/management/frontend/src/res/sort-up.svg
Normal 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 |
@ -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()
|
||||||
|
@ -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
|
||||||
|
80
maubot/management/frontend/src/style/lib/contextmenu.scss
Normal file
80
maubot/management/frontend/src/style/lib/contextmenu.scss
Normal 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;
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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
|
@ -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
Loading…
Reference in New Issue
Block a user