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:
parent
aac99b7ee4
commit
32b60fa0ff
20 changed files with 545 additions and 82 deletions
|
@ -26,7 +26,7 @@ from .server import MaubotServer
|
|||
from .client import Client, init as init_client_class
|
||||
from .loader.zip import init as init_zip_loader
|
||||
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__
|
||||
|
||||
parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.",
|
||||
|
@ -43,6 +43,7 @@ config.load()
|
|||
config.update()
|
||||
|
||||
logging.config.dictConfig(copy.deepcopy(config["logging"]))
|
||||
init_log_listener()
|
||||
log = logging.getLogger("maubot.init")
|
||||
log.info(f"Initializing maubot {__version__}")
|
||||
|
||||
|
@ -52,7 +53,7 @@ init_zip_loader(config)
|
|||
db_session = init_db(config)
|
||||
clients = init_client_class(db_session, 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.app.add_subapp(config["server.base_path"], management_api)
|
||||
|
||||
|
@ -88,7 +89,7 @@ except KeyboardInterrupt:
|
|||
loop=loop))
|
||||
db_session.commit()
|
||||
log.debug("Closing websockets")
|
||||
loop.run_until_complete(stop_management_api())
|
||||
loop.run_until_complete(stop_mgmt_api())
|
||||
log.debug("Stopping server")
|
||||
try:
|
||||
loop.run_until_complete(asyncio.wait_for(server.stop(), 5, loop=loop))
|
||||
|
|
|
@ -31,7 +31,7 @@ from .client import Client
|
|||
from .loader import PluginLoader
|
||||
from .plugin_base import Plugin
|
||||
|
||||
log = logging.getLogger("maubot.plugin")
|
||||
log = logging.getLogger("maubot.instance")
|
||||
|
||||
yaml = YAML()
|
||||
yaml.indent(4)
|
||||
|
@ -54,7 +54,7 @@ class PluginInstance:
|
|||
|
||||
def __init__(self, db_instance: DBPlugin):
|
||||
self.db_instance = db_instance
|
||||
self.log = logging.getLogger(f"maubot.plugin.{self.id}")
|
||||
self.log = log.getChild(self.id)
|
||||
self.config = None
|
||||
self.started = False
|
||||
self.loader = None
|
||||
|
|
|
@ -23,7 +23,8 @@ from .auth import web as _
|
|||
from .plugin import web as _
|
||||
from .instance 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:
|
||||
|
|
64
maubot/management/api/dev_open.py
Normal file
64
maubot/management/api/dev_open.py
Normal 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
|
|
@ -13,13 +13,15 @@
|
|||
#
|
||||
# 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 Deque, List
|
||||
from datetime import datetime
|
||||
from collections import deque
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .base import routes, get_loop
|
||||
from .base import routes, get_loop, get_config
|
||||
from .auth import is_valid_token
|
||||
|
||||
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",
|
||||
"pathname"}
|
||||
EXCLUDE_ATTRS = BUILTIN_ATTRS - INCLUDE_ATTRS
|
||||
MAX_LINES = 2048
|
||||
|
||||
|
||||
class WebSocketHandler(logging.Handler):
|
||||
def __init__(self, ws, level=logging.NOTSET) -> None:
|
||||
class LogCollector(logging.Handler):
|
||||
lines: Deque[dict]
|
||||
formatter: logging.Formatter
|
||||
listeners: List[web.WebSocketResponse]
|
||||
|
||||
def __init__(self, level=logging.NOTSET) -> None:
|
||||
super().__init__(level)
|
||||
self.ws = ws
|
||||
self.lines = deque(maxlen=MAX_LINES)
|
||||
self.formatter = logging.Formatter()
|
||||
self.listeners = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
|
@ -51,9 +59,9 @@ class WebSocketHandler(logging.Handler):
|
|||
for name, value in record.__dict__.items()
|
||||
if name not in EXCLUDE_ATTRS
|
||||
}
|
||||
content["id"] = record.relativeCreated
|
||||
content["id"] = str(record.relativeCreated)
|
||||
content["msg"] = record.getMessage()
|
||||
content["time"] = datetime.utcnow()
|
||||
content["time"] = datetime.fromtimestamp(record.created)
|
||||
|
||||
if 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():
|
||||
if isinstance(value, datetime):
|
||||
content[name] = value.astimezone().isoformat()
|
||||
|
||||
asyncio.ensure_future(self.send(content), loop=get_loop())
|
||||
asyncio.ensure_future(self.send(content))
|
||||
self.lines.append(content)
|
||||
|
||||
async def send(self, record: dict) -> None:
|
||||
try:
|
||||
await self.ws.send_json(record)
|
||||
except Exception as e:
|
||||
print("Log sending error:", e)
|
||||
for ws in self.listeners:
|
||||
try:
|
||||
await ws.send_json(record)
|
||||
except Exception as e:
|
||||
print("Log sending error:", e)
|
||||
|
||||
|
||||
handler = LogCollector()
|
||||
log_root = logging.getLogger("maubot")
|
||||
log = logging.getLogger("maubot.server.websocket")
|
||||
sockets = []
|
||||
|
||||
|
||||
def init() -> None:
|
||||
log_root.addHandler(handler)
|
||||
|
||||
|
||||
async def stop_all() -> None:
|
||||
log_root.removeHandler(handler)
|
||||
for socket in sockets:
|
||||
try:
|
||||
await socket.close(code=1012)
|
||||
|
@ -90,7 +105,6 @@ async def log_websocket(request: web.Request) -> web.WebSocketResponse:
|
|||
await ws.prepare(request)
|
||||
sockets.append(ws)
|
||||
log.debug(f"Connection from {request.remote} opened")
|
||||
handler = WebSocketHandler(ws)
|
||||
authenticated = False
|
||||
|
||||
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:
|
||||
continue
|
||||
if is_valid_token(msg.data):
|
||||
await ws.send_json({"auth_success": True})
|
||||
await ws.send_json({"history": list(handler.lines)})
|
||||
if not authenticated:
|
||||
log.debug(f"Connection from {request.remote} authenticated")
|
||||
log_root.addHandler(handler)
|
||||
handler.listeners.append(ws)
|
||||
authenticated = True
|
||||
await ws.send_json({"auth_success": True})
|
||||
elif not authenticated:
|
||||
await ws.send_json({"auth_success": False})
|
||||
except Exception:
|
||||
|
@ -118,7 +133,7 @@ async def log_websocket(request: web.Request) -> web.WebSocketResponse:
|
|||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
log_root.removeHandler(handler)
|
||||
handler.listeners.remove(ws)
|
||||
log.debug(f"Connection from {request.remote} closed")
|
||||
sockets.remove(ws)
|
||||
return ws
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
"react": "^16.6.0",
|
||||
"react-ace": "^6.2.0",
|
||||
"react-dom": "^16.6.0",
|
||||
"react-json-tree": "^0.11.0",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"react-scripts": "2.0.5",
|
||||
"react-select": "^2.1.1"
|
||||
|
|
|
@ -82,7 +82,8 @@ export async function openLogSocket() {
|
|||
socket: null,
|
||||
connected: false,
|
||||
authenticated: false,
|
||||
onLog: data => {},
|
||||
onLog: data => undefined,
|
||||
onHistory: history => undefined,
|
||||
fails: -1,
|
||||
}
|
||||
const openHandler = () => {
|
||||
|
@ -100,9 +101,9 @@ export async function openLogSocket() {
|
|||
} else {
|
||||
console.info("Websocket connection authentication failed")
|
||||
}
|
||||
} else if (data.history) {
|
||||
wrapper.onHistory(data.history)
|
||||
} else {
|
||||
data.time = new Date(data.time)
|
||||
console.log("SERVLOG", data)
|
||||
wrapper.onLog(data)
|
||||
}
|
||||
}
|
||||
|
@ -131,6 +132,21 @@ export async function openLogSocket() {
|
|||
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 getInstance = id => defaultGet(`/instance/${id}`)
|
||||
export const putInstance = (instance, id) => defaultPut("instance", instance, id)
|
||||
|
@ -179,7 +195,7 @@ export const deleteClient = id => defaultDelete("client", id)
|
|||
|
||||
export default {
|
||||
BASE_PATH,
|
||||
login, ping, openLogSocket,
|
||||
login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
|
||||
getInstances, getInstance, putInstance, deleteInstance,
|
||||
getPlugins, getPlugin, uploadPlugin, deletePlugin,
|
||||
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient,
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React, { Component } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import Log from "./Log"
|
||||
|
||||
class BaseMainView extends Component {
|
||||
constructor(props) {
|
||||
|
@ -65,7 +64,9 @@ class BaseMainView extends Component {
|
|||
</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
|
||||
|
|
|
@ -196,6 +196,7 @@ class Client extends BaseMainView {
|
|||
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
|
||||
</button>
|
||||
</div>
|
||||
{this.renderLogButton(this.state.id)}
|
||||
<div className="error">{this.state.error}</div>
|
||||
</>
|
||||
|
||||
|
@ -209,7 +210,6 @@ class Client extends BaseMainView {
|
|||
{this.renderInstances()}
|
||||
</div>
|
||||
</div>
|
||||
{this.renderLog()}
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
// 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 Log from "./Log"
|
||||
|
||||
class Home extends Component {
|
||||
render() {
|
||||
|
@ -22,7 +21,9 @@ class Home extends Component {
|
|||
<div className="home">
|
||||
See sidebar to get started
|
||||
</div>
|
||||
<Log lines={this.props.log}/>
|
||||
<div className="buttons">
|
||||
<button className="open-log" onClick={this.props.openLog}>View logs</button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -167,8 +167,8 @@ class Instance extends BaseMainView {
|
|||
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
|
||||
</button>
|
||||
</div>
|
||||
{this.renderLogButton(`instance.${this.state.id}`)}
|
||||
<div className="error">{this.state.error}</div>
|
||||
{this.renderLog()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,20 +13,116 @@
|
|||
//
|
||||
// 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 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">
|
||||
{lines.map(data => <>
|
||||
<div className="row" key={data.id}>
|
||||
<span className="time">{data.time.toLocaleTimeString()}</span>
|
||||
<span className="level">{data.levelname}</span>
|
||||
{showName && <span className="logger">{data.name}</span>}
|
||||
<span className="text">{data.msg}</span>
|
||||
</div>
|
||||
{data.exc_info && <div className="row exception" key={data.id + "-exc"}>
|
||||
{data.exc_info.replace(/\\n/g, "\n")}
|
||||
</div>}
|
||||
</>)}
|
||||
</div>
|
||||
class LogEntry extends PureComponent {
|
||||
static contextType = Modal.Context
|
||||
|
||||
renderName() {
|
||||
const line = this.props.line
|
||||
if (line.nameLink) {
|
||||
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>
|
||||
</>
|
||||
}
|
||||
return this.props.line.msg
|
||||
}
|
||||
|
||||
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
|
||||
|
|
51
maubot/management/frontend/src/pages/dashboard/Modal.js
Normal file
51
maubot/management/frontend/src/pages/dashboard/Modal.js
Normal 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
|
|
@ -93,9 +93,9 @@ class Plugin extends BaseMainView {
|
|||
{this.state.deleting ? <Spinner/> : "Delete"}
|
||||
</button>
|
||||
</div>}
|
||||
{this.renderLogButton("loader.zip")}
|
||||
<div className="error">{this.state.error}</div>
|
||||
{this.renderInstances()}
|
||||
{this.renderLog()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,8 @@ import Instance from "./Instance"
|
|||
import Client from "./Client"
|
||||
import Plugin from "./Plugin"
|
||||
import Home from "./Home"
|
||||
import Log from "./Log"
|
||||
import Modal from "./Modal"
|
||||
|
||||
class Dashboard extends Component {
|
||||
constructor(props) {
|
||||
|
@ -30,9 +32,14 @@ class Dashboard extends Component {
|
|||
clients: {},
|
||||
plugins: {},
|
||||
sidebarOpen: false,
|
||||
modalOpen: false,
|
||||
logFocus: "",
|
||||
}
|
||||
this.logLines = []
|
||||
this.logMap = {}
|
||||
this.logModal = {
|
||||
open: () => undefined,
|
||||
}
|
||||
window.maubot = this
|
||||
}
|
||||
|
||||
|
@ -44,7 +51,8 @@ class Dashboard extends Component {
|
|||
|
||||
async componentWillMount() {
|
||||
const [instanceList, clientList, pluginList] = await Promise.all([
|
||||
api.getInstances(), api.getClients(), api.getPlugins()])
|
||||
api.getInstances(), api.getClients(), api.getPlugins(),
|
||||
api.updateDebugOpenFileEnabled()])
|
||||
const instances = {}
|
||||
for (const instance of instanceList) {
|
||||
instances[instance.id] = instance
|
||||
|
@ -60,10 +68,32 @@ class Dashboard extends Component {
|
|||
this.setState({ instances, clients, plugins })
|
||||
|
||||
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 => {
|
||||
processEntry(data)
|
||||
this.logLines.push(data)
|
||||
;(this.logMap[data.name] || (this.logMap[data.name] = [])).push(data)
|
||||
this.setState({})
|
||||
this.setState({ logFocus: this.state.logFocus })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,28 +117,22 @@ class Dashboard extends Component {
|
|||
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) {
|
||||
const entry = this.state[field][id]
|
||||
if (!entry) {
|
||||
return this.renderNotFound(field.slice(0, -1))
|
||||
}
|
||||
console.log(`maubot.${field.slice(0, -1)}.${id}`)
|
||||
return React.createElement(type, {
|
||||
entry,
|
||||
onDelete: () => this.delete(field, id),
|
||||
onChange: newEntry => this.add(field, newEntry, id),
|
||||
openLog: filter => {
|
||||
this.setState({
|
||||
logFocus: filter,
|
||||
})
|
||||
this.logModal.open()
|
||||
},
|
||||
ctx: this.state,
|
||||
log: this.getLog(field, id) || [],
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -118,7 +142,7 @@ class Dashboard extends Component {
|
|||
</div>
|
||||
)
|
||||
|
||||
render() {
|
||||
renderMain() {
|
||||
return <div className={`dashboard ${this.state.sidebarOpen ? "sidebar-open" : ""}`}>
|
||||
<Link to="/" className="title">
|
||||
<img src="/favicon.png" alt=""/>
|
||||
|
@ -161,7 +185,7 @@ class Dashboard extends Component {
|
|||
|
||||
<main className="view">
|
||||
<Switch>
|
||||
<Route path="/" exact render={() => <Home log={this.logLines}/>}/>
|
||||
<Route path="/" exact render={() => <Home openLog={this.logModal.open}/>}/>
|
||||
<Route path="/new/instance" render={() =>
|
||||
<Instance onChange={newEntry => this.add("instances", newEntry)}
|
||||
ctx={this.state}/>}/>
|
||||
|
@ -180,6 +204,19 @@ class Dashboard extends Component {
|
|||
</main>
|
||||
</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)
|
||||
|
|
|
@ -27,3 +27,5 @@
|
|||
|
||||
@import pages/login
|
||||
@import pages/dashboard
|
||||
@import pages/modal
|
||||
@import pages/log
|
||||
|
|
|
@ -89,29 +89,16 @@
|
|||
margin-top: 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
|
||||
+button-group
|
||||
display: flex
|
||||
margin: 1rem .5rem
|
||||
width: calc(100% - 1rem)
|
||||
|
||||
button.open-log
|
||||
+button
|
||||
+main-color-button
|
||||
|
||||
div.error
|
||||
+notification($error)
|
||||
margin: 1rem .5rem
|
||||
|
|
80
maubot/management/frontend/src/style/pages/log.sass
Normal file
80
maubot/management/frontend/src/style/pages/log.sass
Normal 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
|
71
maubot/management/frontend/src/style/pages/modal.sass
Normal file
71
maubot/management/frontend/src/style/pages/modal.sass
Normal 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
|
|
@ -1770,7 +1770,7 @@ babel-register@^6.26.0:
|
|||
mkdirp "^0.5.1"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
|
||||
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"
|
||||
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:
|
||||
version "1.3.0"
|
||||
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"
|
||||
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:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||
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:
|
||||
version "4.4.2"
|
||||
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"
|
||||
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:
|
||||
version "1.5.1"
|
||||
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"
|
||||
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:
|
||||
version "6.0.5"
|
||||
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:
|
||||
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:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
|
||||
|
|
Loading…
Reference in a new issue