Overhaul log viewer

* Move viewer to separate modal to allow using more horizontal space
* Load log history (up to 2048 lines)
* Add colors and use table styling for log viewer
* Add links to log entries that open mentioned instance/client or line in code
This commit is contained in:
Tulir Asokan 2018-11-29 00:58:20 +02:00
parent aac99b7ee4
commit 32b60fa0ff
20 changed files with 545 additions and 82 deletions

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_management_api, stop as stop_management_api from .management.api import init as init_mgmt_api, stop as stop_mgmt_api, init_log_listener
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,6 +43,7 @@ config.load()
config.update() config.update()
logging.config.dictConfig(copy.deepcopy(config["logging"])) logging.config.dictConfig(copy.deepcopy(config["logging"]))
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__}")
@ -52,7 +53,7 @@ init_zip_loader(config)
db_session = init_db(config) db_session = init_db(config)
clients = init_client_class(db_session, loop) clients = init_client_class(db_session, loop)
plugins = init_plugin_instance_class(db_session, config, loop) plugins = init_plugin_instance_class(db_session, config, loop)
management_api = init_management_api(config, loop) management_api = init_mgmt_api(config, loop)
server = MaubotServer(config, loop) server = MaubotServer(config, loop)
server.app.add_subapp(config["server.base_path"], management_api) server.app.add_subapp(config["server.base_path"], management_api)
@ -88,7 +89,7 @@ except KeyboardInterrupt:
loop=loop)) loop=loop))
db_session.commit() db_session.commit()
log.debug("Closing websockets") log.debug("Closing websockets")
loop.run_until_complete(stop_management_api()) loop.run_until_complete(stop_mgmt_api())
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

@ -31,7 +31,7 @@ from .client import Client
from .loader import PluginLoader from .loader import PluginLoader
from .plugin_base import Plugin from .plugin_base import Plugin
log = logging.getLogger("maubot.plugin") log = logging.getLogger("maubot.instance")
yaml = YAML() yaml = YAML()
yaml.indent(4) yaml.indent(4)
@ -54,7 +54,7 @@ class PluginInstance:
def __init__(self, db_instance: DBPlugin): def __init__(self, db_instance: DBPlugin):
self.db_instance = db_instance self.db_instance = db_instance
self.log = logging.getLogger(f"maubot.plugin.{self.id}") self.log = log.getChild(self.id)
self.config = None self.config = None
self.started = False self.started = False
self.loader = None self.loader = None

View File

@ -23,7 +23,8 @@ from .auth import web as _
from .plugin import web as _ from .plugin import web as _
from .instance import web as _ from .instance import web as _
from .client import web as _ from .client import web as _
from .log import stop_all as stop_log_sockets 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:

View File

@ -0,0 +1,64 @@
# 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 string import Template
from subprocess import run
import re
from ruamel.yaml import YAML
from aiohttp import web
from .base import routes
enabled = False
@routes.get("/debug/open")
async def check_enabled(_: web.Request) -> web.Response:
return web.json_response({
"enabled": enabled,
})
try:
yaml = YAML()
with open(".dev-open-cfg.yaml", "r") as file:
cfg = yaml.load(file)
editor_command = Template(cfg["editor"])
pathmap = [(re.compile(item["find"]), item["replace"]) for item in cfg["pathmap"]]
@routes.post("/debug/open")
async def open_file(request: web.Request) -> web.Response:
data = await request.json()
try:
path = data["path"]
for find, replace in pathmap:
path = find.sub(replace, path)
cmd = editor_command.substitute(path=path, line=data["line"])
except (KeyError, ValueError):
return web.Response(status=400)
res = run(cmd, shell=True)
return web.json_response({
"return": res.returncode,
"stdout": res.stdout,
"stderr": res.stderr
})
enabled = True
except Exception:
pass

View File

