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 .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))

View file

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

View file

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

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
# 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:
for ws in self.listeners:
try:
await self.ws.send_json(record)
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

View file

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

View file

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

View file

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

View file

@ -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()}
</>
}
}

View file

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

View file

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

View file

@ -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>
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>
{data.exc_info && <div className="row exception" key={data.id + "-exc"}>
{data.exc_info.replace(/\\n/g, "\n")}
</div>}
</>)}
</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

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"}
</button>
</div>}
{this.renderLogButton("loader.zip")}
<div className="error">{this.state.error}</div>
{this.renderInstances()}
{this.renderLog()}
</div>
}
}

View file

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

View file

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

View file

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

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