Add config option to disable parts of management API

This commit is contained in:
Tulir Asokan 2018-12-30 19:16:30 +02:00
parent 147081c0db
commit 75b5ac8ebd
17 changed files with 287 additions and 149 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"]))
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))

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

@ -77,7 +77,8 @@ 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, "database": (self.inst_db is not None
and self.mb_config["api_features.instance_database"]),
} }
def get_db_tables(self) -> Dict[str, sql.Table]: def get_db_tables(self) -> Dict[str, sql.Table]:

View File

@ -15,21 +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 . import auth, plugin, instance, instance_database, client, client_proxy, client_auth, dev_open
from .log import stop_all as stop_log_sockets, init as init_log_listener
@routes.get("/features")
def features(_: web.Request) -> web.Response:
return web.json_response(get_config()["api_features"])
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()

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

@ -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

@ -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`
@ -211,7 +222,9 @@ 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, getInstanceDatabase, queryInstanceDatabase,
getPlugins, getPlugin, uploadPlugin, deletePlugin, getPlugins, getPlugin, uploadPlugin, deletePlugin,

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

@ -157,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 })}/>
<PrefSelect rowName="Primary user" options={this.clientOptions} {api.getFeatures().client ? (
isSearchable={false} value={this.selectedClientEntry} <PrefSelect rowName="Primary user" options={this.clientOptions}
origValue={this.props.entry.primary_user} isSearchable={false} value={this.selectedClientEntry}
onChange={({ id }) => this.setState({ primary_user: id })}/> origValue={this.props.entry.primary_user}
<PrefSelect rowName="Type" options={this.typeOptions} isSearchable={false} onChange={({ id }) => this.setState({ primary_user: id })}/>
value={this.selectedPluginEntry} origValue={this.props.entry.type} ) : (
onChange={({ id }) => this.setState({ type: 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}
value={this.selectedPluginEntry} origValue={this.props.entry.type}
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 })}
@ -190,10 +201,11 @@ class Instance extends BaseMainView {
View database View database
</Link> </Link>
)} )}
{api.getFeatures().log &&
<button className="open-log" <button className="open-log"
onClick={() => this.props.openLog(`instance.${this.state.id}`)}> onClick={() => this.props.openLog(`instance.${this.state.id}`)}>
View logs View logs
</button> </button>}
</div>} </div>}
<div className="error">{this.state.error}</div> <div className="error">{this.state.error}</div>
</div> </div>

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 = {}
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,31 +181,31 @@ 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"