@ -13,13 +13,15 @@
# #
# 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 typing import Deque, List
from datetime import datetime from datetime import datetime
from collections import deque
import logging import logging
import asyncio import asyncio
from aiohttp import web from aiohttp import web
from .base import routes, get_loop from .base import routes, get_loop, get_config
from .auth import is_valid_token from .auth import is_valid_token
BUILTIN_ATTRS = {"args", "asctime", "created", "exc_info", "exc_text", "filename", "funcName", BUILTIN_ATTRS = {"args", "asctime", "created", "exc_info", "exc_text", "filename", "funcName",
@ -29,13 +31,19 @@ BUILTIN_ATTRS = {"args", "asctime", "created", "exc_info", "exc_text", "filename
INCLUDE_ATTRS = {"filename", "funcName", "levelname", "levelno", "lineno", "module", "name", INCLUDE_ATTRS = {"filename", "funcName", "levelname", "levelno", "lineno", "module", "name",
"pathname"} "pathname"}
EXCLUDE_ATTRS = BUILTIN_ATTRS - INCLUDE_ATTRS EXCLUDE_ATTRS = BUILTIN_ATTRS - INCLUDE_ATTRS
MAX_LINES = 2048
class WebSocketHandler(logging.Handler): class LogCollector(logging.Handler):
def __init__(self, ws, level=logging.NOTSET) -> None: lines: Deque[dict]
formatter: logging.Formatter
listeners: List[web.WebSocketResponse]
def __init__(self, level=logging.NOTSET) -> None:
super().__init__(level) super().__init__(level)
self.ws = ws self.lines = deque(maxlen=MAX_LINES)
self.formatter = logging.Formatter() self.formatter = logging.Formatter()
self.listeners = []
def emit(self, record: logging.LogRecord) -> None: def emit(self, record: logging.LogRecord) -> None:
try: try:
@ -51,9 +59,9 @@ class WebSocketHandler(logging.Handler):
for name, value in record.__dict__.items() for name, value in record.__dict__.items()
if name not in EXCLUDE_ATTRS if name not in EXCLUDE_ATTRS
} }
content["id"] = record.relativeCreated content["id"] = str(record.relativeCreated)
content["msg"] = record.getMessage() content["msg"] = record.getMessage()
content["time"] = datetime.utcnow() content["time"] = datetime.fromtimestamp(record.created)
if record.exc_info: if record.exc_info:
content["exc_info"] = self.formatter.formatException(record.exc_info) content["exc_info"] = self.formatter.formatException(record.exc_info)
@ -61,22 +69,29 @@ class WebSocketHandler(logging.Handler):
for name, value in content.items(): for name, value in content.items():
if isinstance(value, datetime): if isinstance(value, datetime):
content[name] = value.astimezone().isoformat() content[name] = value.astimezone().isoformat()
asyncio.ensure_future(self.send(content))
asyncio.ensure_future(self.send(content), loop=get_loop()) self.lines.append(content)
async def send(self, record: dict) -> None: async def send(self, record: dict) -> None:
for ws in self.listeners:
try: try:
await self.ws.send_json(record) await ws.send_json(record)
except Exception as e: except Exception as e:
print("Log sending error:", e) print("Log sending error:", e)
handler = LogCollector()
log_root = logging.getLogger("maubot") log_root = logging.getLogger("maubot")
log = logging.getLogger("maubot.server.websocket") log = logging.getLogger("maubot.server.websocket")
sockets = [] sockets = []
def init() -> None:
log_root.addHandler(handler)
async def stop_all() -> None: async def stop_all() -> None:
log_root.removeHandler(handler)
for socket in sockets: for socket in sockets:
try: try:
await socket.close(code=1012) await socket.close(code=1012)
@ -90,7 +105,6 @@ async def log_websocket(request: web.Request) -> web.WebSocketResponse:
await ws.prepare(request) await ws.prepare(request)
sockets.append(ws) sockets.append(ws)
log.debug(f"Connection from {request.remote} opened") log.debug(f"Connection from {request.remote} opened")
handler = WebSocketHandler(ws)
authenticated = False authenticated = False
async def close_if_not_authenticated(): async def close_if_not_authenticated():
@ -106,11 +120,12 @@ async def log_websocket(request: web.Request) -> web.WebSocketResponse:
if msg.type != web.WSMsgType.TEXT: if msg.type != web.WSMsgType.TEXT:
continue continue
if is_valid_token(msg.data): if is_valid_token(msg.data):
await ws.send_json({"auth_success": True})
await ws.send_json({"history": list(handler.lines)})
if not authenticated: if not authenticated:
log.debug(f"Connection from {request.remote} authenticated") log.debug(f"Connection from {request.remote} authenticated")
log_root.addHandler(handler) handler.listeners.append(ws)
authenticated = True authenticated = True
await ws.send_json({"auth_success": True})
elif not authenticated: elif not authenticated:
await ws.send_json({"auth_success": False}) await ws.send_json({"auth_success": False})
except Exception: except Exception:
@ -118,7 +133,7 @@ async def log_websocket(request: web.Request) -> web.WebSocketResponse:
await ws.close() await ws.close()
except Exception: except Exception:
pass pass
log_root.removeHandler(handler) handler.listeners.remove(ws)
log.debug(f"Connection from {request.remote} closed") log.debug(f"Connection from {request.remote} closed")
sockets.remove(ws) sockets.remove(ws)
return ws return ws

View File

@ -7,6 +7,7 @@
"react": "^16.6.0", "react": "^16.6.0",
"react-ace": "^6.2.0", "react-ace": "^6.2.0",
"react-dom": "^16.6.0", "react-dom": "^16.6.0",
"react-json-tree": "^0.11.0",
"react-router-dom": "^4.3.1", "react-router-dom": "^4.3.1",
"react-scripts": "2.0.5", "react-scripts": "2.0.5",
"react-select": "^2.1.1" "react-select": "^2.1.1"

View File

@ -82,7 +82,8 @@ export async function openLogSocket() {
socket: null, socket: null,
connected: false, connected: false,
authenticated: false, authenticated: false,
onLog: data => {}, onLog: data => undefined,
onHistory: history => undefined,
fails: -1, fails: -1,
} }
const openHandler = () => { const openHandler = () => {
@ -100,9 +101,9 @@ export async function openLogSocket() {
} else { } else {
console.info("Websocket connection authentication failed") console.info("Websocket connection authentication failed")
} }
} else if (data.history) {
wrapper.onHistory(data.history)
} else { } else {
data.time = new Date(data.time)
console.log("SERVLOG", data)
wrapper.onLog(data) wrapper.onLog(data)
} }
} }
@ -131,6 +132,21 @@ export async function openLogSocket() {
return wrapper return wrapper
} }
let _debugOpenFileEnabled = undefined
export const debugOpenFileEnabled = () => _debugOpenFileEnabled
export const updateDebugOpenFileEnabled = async () => {
const resp = await defaultGet("/debug/open")
_debugOpenFileEnabled = resp["enabled"] || false
}
export async function debugOpenFile(path, line) {
const resp = await fetch(`${BASE_PATH}/debug/open`, {
headers: getHeaders(),
body: JSON.stringify({ path, line }),
method: "POST",
})
return await resp.json()
}
export const getInstances = () => defaultGet("/instances") export const getInstances = () => defaultGet("/instances")
export const getInstance = id => defaultGet(`/instance/${id}`) 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)
@ -179,7 +195,7 @@ export const deleteClient = id => defaultDelete("client", id)
export default { export default {
BASE_PATH, BASE_PATH,
login, ping, openLogSocket, login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
getInstances, getInstance, putInstance, deleteInstance, getInstances, getInstance, putInstance, deleteInstance,
getPlugins, getPlugin, uploadPlugin, deletePlugin, getPlugins, getPlugin, uploadPlugin, deletePlugin,
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient, getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient,

View File

@ -1,6 +1,5 @@
import React, { Component } from "react" import React, { Component } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import Log from "./Log"
class BaseMainView extends Component { class BaseMainView extends Component {
constructor(props) { constructor(props) {
@ -65,7 +64,9 @@ class BaseMainView extends Component {
</div> </div>
) )
renderLog = () => !this.isNew && <Log showName={false} lines={this.props.log}/> renderLogButton = (filter) => !this.isNew && <div className="buttons">
<button className="open-log" onClick={() => this.props.openLog(filter)}>View logs</button>
</div>
} }
export default BaseMainView export default BaseMainView

View File

@ -196,6 +196,7 @@ class Client extends BaseMainView {
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")} {this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
</button> </button>
</div> </div>
{this.renderLogButton(this.state.id)}
<div className="error">{this.state.error}</div> <div className="error">{this.state.error}</div>
</> </>
@ -209,7 +210,6 @@ class Client extends BaseMainView {
{this.renderInstances()} {this.renderInstances()}
</div> </div>
</div> </div>
{this.renderLog()}
</> </>
} }
} }

View File

@ -14,7 +14,6 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react" import React, { Component } from "react"
import Log from "./Log"
class Home extends Component { class Home extends Component {
render() { render() {
@ -22,7 +21,9 @@ class Home extends Component {
<div className="home"> <div className="home">
See sidebar to get started See sidebar to get started
</div> </div>
<Log lines={this.props.log}/> <div className="buttons">
<button className="open-log" onClick={this.props.openLog}>View logs</button>
</div>
</> </>
} }
} }

View File

@ -167,8 +167,8 @@ class Instance extends BaseMainView {
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")} {this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
</button> </button>
</div> </div>
{this.renderLogButton(`instance.${this.state.id}`)}
<div className="error">{this.state.error}</div> <div className="error">{this.state.error}</div>
{this.renderLog()}
</div> </div>
} }
} }

View File

@ -13,20 +13,116 @@
// //
// 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, { PureComponent } from "react"
import { Link } from "react-router-dom"
import JSONTree from "react-json-tree"
import api from "../../api"
import Modal from "./Modal"
const Log = ({ lines, showName = true }) => <div className="log"> class LogEntry extends PureComponent {
{lines.map(data => <> static contextType = Modal.Context
<div className="row" key={data.id}>
<span className="time">{data.time.toLocaleTimeString()}</span> renderName() {
<span className="level">{data.levelname}</span> const line = this.props.line
{showName && <span className="logger">{data.name}</span>} if (line.nameLink) {
<span className="text">{data.msg}</span> const modal = this.context
return (
<Link to={line.nameLink} onClick={modal.close}>
{line.name}
</Link>
)
}
return line.name
}
renderContent() {
if (this.props.line.matrix_http_request) {
const req = this.props.line.matrix_http_request
return <>
{req.method} {req.path}
<div className="content">
{Object.entries(req.content).length > 0
&& <JSONTree data={{ content: req.content }} hideRoot={true}/>}
</div> </div>
{data.exc_info && <div className="row exception" key={data.id + "-exc"}> </>
{data.exc_info.replace(/\\n/g, "\n")} }
</div>} return this.props.line.msg
</>)} }
</div>
renderTime() {
return this.props.line.time.toLocaleTimeString("en-GB")
}
renderLevelName() {
return this.props.line.levelname
}
get unfocused() {
return this.props.focus && this.props.line.name !== this.props.focus
? "unfocused"
: ""
}
renderRow(content) {
return (
<div className={`row ${this.props.line.levelname.toLowerCase()} ${this.unfocused}`}>
<span className="time">{this.renderTime()}</span>
<span className="level">{this.renderLevelName()}</span>
<span className="logger">{this.renderName()}</span>
<span className="text">{content}</span>
</div>
)
}
renderExceptionInfo() {
if (!api.debugOpenFileEnabled()) {
return this.props.line.exc_info
}
const fileLinks = []
let str = this.props.line.exc_info.replace(
/File "(.+)", line ([0-9]+), in (.+)/g,
(_, file, line, method) => {
fileLinks.push(
<a href={"#/debugOpenFile"} onClick={() => {
api.debugOpenFile(file, line)
return false
}}>File "{file}", line {line}, in {method}</a>,
)
return "||EDGE||"
})
fileLinks.reverse()
const result = []
let key = 0
for (const part of str.split("||EDGE||")) {
result.push(<React.Fragment key={key++}>
{part}
{fileLinks.pop()}
</React.Fragment>)
}
return result
}
render() {
return <>
{this.renderRow(this.renderContent())}
{this.props.line.exc_info && this.renderRow(this.renderExceptionInfo())}
</>
}
}
class Log extends PureComponent {
render() {
return (
<div className="log">
<div className="lines">
{this.props.lines.map(data => <LogEntry key={data.id} line={data}
focus={this.props.focus}/>)}
</div>
</div>
)
}
}
export default Log export default Log

View File

@ -0,0 +1,51 @@
// 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, createContext } from "react"
const rem = 16
class Modal extends Component {
static Context = createContext(null)
constructor(props) {
super(props)
this.state = {
open: false,
}
this.wrapper = { clientWidth: 9001 }
}
open = () => this.setState({ open: true })
close = () => this.setState({ open: false })
render() {
return this.state.open && (
<div className="modal-wrapper-wrapper" ref={ref => this.wrapper = ref}
onClick={() => this.wrapper.clientWidth > 45 * rem && this.close()}>
<div className="modal-wrapper" onClick={evt => evt.stopPropagation()}>
<button className="close" onClick={this.close}>Close</button>
<div className="modal">
<Modal.Context.Provider value={this}>
{this.props.children}
</Modal.Context.Provider>
</div>
</div>
</div>
)
}
}
export default Modal

View File

@ -93,9 +93,9 @@ class Plugin extends BaseMainView {
{this.state.deleting ? <Spinner/> : "Delete"} {this.state.deleting ? <Spinner/> : "Delete"}
</button> </button>
</div>} </div>}
{this.renderLogButton("loader.zip")}
<div className="error">{this.state.error}</div> <div className="error">{this.state.error}</div>
{this.renderInstances()} {this.renderInstances()}
{this.renderLog()}
</div> </div>
} }
} }

View File

@ -21,6 +21,8 @@ import Instance from "./Instance"
import Client from "./Client" import Client from "./Client"
import Plugin from "./Plugin" import Plugin from "./Plugin"
import Home from "./Home" import Home from "./Home"
import Log from "./Log"
import Modal from "./Modal"
class Dashboard extends Component { class Dashboard extends Component {
constructor(props) { constructor(props) {
@ -30,9 +32,14 @@ class Dashboard extends Component {
clients: {}, clients: {},
plugins: {}, plugins: {},
sidebarOpen: false, sidebarOpen: false,
modalOpen: false,
logFocus: "",
} }
this.logLines = [] this.logLines = []
this.logMap = {} this.logMap = {}
this.logModal = {
open: () => undefined,
}
window.maubot = this window.maubot = this
} }
@ -44,7 +51,8 @@ class Dashboard extends Component {
async componentWillMount() { async componentWillMount() {
const [instanceList, clientList, pluginList] = await Promise.all([ const [instanceList, clientList, pluginList] = await Promise.all([
api.getInstances(), api.getClients(), api.getPlugins()]) api.getInstances(), api.getClients(), api.getPlugins(),
api.updateDebugOpenFileEnabled()])
const instances = {} const instances = {}
for (const instance of instanceList) { for (const instance of instanceList) {
instances[instance.id] = instance instances[instance.id] = instance
@ -60,10 +68,32 @@ class Dashboard extends Component {
this.setState({ instances, clients, plugins }) this.setState({ instances, clients, plugins })
const logs = await api.openLogSocket() const logs = await api.openLogSocket()
const processEntry = (entry) => {
entry.time = new Date(entry.time)
if (entry.name.startsWith("maubot.")) {
entry.name = entry.name.substr("maubot.".length)
}
if (entry.name.startsWith("client.")) {
entry.name = entry.name.substr("client.".length)
entry.nameLink = `/client/${entry.name}`
} else if (entry.name.startsWith("instance.")) {
entry.nameLink = `/instance/${entry.name.substr("instance.".length)}`
}
(this.logMap[entry.name] || (this.logMap[entry.name] = [])).push(entry)
}
logs.onHistory = history => {
for (const data of history) {
processEntry(data)
}
this.logLines = history
this.setState({ logFocus: this.state.logFocus })
}
logs.onLog = data => { logs.onLog = data => {
processEntry(data)
this.logLines.push(data) this.logLines.push(data)
;(this.logMap[data.name] || (this.logMap[data.name] = [])).push(data) this.setState({ logFocus: this.state.logFocus })
this.setState({})
} }
} }
@ -87,28 +117,22 @@ class Dashboard extends Component {
this.setState({ [stateField]: data }) this.setState({ [stateField]: data })
} }
getLog(field, id) {
if (field === "clients") {
return this.logMap[`maubot.client.${id}`]
} else if (field === "instances") {
return this.logMap[`maubot.plugin.${id}`]
} else if (field === "plugins") {
return this.logMap["maubot.loader.zip"]
}
}
renderView(field, type, id) { renderView(field, type, id) {
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(field.slice(0, -1))
} }
console.log(`maubot.${field.slice(0, -1)}.${id}`)
return React.createElement(type, { return React.createElement(type, {
entry, entry,
onDelete: () => this.delete(field, id), onDelete: () => this.delete(field, id),
onChange: newEntry => this.add(field, newEntry, id), onChange: newEntry => this.add(field, newEntry, id),
openLog: filter => {
this.setState({
logFocus: filter,
})
this.logModal.open()
},
ctx: this.state, ctx: this.state,
log: this.getLog(field, id) || [],
}) })
} }
@ -118,7 +142,7 @@ class Dashboard extends Component {
</div> </div>
) )
render() { 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">
<img src="/favicon.png" alt=""/> <img src="/favicon.png" alt=""/>
@ -161,7 +185,7 @@ class Dashboard extends Component {
<main className="view"> <main className="view">
<Switch> <Switch>
<Route path="/" exact render={() => <Home log={this.logLines}/>}/> <Route path="/" exact render={() => <Home openLog={this.logModal.open}/>}/>
<Route path="/new/instance" render={() => <Route path="/new/instance" render={() =>
<Instance onChange={newEntry => this.add("instances", newEntry)} <Instance onChange={newEntry => this.add("instances", newEntry)}
ctx={this.state}/>}/> ctx={this.state}/>}/>
@ -180,6 +204,19 @@ class Dashboard extends Component {
</main> </main>
</div> </div>
} }
renderModal() {
return <Modal ref={ref => this.logModal = ref}>
<Log lines={this.logLines} focus={this.state.logFocus}/>
</Modal>
}
render() {
return <>
{this.renderMain()}
{this.renderModal()}
</>
}
} }
export default withRouter(Dashboard) export default withRouter(Dashboard)

View File

@ -27,3 +27,5 @@
@import pages/login @import pages/login
@import pages/dashboard @import pages/dashboard
@import pages/modal
@import pages/log

View File

@ -89,29 +89,16 @@
margin-top: 5rem margin-top: 5rem
font-size: 1.5rem font-size: 1.5rem
div.log
text-align: left
font-size: 12px
max-height: 20rem
font-family: "Fira Code", monospace
overflow: auto
> div.row
white-space: pre
> span.level:before
content: " ["
> span.logger:before
content: "@"
> span.text:before
content: "] "
div.buttons div.buttons
+button-group +button-group
display: flex display: flex
margin: 1rem .5rem margin: 1rem .5rem
width: calc(100% - 1rem) width: calc(100% - 1rem)
button.open-log
+button
+main-color-button
div.error div.error
+notification($error) +notification($error)
margin: 1rem .5rem margin: 1rem .5rem

View File

@ -0,0 +1,80 @@
// 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.log
height: 100%
width: 100%
overflow: auto
> div.lines
text-align: left
font-size: 12px
max-height: 100%
min-width: 100%
font-family: "Fira Code", monospace
display: table
> div.row
display: table-row
white-space: pre
&.debug
background-color: $background
&:nth-child(odd)
background-color: $background-dark
&.info
background-color: #AAFAFA
&:nth-child(odd)
background-color: #66FAFA
&.warning, &.warn
background-color: #FABB77
&:nth-child(odd)
background-color: #FAAA55
&.error
background-color: #FAAAAA
&:nth-child(odd)
background-color: #FA9999
&.fatal
background-color: #CC44CC
&:nth-child(odd)
background-color: #AA44AA
&.unfocused
opacity: .25
> span
padding: .125rem .25rem
display: table-cell
a
color: inherit
text-decoration: none
&:hover
text-decoration: underline
> span.text
> div.content > *
background-color: inherit !important
margin: 0 !important

View File

@ -0,0 +1,71 @@
// 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.modal-wrapper-wrapper
z-index: 9001
position: fixed
top: 0
bottom: 0
left: 0
right: 0
background-color: rgba(0, 0, 0, 0.5)
--modal-margin: 2.5rem
--button-height: 0rem
@media screen and (max-width: 45rem)
--modal-margin: 1rem
--button-height: 2.5rem
@media screen and (max-width: 35rem)
--modal-margin: 0rem
--button-height: 3rem
button.close
+button
display: none
width: 100%
height: var(--button-height)
border-radius: .25rem .25rem 0 0
@media screen and (max-width: 45rem)
display: block
@media screen and (max-width: 35rem)
border-radius: 0
div.modal-wrapper
width: calc(100% - 2 * var(--modal-margin))
height: calc(100% - 2 * var(--modal-margin) - var(--button-height))
margin: var(--modal-margin)
border-radius: .25rem
@media screen and (max-width: 35rem)
border-radius: 0
div.modal
padding: 1rem
height: 100%
width: 100%
background-color: $background
box-sizing: border-box
border-radius: .25rem
@media screen and (max-width: 45rem)
border-radius: 0 0 .25rem .25rem
@media screen and (max-width: 35rem)
border-radius: 0
padding: .5rem

View File

@ -1770,7 +1770,7 @@ babel-register@^6.26.0:
mkdirp "^0.5.1" mkdirp "^0.5.1"
source-map-support "^0.4.15" source-map-support "^0.4.15"
babel-runtime@^6.22.0, babel-runtime@^6.26.0: babel-runtime@^6.22.0, babel-runtime@^6.26.0, babel-runtime@^6.6.1:
version "6.26.0" version "6.26.0"
resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
@ -1829,6 +1829,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
base16@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/base16/-/base16-1.0.0.tgz#e297f60d7ec1014a7a971a39ebc8a98c0b681e70"
integrity sha1-4pf2DX7BAUp6lxo568ipjAtoHnA=
base64-js@^1.0.2: base64-js@^1.0.2:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3"
@ -6303,11 +6308,21 @@ lodash.clonedeep@^4.3.2:
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
lodash.curry@^4.0.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170"
integrity sha1-JI42By7ekGUB11lmIAqG2riyMXA=
lodash.debounce@^4.0.8: lodash.debounce@^4.0.8:
version "4.0.8" version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
lodash.flow@^3.3.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/lodash.flow/-/lodash.flow-3.5.0.tgz#87bf40292b8cf83e4e8ce1a3ae4209e20071675a"
integrity sha1-h79AKSuM+D5OjOGjrkIJ4gBxZ1o=
lodash.get@^4.4.2: lodash.get@^4.4.2:
version "4.4.2" version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
@ -8366,6 +8381,11 @@ punycode@^1.2.4, punycode@^1.4.1:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
pure-color@^1.2.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e"
integrity sha1-H+Bk+wrIUfDeYTIKi/eWg2Qi8z4=
q@^1.1.2: q@^1.1.2:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
@ -8476,6 +8496,16 @@ react-app-polyfill@^0.1.3:
raf "3.4.0" raf "3.4.0"
whatwg-fetch "3.0.0" whatwg-fetch "3.0.0"
react-base16-styling@^0.5.1:
version "0.5.3"
resolved "https://registry.yarnpkg.com/react-base16-styling/-/react-base16-styling-0.5.3.tgz#3858f24e9c4dd8cbd3f702f3f74d581ca2917269"
integrity sha1-OFjyTpxN2MvT9wLz901YHKKRcmk=
dependencies:
base16 "^1.0.0"
lodash.curry "^4.0.1"
lodash.flow "^3.3.0"
pure-color "^1.2.0"
react-dev-utils@^6.0.5: react-dev-utils@^6.0.5:
version "6.0.5" version "6.0.5"
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-6.0.5.tgz#6ef34d0a416dc1c97ac20025031ea1f0d819b21d" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-6.0.5.tgz#6ef34d0a416dc1c97ac20025031ea1f0d819b21d"
@ -8526,6 +8556,15 @@ react-input-autosize@^2.2.1:
dependencies: dependencies:
prop-types "^15.5.8" prop-types "^15.5.8"
react-json-tree@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/react-json-tree/-/react-json-tree-0.11.0.tgz#f5b17e83329a9c76ae38be5c04fda3a7fd684a35"
integrity sha1-9bF+gzKanHauOL5cBP2jp/1oSjU=
dependencies:
babel-runtime "^6.6.1"
prop-types "^15.5.8"
react-base16-styling "^0.5.1"
react-lifecycles-compat@^3.0.4: react-lifecycles-compat@^3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"