Add Cloudron packaging for Maubot
This commit is contained in:
72
maubot-src/maubot/management/frontend/src/pages/Login.js
Normal file
72
maubot-src/maubot/management/frontend/src/pages/Login.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { Component } from "react"
|
||||
import Spinner from "../components/Spinner"
|
||||
import api from "../api"
|
||||
|
||||
class Login extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
username: "",
|
||||
password: "",
|
||||
loading: false,
|
||||
error: "",
|
||||
}
|
||||
}
|
||||
|
||||
inputChanged = event => this.setState({ [event.target.name]: event.target.value })
|
||||
|
||||
login = async evt => {
|
||||
evt.preventDefault()
|
||||
this.setState({ loading: true })
|
||||
const resp = await api.login(this.state.username, this.state.password)
|
||||
if (resp.token) {
|
||||
await this.props.onLogin(resp.token)
|
||||
} else if (resp.error) {
|
||||
this.setState({ error: resp.error, loading: false })
|
||||
} else {
|
||||
this.setState({ error: "Unknown error", loading: false })
|
||||
console.log("Unknown error:", resp)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!api.getFeatures().login) {
|
||||
return <div className="login-wrapper">
|
||||
<div className="login errored">
|
||||
<h1>Maubot Manager</h1>
|
||||
<div className="error">Login has been disabled in the maubot config.</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
return <div className="login-wrapper">
|
||||
<form className={`login ${this.state.error && "errored"}`} onSubmit={this.login}>
|
||||
<h1>Maubot Manager</h1>
|
||||
<input type="text" placeholder="Username" value={this.state.username}
|
||||
name="username" onChange={this.inputChanged}/>
|
||||
<input type="password" placeholder="Password" value={this.state.password}
|
||||
name="password" onChange={this.inputChanged}/>
|
||||
<button onClick={this.login} type="submit">
|
||||
{this.state.loading ? <Spinner/> : "Log in"}
|
||||
</button>
|
||||
{this.state.error && <div className="error">{this.state.error}</div>}
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default Login
|
||||
91
maubot-src/maubot/management/frontend/src/pages/Main.js
Normal file
91
maubot-src/maubot/management/frontend/src/pages/Main.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { Component } from "react"
|
||||
import { HashRouter as Router, Switch } from "react-router-dom"
|
||||
import PrivateRoute from "../components/PrivateRoute"
|
||||
import Spinner from "../components/Spinner"
|
||||
import api from "../api"
|
||||
import Dashboard from "./dashboard"
|
||||
import Login from "./Login"
|
||||
|
||||
class Main extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
pinged: false,
|
||||
authed: false,
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.getBasePath()
|
||||
if (localStorage.accessToken) {
|
||||
await this.ping()
|
||||
} else {
|
||||
await api.remoteGetFeatures()
|
||||
}
|
||||
this.setState({ pinged: true })
|
||||
}
|
||||
|
||||
async getBasePath() {
|
||||
try {
|
||||
const resp = await fetch(process.env.PUBLIC_URL + "/paths.json", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
const apiPaths = await resp.json()
|
||||
api.setBasePath(apiPaths.api_path)
|
||||
} catch (err) {
|
||||
console.error("Failed to get API path:", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async ping() {
|
||||
try {
|
||||
const username = await api.ping()
|
||||
if (username) {
|
||||
localStorage.username = username
|
||||
this.setState({ authed: true })
|
||||
} else {
|
||||
delete localStorage.accessToken
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
login = async (token) => {
|
||||
localStorage.accessToken = token
|
||||
await this.ping()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.pinged) {
|
||||
return <Spinner className="maubot-loading"/>
|
||||
}
|
||||
return <Router>
|
||||
<div className={`maubot-wrapper ${this.state.authed ? "authenticated" : ""}`}>
|
||||
<Switch>
|
||||
<PrivateRoute path="/login" render={() => <Login onLogin={this.login}/>}
|
||||
authed={!this.state.authed} to="/"/>
|
||||
<PrivateRoute path="/" component={Dashboard} authed={this.state.authed}/>
|
||||
</Switch>
|
||||
</div>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
export default Main
|
||||
@@ -0,0 +1,98 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { Component } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import api from "../../api"
|
||||
|
||||
class BaseMainView extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = Object.assign(this.initialState, props.entry)
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
const newState = Object.assign(this.initialState, nextProps.entry)
|
||||
for (const key of this.entryKeys) {
|
||||
if (this.props.entry[key] === nextProps.entry[key]) {
|
||||
newState[key] = this.state[key]
|
||||
}
|
||||
}
|
||||
this.setState(newState)
|
||||
}
|
||||
|
||||
delete = async () => {
|
||||
if (!window.confirm(`Are you sure you want to delete ${this.state.id}?`)) {
|
||||
return
|
||||
}
|
||||
this.setState({ deleting: true })
|
||||
const resp = await this.deleteFunc(this.state.id)
|
||||
if (resp.success) {
|
||||
this.props.history.push("/")
|
||||
this.props.onDelete()
|
||||
} else {
|
||||
this.setState({ deleting: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
get entryKeys() {
|
||||
return []
|
||||
}
|
||||
|
||||
get initialState() {
|
||||
return {}
|
||||
}
|
||||
|
||||
get hasInstances() {
|
||||
return this.state.instances && this.state.instances.length > 0
|
||||
}
|
||||
|
||||
get isNew() {
|
||||
return !this.props.entry.id
|
||||
}
|
||||
|
||||
inputChange = event => {
|
||||
if (!event.target.name) {
|
||||
return
|
||||
}
|
||||
this.setState({ [event.target.name]: event.target.value })
|
||||
}
|
||||
|
||||
async readFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsArrayBuffer(file)
|
||||
reader.onload = evt => resolve(evt.target.result)
|
||||
reader.onerror = err => reject(err)
|
||||
})
|
||||
}
|
||||
|
||||
renderInstances = () => !this.isNew && (
|
||||
<div className="instances">
|
||||
<h3>{this.hasInstances ? "Instances" : "No instances :("}</h3>
|
||||
{this.state.instances.map(instance => (
|
||||
<Link className="instance" key={instance.id} to={`/instance/${instance.id}`}>
|
||||
{instance.id}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
renderLogButton = (filter) => !this.isNew && api.getFeatures().log && <div className="buttons">
|
||||
<button className="open-log" onClick={() => this.props.openLog(filter)}>View logs</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default BaseMainView
|
||||
@@ -0,0 +1,392 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2022 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 from "react"
|
||||
import { NavLink, withRouter } from "react-router-dom"
|
||||
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
|
||||
import { ReactComponent as UploadButton } from "../../res/upload.svg"
|
||||
import { ReactComponent as NoAvatarIcon } from "../../res/bot.svg"
|
||||
import { PrefTable, PrefSwitch, PrefInput, PrefSelect } from "../../components/PreferenceTable"
|
||||
import Spinner from "../../components/Spinner"
|
||||
import api from "../../api"
|
||||
import BaseMainView from "./BaseMainView"
|
||||
|
||||
const ClientListEntry = ({ entry }) => {
|
||||
const classes = ["client", "entry"]
|
||||
if (!entry.enabled) {
|
||||
classes.push("disabled")
|
||||
} else if (!entry.started) {
|
||||
classes.push("stopped")
|
||||
}
|
||||
const avatarMXC = entry.avatar_url === "disable"
|
||||
? entry.remote_avatar_url
|
||||
: entry.avatar_url
|
||||
const avatarURL = avatarMXC && api.getAvatarURL({
|
||||
id: entry.id,
|
||||
avatar_url: avatarMXC,
|
||||
})
|
||||
const displayname = (
|
||||
entry.displayname === "disable"
|
||||
? entry.remote_displayname
|
||||
: entry.displayname
|
||||
) || entry.id
|
||||
return (
|
||||
<NavLink className={classes.join(" ")} to={`/client/${entry.id}`}>
|
||||
{avatarURL
|
||||
? <img className='avatar' src={avatarURL} alt=""/>
|
||||
: <NoAvatarIcon className='avatar'/>}
|
||||
<span className="displayname">{displayname}</span>
|
||||
<ChevronRight className='chevron'/>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
class Client extends BaseMainView {
|
||||
static ListEntry = ClientListEntry
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.deleteFunc = api.deleteClient
|
||||
this.recovery_key = null
|
||||
this.homeserverOptions = []
|
||||
}
|
||||
|
||||
get entryKeys() {
|
||||
return ["id", "displayname", "homeserver", "avatar_url", "access_token", "device_id",
|
||||
"sync", "autojoin", "online", "enabled", "started", "trust_state"]
|
||||
}
|
||||
|
||||
get initialState() {
|
||||
return {
|
||||
id: "",
|
||||
displayname: "disable",
|
||||
homeserver: "",
|
||||
avatar_url: "disable",
|
||||
access_token: "",
|
||||
device_id: "",
|
||||
fingerprint: null,
|
||||
sync: true,
|
||||
autojoin: true,
|
||||
enabled: true,
|
||||
online: true,
|
||||
started: false,
|
||||
trust_state: null,
|
||||
|
||||
instances: [],
|
||||
|
||||
uploadingAvatar: false,
|
||||
saving: false,
|
||||
deleting: false,
|
||||
verifying: false,
|
||||
startingOrStopping: false,
|
||||
clearingCache: false,
|
||||
error: "",
|
||||
}
|
||||
}
|
||||
|
||||
get clientInState() {
|
||||
const client = Object.assign({}, this.state)
|
||||
delete client.uploadingAvatar
|
||||
delete client.saving
|
||||
delete client.deleting
|
||||
delete client.startingOrStopping
|
||||
delete client.clearingCache
|
||||
delete client.error
|
||||
delete client.instances
|
||||
return client
|
||||
}
|
||||
|
||||
get selectedHomeserver() {
|
||||
return this.state.homeserver
|
||||
? this.homeserverEntry([this.props.ctx.homeserversByURL[this.state.homeserver],
|
||||
this.state.homeserver])
|
||||
: {}
|
||||
}
|
||||
|
||||
homeserverEntry = ([serverName, serverURL]) => serverURL && {
|
||||
id: serverURL,
|
||||
value: serverURL,
|
||||
label: serverName || serverURL,
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
this.updateHomeserverOptions()
|
||||
}
|
||||
|
||||
updateHomeserverOptions() {
|
||||
this.homeserverOptions = Object
|
||||
.entries(this.props.ctx.homeserversByName)
|
||||
.map(this.homeserverEntry)
|
||||
}
|
||||
|
||||
isValidHomeserver(value) {
|
||||
try {
|
||||
return Boolean(new URL(value))
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
avatarUpload = async event => {
|
||||
const file = event.target.files[0]
|
||||
this.setState({
|
||||
uploadingAvatar: true,
|
||||
})
|
||||
const data = await this.readFile(file)
|
||||
const resp = await api.uploadAvatar(this.state.id, data, file.type)
|
||||
this.setState({
|
||||
uploadingAvatar: false,
|
||||
avatar_url: resp.content_uri,
|
||||
})
|
||||
}
|
||||
|
||||
save = async () => {
|
||||
this.setState({ saving: true })
|
||||
const resp = await api.putClient(this.clientInState)
|
||||
if (resp.id) {
|
||||
if (this.isNew) {
|
||||
this.props.history.push(`/client/${resp.id}`)
|
||||
} else {
|
||||
this.setState({ saving: false, error: "" })
|
||||
}
|
||||
this.props.onChange(resp)
|
||||
} else {
|
||||
this.setState({ saving: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
startOrStop = async () => {
|
||||
this.setState({ startingOrStopping: true })
|
||||
const resp = await api.putClient({
|
||||
id: this.props.entry.id,
|
||||
started: !this.props.entry.started,
|
||||
})
|
||||
if (resp.id) {
|
||||
this.props.onChange(resp)
|
||||
this.setState({ startingOrStopping: false, error: "" })
|
||||
} else {
|
||||
this.setState({ startingOrStopping: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
clearCache = async () => {
|
||||
this.setState({ clearingCache: true })
|
||||
const resp = await api.clearClientCache(this.props.entry.id)
|
||||
if (resp.success) {
|
||||
this.setState({ clearingCache: false, error: "" })
|
||||
} else {
|
||||
this.setState({ clearingCache: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
verifyRecoveryKey = async () => {
|
||||
const recoveryKey = window.prompt("Enter recovery key")
|
||||
if (!recoveryKey) {
|
||||
return
|
||||
}
|
||||
this.setState({ verifying: true })
|
||||
const resp = await api.verifyClient(this.props.entry.id, recoveryKey)
|
||||
if (resp.success) {
|
||||
this.setState({ verifying: false, error: "" })
|
||||
} else {
|
||||
this.setState({ verifying: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
generateRecoveryKey = async () => {
|
||||
this.setState({ verifying: true })
|
||||
const resp = await api.generateRecoveryKey(this.props.entry.id)
|
||||
if (resp.success) {
|
||||
this.recovery_key = resp.recovery_key
|
||||
this.setState({ verifying: false, error: "" })
|
||||
} else {
|
||||
this.setState({ verifying: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
get loading() {
|
||||
return this.state.saving || this.state.startingOrStopping
|
||||
|| this.clearingCache || this.state.deleting || this.state.verifying
|
||||
}
|
||||
|
||||
renderStartedContainer = () => {
|
||||
let text
|
||||
if (this.props.entry.started) {
|
||||
if (this.props.entry.sync_ok) {
|
||||
text = "Started"
|
||||
} else {
|
||||
text = "Erroring"
|
||||
}
|
||||
} else if (this.props.entry.enabled) {
|
||||
text = "Stopped"
|
||||
} else {
|
||||
text = "Disabled"
|
||||
}
|
||||
return <div className="started-container">
|
||||
<span className={`started ${this.props.entry.started}
|
||||
${this.props.entry.sync_ok ? "sync_ok" : "sync_error"}
|
||||
${this.props.entry.enabled ? "" : "disabled"}`}/>
|
||||
<span className="text">{text}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
get avatarMXC() {
|
||||
if (this.state.avatar_url === "disable") {
|
||||
return this.props.entry.remote_avatar_url
|
||||
} else if (!this.state.avatar_url?.startsWith("mxc://")) {
|
||||
return null
|
||||
}
|
||||
return this.state.avatar_url
|
||||
}
|
||||
|
||||
get avatarURL() {
|
||||
return api.getAvatarURL({
|
||||
id: this.state.id,
|
||||
avatar_url: this.avatarMXC,
|
||||
})
|
||||
}
|
||||
|
||||
renderSidebar = () => !this.isNew && (
|
||||
<div className="sidebar">
|
||||
<div className={`avatar-container ${this.avatarMXC ? "" : "no-avatar"}
|
||||
${this.state.uploadingAvatar ? "uploading" : ""}`}>
|
||||
{this.avatarMXC && <img className="avatar" src={this.avatarURL} alt="Avatar"/>}
|
||||
<UploadButton className="upload"/>
|
||||
<input className="file-selector" type="file" accept="image/png, image/jpeg"
|
||||
onChange={this.avatarUpload} disabled={this.state.uploadingAvatar}
|
||||
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
|
||||
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
|
||||
{this.state.uploadingAvatar && <Spinner/>}
|
||||
</div>
|
||||
{this.renderStartedContainer()}
|
||||
{(this.props.entry.started || this.props.entry.enabled) && <>
|
||||
<button className="save" onClick={this.startOrStop} disabled={this.loading}>
|
||||
{this.state.startingOrStopping ? <Spinner/>
|
||||
: (this.props.entry.started ? "Stop" : "Start")}
|
||||
</button>
|
||||
<button className="clearcache" onClick={this.clearCache} disabled={this.loading}>
|
||||
{this.state.clearingCache ? <Spinner/> : "Clear cache"}
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
|
||||
renderPreferences = () => (
|
||||
<PrefTable>
|
||||
<PrefInput rowName="User ID" type="text" disabled={!this.isNew} fullWidth={true}
|
||||
name={this.isNew ? "id" : ""} className="id"
|
||||
value={this.state.id} origValue={this.props.entry.id}
|
||||
placeholder="@fancybot:example.com" onChange={this.inputChange}/>
|
||||
{api.getFeatures().client_auth ? (
|
||||
<PrefSelect rowName="Homeserver" options={this.homeserverOptions} fullWidth={true}
|
||||
isSearchable={true} value={this.selectedHomeserver}
|
||||
origValue={this.props.entry.homeserver}
|
||||
onChange={({ value }) => this.setState({ homeserver: value })}
|
||||
creatable={true} isValidNewOption={this.isValidHomeserver}/>
|
||||
) : (
|
||||
<PrefInput rowName="Homeserver" type="text" name="homeserver" fullWidth={true}
|
||||
value={this.state.homeserver} origValue={this.props.entry.homeserver}
|
||||
placeholder="https://example.com" onChange={this.inputChange}/>
|
||||
)}
|
||||
<PrefInput rowName="Access token" type="text" name="access_token"
|
||||
value={this.state.access_token} origValue={this.props.entry.access_token}
|
||||
placeholder="syt_bWF1Ym90_tUleVHiGyLKwXLaAMqlm_0afdcq"
|
||||
onChange={this.inputChange}/>
|
||||
<PrefInput rowName="Device ID" type="text" name="device_id"
|
||||
value={this.state.device_id || ""}
|
||||
origValue={this.props.entry.device_id || ""}
|
||||
placeholder="maubot_F00BAR12" onChange={this.inputChange}/>
|
||||
{this.props.entry.fingerprint && <>
|
||||
<PrefInput
|
||||
rowName="E2EE device fingerprint" type="text" disabled={true}
|
||||
value={this.props.entry.fingerprint} className="fingerprint"
|
||||
/>
|
||||
<PrefInput
|
||||
rowName="Trust state" type="text" disabled={true}
|
||||
value={this.props.entry.trust_state || ""} className="trust-state"
|
||||
/>
|
||||
</>}
|
||||
<PrefInput rowName="Display name" type="text" name="displayname"
|
||||
value={this.state.displayname} origValue={this.props.entry.displayname}
|
||||
placeholder="My fancy bot" onChange={this.inputChange}/>
|
||||
<PrefInput rowName="Avatar URL" type="text" name="avatar_url"
|
||||
value={this.state.avatar_url} origValue={this.props.entry.avatar_url}
|
||||
placeholder="mxc://example.com/mbmwyoTvPhEQPiCskcUsppko"
|
||||
onChange={this.inputChange}/>
|
||||
<PrefSwitch rowName="Sync"
|
||||
active={this.state.sync} origActive={this.props.entry.sync}
|
||||
onToggle={sync => this.setState({ sync })}/>
|
||||
<PrefSwitch rowName="Autojoin"
|
||||
active={this.state.autojoin} origActive={this.props.entry.autojoin}
|
||||
onToggle={autojoin => this.setState({ autojoin })}/>
|
||||
<PrefSwitch rowName="Enabled"
|
||||
active={this.state.enabled} origActive={this.props.entry.enabled}
|
||||
onToggle={enabled => this.setState({
|
||||
enabled,
|
||||
started: enabled && this.state.started,
|
||||
})}/>
|
||||
<PrefSwitch rowName="Online"
|
||||
active={this.state.online} origActive={this.props.entry.online}
|
||||
onToggle={online => this.setState({ online })} />
|
||||
</PrefTable>
|
||||
)
|
||||
|
||||
renderPrefButtons = () => <>
|
||||
<div className="buttons">
|
||||
{!this.isNew && (
|
||||
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
|
||||
onClick={this.delete} disabled={this.loading || this.hasInstances}
|
||||
title={this.hasInstances ? "Can't delete client that is in use" : ""}>
|
||||
{this.state.deleting ? <Spinner/> : "Delete"}
|
||||
</button>
|
||||
)}
|
||||
<button className="save" onClick={this.save} disabled={this.loading}>
|
||||
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
|
||||
</button>
|
||||
</div>
|
||||
{this.renderLogButton(this.state.id)}
|
||||
<div className="error">{this.state.error}</div>
|
||||
</>
|
||||
|
||||
renderVerifyButtons = () => <>
|
||||
<div className="buttons">
|
||||
<button className="verify" onClick={this.verifyRecoveryKey} disabled={this.loading}>
|
||||
{this.state.verifying ? <Spinner/> : "Verify"}
|
||||
</button>
|
||||
<button className="verify" onClick={this.generateRecoveryKey} disabled={this.loading}>
|
||||
{this.state.verifying ? <Spinner/> : "Generate recovery key"}
|
||||
</button>
|
||||
</div>
|
||||
{this.recovery_key && <div className="recovery-key">
|
||||
<strong>Recovery key:</strong> <code>{this.recovery_key}</code>
|
||||
</div>}
|
||||
</>
|
||||
|
||||
render() {
|
||||
return <>
|
||||
<div className="client">
|
||||
{this.renderSidebar()}
|
||||
<div className="info">
|
||||
{this.renderPreferences()}
|
||||
{this.renderPrefButtons()}
|
||||
{this.props.entry.fingerprint && this.renderVerifyButtons()}
|
||||
{this.renderInstances()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Client)
|
||||
@@ -0,0 +1,30 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { Component } from "react"
|
||||
|
||||
class Home extends Component {
|
||||
render() {
|
||||
return <>
|
||||
<div className="home">
|
||||
See sidebar to get started
|
||||
</div>
|
||||
<div className="buttons">
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
export default Home
|
||||
@@ -0,0 +1,233 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2022 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 from "react"
|
||||
import { Link, NavLink, Route, Switch, withRouter } from "react-router-dom"
|
||||
import AceEditor from "react-ace"
|
||||
import "ace-builds/src-noconflict/mode-yaml"
|
||||
import "ace-builds/src-noconflict/theme-github"
|
||||
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
|
||||
import { ReactComponent as NoAvatarIcon } from "../../res/bot.svg"
|
||||
import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable"
|
||||
import api from "../../api"
|
||||
import Spinner from "../../components/Spinner"
|
||||
import BaseMainView from "./BaseMainView"
|
||||
import InstanceDatabase from "./InstanceDatabase"
|
||||
|
||||
const InstanceListEntry = ({ entry }) => (
|
||||
<NavLink className="instance entry" to={`/instance/${entry.id}`}>
|
||||
<span className="id">{entry.id}</span>
|
||||
<ChevronRight className='chevron'/>
|
||||
</NavLink>
|
||||
)
|
||||
|
||||
class Instance extends BaseMainView {
|
||||
static ListEntry = InstanceListEntry
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.deleteFunc = api.deleteInstance
|
||||
this.updateClientOptions()
|
||||
}
|
||||
|
||||
get entryKeys() {
|
||||
return ["id", "primary_user", "enabled", "started", "type", "config", "database_engine"]
|
||||
}
|
||||
|
||||
get initialState() {
|
||||
return {
|
||||
id: "",
|
||||
primary_user: "",
|
||||
enabled: true,
|
||||
started: true,
|
||||
type: "",
|
||||
config: "",
|
||||
database_engine: "",
|
||||
|
||||
saving: false,
|
||||
deleting: false,
|
||||
error: "",
|
||||
}
|
||||
}
|
||||
|
||||
get instanceInState() {
|
||||
const instance = Object.assign({}, this.state)
|
||||
delete instance.saving
|
||||
delete instance.deleting
|
||||
delete instance.error
|
||||
return instance
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
this.updateClientOptions()
|
||||
}
|
||||
|
||||
getAvatarMXC(client) {
|
||||
return client.avatar_url === "disable" ? client.remote_avatar_url : client.avatar_url
|
||||
}
|
||||
|
||||
getAvatarURL(client) {
|
||||
return api.getAvatarURL({
|
||||
id: client.id,
|
||||
avatar_url: this.getAvatarMXC(client),
|
||||
})
|
||||
}
|
||||
|
||||
clientSelectEntry = client => client && {
|
||||
id: client.id,
|
||||
value: client.id,
|
||||
label: (
|
||||
<div className="select-client">
|
||||
{this.getAvatarMXC(client)
|
||||
? <img className="avatar" src={this.getAvatarURL(client)} alt=""/>
|
||||
: <NoAvatarIcon className='avatar'/>}
|
||||
<span className="displayname">{
|
||||
(client.displayname === "disable"
|
||||
? client.remote_displayname
|
||||
: client.displayname
|
||||
) || client.id
|
||||
}</span>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
updateClientOptions() {
|
||||
this.clientOptions = Object.values(this.props.ctx.clients).map(this.clientSelectEntry)
|
||||
}
|
||||
|
||||
save = async () => {
|
||||
this.setState({ saving: true })
|
||||
const resp = await api.putInstance(this.instanceInState, this.props.entry
|
||||
? this.props.entry.id : undefined)
|
||||
if (resp.id) {
|
||||
if (this.isNew) {
|
||||
this.props.history.push(`/instance/${resp.id}`)
|
||||
} else {
|
||||
if (resp.id !== this.props.entry.id) {
|
||||
this.props.history.replace(`/instance/${resp.id}`)
|
||||
}
|
||||
this.setState({ saving: false, error: "" })
|
||||
}
|
||||
this.props.onChange(resp)
|
||||
} else {
|
||||
this.setState({ saving: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
get selectedClientEntry() {
|
||||
return this.state.primary_user
|
||||
? this.clientSelectEntry(this.props.ctx.clients[this.state.primary_user])
|
||||
: {}
|
||||
}
|
||||
|
||||
get selectedPluginEntry() {
|
||||
return {
|
||||
id: this.state.type,
|
||||
value: this.state.type,
|
||||
label: this.state.type,
|
||||
}
|
||||
}
|
||||
|
||||
get typeOptions() {
|
||||
return Object.values(this.props.ctx.plugins).map(plugin => plugin && {
|
||||
id: plugin.id,
|
||||
value: plugin.id,
|
||||
label: plugin.id,
|
||||
})
|
||||
}
|
||||
|
||||
get loading() {
|
||||
return this.state.deleting || this.state.saving
|
||||
}
|
||||
|
||||
get isValid() {
|
||||
return this.state.id && this.state.primary_user && this.state.type
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Switch>
|
||||
<Route path="/instance/:id/database" render={this.renderDatabase}/>
|
||||
<Route render={this.renderMain}/>
|
||||
</Switch>
|
||||
}
|
||||
|
||||
renderDatabase = () => <InstanceDatabase instanceID={this.props.entry.id}/>
|
||||
|
||||
renderMain = () => <div className="instance">
|
||||
<PrefTable>
|
||||
<PrefInput rowName="ID" type="text" name="id" value={this.state.id}
|
||||
placeholder="fancybotinstance" onChange={this.inputChange}
|
||||
disabled={!this.isNew} fullWidth={true} className="id"/>
|
||||
<PrefSwitch rowName="Enabled"
|
||||
active={this.state.enabled} origActive={this.props.entry.enabled}
|
||||
onToggle={enabled => this.setState({ enabled })}/>
|
||||
<PrefSwitch rowName="Running"
|
||||
active={this.state.started} origActive={this.props.entry.started}
|
||||
onToggle={started => this.setState({ started })}/>
|
||||
{api.getFeatures().client ? (
|
||||
<PrefSelect rowName="Primary user" options={this.clientOptions}
|
||||
isSearchable={false} value={this.selectedClientEntry}
|
||||
origValue={this.props.entry.primary_user}
|
||||
onChange={({ id }) => this.setState({ primary_user: id })}/>
|
||||
) : (
|
||||
<PrefInput rowName="Primary user" type="text" name="primary_user"
|
||||
value={this.state.primary_user} placeholder="@user:example.com"
|
||||
onChange={this.inputChange}/>
|
||||
)}
|
||||
{api.getFeatures().plugin ? (
|
||||
<PrefSelect rowName="Type" options={this.typeOptions} isSearchable={false}
|
||||
value={this.selectedPluginEntry} origValue={this.props.entry.type}
|
||||
onChange={({ id }) => this.setState({ type: id })}/>
|
||||
) : (
|
||||
<PrefInput rowName="Type" type="text" name="type" value={this.state.type}
|
||||
placeholder="xyz.maubot.example" onChange={this.inputChange}/>
|
||||
)}
|
||||
</PrefTable>
|
||||
{!this.isNew && Boolean(this.props.entry.base_config) &&
|
||||
<AceEditor mode="yaml" theme="github" onChange={config => this.setState({ config })}
|
||||
name="config" value={this.state.config}
|
||||
editorProps={{
|
||||
fontSize: "10pt",
|
||||
$blockScrolling: true,
|
||||
}}/>}
|
||||
<div className="buttons">
|
||||
{!this.isNew && (
|
||||
<button className="delete" onClick={this.delete} disabled={this.loading}>
|
||||
{this.state.deleting ? <Spinner/> : "Delete"}
|
||||
</button>
|
||||
)}
|
||||
<button className={`save ${this.isValid ? "" : "disabled-bg"}`}
|
||||
onClick={this.save} disabled={this.loading || !this.isValid}>
|
||||
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
|
||||
</button>
|
||||
</div>
|
||||
{!this.isNew && <div className="buttons">
|
||||
{this.props.entry.database && (
|
||||
<Link className="button open-database"
|
||||
to={`/instance/${this.state.id}/database`}>
|
||||
View database
|
||||
</Link>
|
||||
)}
|
||||
{api.getFeatures().log &&
|
||||
<button className="open-log"
|
||||
onClick={() => this.props.openLog(`instance.${this.state.id}`)}>
|
||||
View logs
|
||||
</button>}
|
||||
</div>}
|
||||
<div className="error">{this.state.error}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default withRouter(Instance)
|
||||
@@ -0,0 +1,332 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { Component } from "react"
|
||||
import { NavLink, Link, withRouter } from "react-router-dom"
|
||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from "react-contextmenu"
|
||||
import { ReactComponent as ChevronLeft } from "../../res/chevron-left.svg"
|
||||
import { ReactComponent as OrderDesc } from "../../res/sort-down.svg"
|
||||
import { ReactComponent as OrderAsc } from "../../res/sort-up.svg"
|
||||
import api from "../../api"
|
||||
import Spinner from "../../components/Spinner"
|
||||
|
||||
// eslint-disable-next-line no-extend-native
|
||||
Map.prototype.map = function(func) {
|
||||
const res = []
|
||||
for (const [key, value] of this) {
|
||||
res.push(func(value, key, this))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
class InstanceDatabase extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
tables: null,
|
||||
header: null,
|
||||
content: null,
|
||||
query: "",
|
||||
selectedTable: null,
|
||||
|
||||
error: null,
|
||||
|
||||
prevQuery: null,
|
||||
statusMsg: null,
|
||||
rowCount: null,
|
||||
insertedPrimaryKey: null,
|
||||
}
|
||||
this.order = new Map()
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const tables = new Map(Object.entries(await api.getInstanceDatabase(this.props.instanceID)))
|
||||
for (const [name, table] of tables) {
|
||||
table.name = name
|
||||
table.columns = new Map(Object.entries(table.columns))
|
||||
for (const [columnName, column] of table.columns) {
|
||||
column.name = columnName
|
||||
column.sort = null
|
||||
}
|
||||
}
|
||||
this.setState({ tables })
|
||||
this.checkLocationTable()
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.location !== prevProps.location) {
|
||||
this.order = new Map()
|
||||
this.setState({ header: null, content: null })
|
||||
this.checkLocationTable()
|
||||
}
|
||||
}
|
||||
|
||||
checkLocationTable() {
|
||||
const prefix = `/instance/${this.props.instanceID}/database/`
|
||||
if (this.props.location.pathname.startsWith(prefix)) {
|
||||
const table = this.props.location.pathname.substr(prefix.length)
|
||||
this.setState({ selectedTable: table })
|
||||
this.buildSQLQuery(table)
|
||||
}
|
||||
}
|
||||
|
||||
getSortQueryParams(table) {
|
||||
const order = []
|
||||
for (const [column, sort] of Array.from(this.order.entries()).reverse()) {
|
||||
order.push(`order=${column}:${sort}`)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
buildSQLQuery(table = this.state.selectedTable, resetContent = true) {
|
||||
let query = `SELECT * FROM "${table}"`
|
||||
|
||||
if (this.order.size > 0) {
|
||||
const order = Array.from(this.order.entries()).reverse()
|
||||
.map(([column, sort]) => `${column} ${sort}`)
|
||||
query += ` ORDER BY ${order.join(", ")}`
|
||||
}
|
||||
|
||||
query += " LIMIT 100"
|
||||
this.setState({ query }, () => this.reloadContent(resetContent))
|
||||
}
|
||||
|
||||
reloadContent = async (resetContent = true) => {
|
||||
this.setState({ loading: true })
|
||||
const res = await api.queryInstanceDatabase(this.props.instanceID, this.state.query)
|
||||
this.setState({ loading: false })
|
||||
if (resetContent) {
|
||||
this.setState({
|
||||
prevQuery: null,
|
||||
rowCount: null,
|
||||
insertedPrimaryKey: null,
|
||||
statusMsg: null,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
if (!res.ok) {
|
||||
this.setState({
|
||||
error: res.error,
|
||||
})
|
||||
} else if (res.rows) {
|
||||
this.setState({
|
||||
header: res.columns,
|
||||
content: res.rows,
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
prevQuery: res.query,
|
||||
rowCount: res.rowcount,
|
||||
insertedPrimaryKey: res.inserted_primary_key,
|
||||
statusMsg: res.status_msg,
|
||||
})
|
||||
this.buildSQLQuery(this.state.selectedTable, false)
|
||||
}
|
||||
}
|
||||
|
||||
toggleSort(column) {
|
||||
const oldSort = this.order.get(column) || "auto"
|
||||
this.order.delete(column)
|
||||
switch (oldSort) {
|
||||
case "auto":
|
||||
this.order.set(column, "DESC")
|
||||
break
|
||||
case "DESC":
|
||||
this.order.set(column, "ASC")
|
||||
break
|
||||
case "ASC":
|
||||
default:
|
||||
break
|
||||
}
|
||||
this.buildSQLQuery()
|
||||
}
|
||||
|
||||
getSortIcon(column) {
|
||||
switch (this.order.get(column)) {
|
||||
case "DESC":
|
||||
return <OrderDesc/>
|
||||
case "ASC":
|
||||
return <OrderAsc/>
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getColumnInfo(columnName) {
|
||||
const table = this.state.tables.get(this.state.selectedTable)
|
||||
if (!table) {
|
||||
return null
|
||||
}
|
||||
const column = table.columns.get(columnName)
|
||||
if (!column) {
|
||||
return null
|
||||
}
|
||||
if (column.primary) {
|
||||
return <span className="meta"> (pk)</span>
|
||||
} else if (column.unique) {
|
||||
return <span className="meta"> (u)</span>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
getColumnType(columnName) {
|
||||
const table = this.state.tables.get(this.state.selectedTable)
|
||||
if (!table) {
|
||||
return null
|
||||
}
|
||||
const column = table.columns.get(columnName)
|
||||
if (!column) {
|
||||
return null
|
||||
}
|
||||
return column.type
|
||||
}
|
||||
|
||||
deleteRow = async (_, data) => {
|
||||
const values = this.state.content[data.row]
|
||||
const keys = this.state.header
|
||||
const condition = []
|
||||
for (const [index, key] of Object.entries(keys)) {
|
||||
const val = values[index]
|
||||
condition.push(`${key}='${this.sqlEscape(val.toString())}'`)
|
||||
}
|
||||
const query = `DELETE FROM "${this.state.selectedTable}" WHERE ${condition.join(" AND ")}`
|
||||
const res = await api.queryInstanceDatabase(this.props.instanceID, query)
|
||||
this.setState({
|
||||
prevQuery: `DELETE FROM "${this.state.selectedTable}" ...`,
|
||||
rowCount: res.rowcount,
|
||||
})
|
||||
await this.reloadContent(false)
|
||||
}
|
||||
|
||||
editCell = async (evt, data) => {
|
||||
console.log("Edit", data)
|
||||
}
|
||||
|
||||
collectContextMeta = props => ({
|
||||
row: props.row,
|
||||
col: props.col,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
sqlEscape = str => str.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, char => {
|
||||
switch (char) {
|
||||
case "\0":
|
||||
return "\\0"
|
||||
case "\x08":
|
||||
return "\\b"
|
||||
case "\x09":
|
||||
return "\\t"
|
||||
case "\x1a":
|
||||
return "\\z"
|
||||
case "\n":
|
||||
return "\\n"
|
||||
case "\r":
|
||||
return "\\r"
|
||||
case "\"":
|
||||
case "'":
|
||||
case "\\":
|
||||
case "%":
|
||||
return "\\" + char
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
|
||||
renderTable = () => <div className="table">
|
||||
{this.state.header ? <>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{this.state.header.map(column => (
|
||||
<td key={column}>
|
||||
<span onClick={() => this.toggleSort(column)}
|
||||
title={this.getColumnType(column)}>
|
||||
<strong>{column}</strong>
|
||||
{this.getColumnInfo(column)}
|
||||
{this.getSortIcon(column)}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{this.state.content.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{row.map((cell, colIndex) => (
|
||||
<ContextMenuTrigger key={colIndex} id="database_table_menu"
|
||||
renderTag="td" row={rowIndex} col={colIndex}
|
||||
collect={this.collectContextMeta}>
|
||||
{cell}
|
||||
</ContextMenuTrigger>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ContextMenu id="database_table_menu">
|
||||
<MenuItem onClick={this.deleteRow}>Delete row</MenuItem>
|
||||
<MenuItem disabled onClick={this.editCell}>Edit cell</MenuItem>
|
||||
</ContextMenu>
|
||||
</> : this.state.loading ? <Spinner/> : null}
|
||||
</div>
|
||||
|
||||
renderContent() {
|
||||
return <>
|
||||
<div className="tables">
|
||||
{this.state.tables.map((_, tbl) => (
|
||||
<NavLink key={tbl} to={`/instance/${this.props.instanceID}/database/${tbl}`}>
|
||||
{tbl}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<div className="query">
|
||||
<input type="text" value={this.state.query} name="query"
|
||||
onChange={evt => this.setState({ query: evt.target.value })}/>
|
||||
<button type="submit" onClick={this.reloadContent}>Query</button>
|
||||
</div>
|
||||
{this.state.error && <div className="error">
|
||||
{this.state.error}
|
||||
</div>}
|
||||
{this.state.prevQuery && <div className="prev-query">
|
||||
<p>
|
||||
Executed <span className="query">{this.state.prevQuery}</span> - {
|
||||
this.state.statusMsg
|
||||
|| <>affected <strong>{this.state.rowCount} rows</strong>.</>
|
||||
}
|
||||
</p>
|
||||
{this.state.insertedPrimaryKey && <p className="inserted-primary-key">
|
||||
Inserted primary key: {this.state.insertedPrimaryKey}
|
||||
</p>}
|
||||
</div>}
|
||||
{this.renderTable()}
|
||||
</>
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="instance-database">
|
||||
<div className="topbar">
|
||||
<Link className="topbar" to={`/instance/${this.props.instanceID}`}>
|
||||
<ChevronLeft/>
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
{this.state.tables
|
||||
? this.renderContent()
|
||||
: <Spinner className="maubot-loading"/>}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(InstanceDatabase)
|
||||
191
maubot-src/maubot/management/frontend/src/pages/dashboard/Log.js
Normal file
191
maubot-src/maubot/management/frontend/src/pages/dashboard/Log.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2022 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, { PureComponent } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { JSONTree } from "react-json-tree"
|
||||
import api from "../../api"
|
||||
import Modal from "./Modal"
|
||||
|
||||
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>
|
||||
{line.nameSuffix}
|
||||
</>
|
||||
}
|
||||
return line.name
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
if (this.props.line.matrix_http_request) {
|
||||
const req = this.props.line.matrix_http_request
|
||||
|
||||
return <>
|
||||
{req.method} {req.url || req.path}
|
||||
<div className="content">
|
||||
{Object.entries(req.content || {}).length > 0
|
||||
&& <JSONTree data={{ content: req.content }} hideRoot={true}/>}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
return this.props.line.msg
|
||||
}
|
||||
|
||||
onClickOpen(path, line) {
|
||||
return () => {
|
||||
if (api.debugOpenFileEnabled()) {
|
||||
api.debugOpenFile(path, line)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
renderTimeTitle() {
|
||||
return this.props.line.time.toDateString()
|
||||
}
|
||||
|
||||
renderTime() {
|
||||
return <a className="time" title={this.renderTimeTitle()}
|
||||
href={`file:///${this.props.line.pathname}:${this.props.line.lineno}`}
|
||||
onClick={this.onClickOpen(this.props.line.pathname, this.props.line.lineno)}>
|
||||
{this.props.line.time.toLocaleTimeString("en-GB")}
|
||||
</a>
|
||||
}
|
||||
|
||||
renderLevelName() {
|
||||
return <span className="level">
|
||||
{this.props.line.levelname}
|
||||
</span>
|
||||
}
|
||||
|
||||
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}`}>
|
||||
{this.renderTime()}
|
||||
{this.renderLevelName()}
|
||||
<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={`file:///${file}:${line}`} onClick={this.onClickOpen(file, line)}>
|
||||
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 {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.linesRef = React.createRef()
|
||||
this.linesBottomRef = React.createRef()
|
||||
}
|
||||
|
||||
getSnapshotBeforeUpdate() {
|
||||
if (this.linesRef.current && this.linesBottomRef.current) {
|
||||
return Log.isVisible(this.linesRef.current, this.linesBottomRef.current)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
componentDidUpdate(_1, _2, wasVisible) {
|
||||
if (wasVisible) {
|
||||
Log.scrollParentToChild(this.linesRef.current, this.linesBottomRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.linesRef.current && this.linesBottomRef.current) {
|
||||
Log.scrollParentToChild(this.linesRef.current, this.linesBottomRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
static scrollParentToChild(parent, child) {
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
const childRect = child.getBoundingClientRect()
|
||||
|
||||
if (!Log.isVisible(parent, child)) {
|
||||
parent.scrollBy({ top: (childRect.top + parent.scrollTop) - parentRect.top })
|
||||
}
|
||||
}
|
||||
|
||||
static isVisible(parent, child) {
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
const childRect = child.getBoundingClientRect()
|
||||
return (childRect.top >= parentRect.top)
|
||||
&& (childRect.top <= parentRect.top + parent.clientHeight)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="log" ref={this.linesRef}>
|
||||
<div className="lines">
|
||||
{this.props.lines.map(data => <LogEntry key={data.id} line={data}
|
||||
focus={this.props.focus}/>)}
|
||||
</div>
|
||||
<div ref={this.linesBottomRef}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Log
|
||||
@@ -0,0 +1,52 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2022 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 })
|
||||
isOpen = () => this.state.open
|
||||
|
||||
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
|
||||
@@ -0,0 +1,104 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2022 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 from "react"
|
||||
import { NavLink, withRouter } from "react-router-dom"
|
||||
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
|
||||
import { ReactComponent as UploadButton } from "../../res/upload.svg"
|
||||
import PrefTable, { PrefInput } from "../../components/PreferenceTable"
|
||||
import Spinner from "../../components/Spinner"
|
||||
import api from "../../api"
|
||||
import BaseMainView from "./BaseMainView"
|
||||
|
||||
const PluginListEntry = ({ entry }) => (
|
||||
<NavLink className="plugin entry" to={`/plugin/${entry.id}`}>
|
||||
<span className="id">{entry.id}</span>
|
||||
<ChevronRight className='chevron'/>
|
||||
</NavLink>
|
||||
)
|
||||
|
||||
|
||||
class Plugin extends BaseMainView {
|
||||
static ListEntry = PluginListEntry
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.deleteFunc = api.deletePlugin
|
||||
}
|
||||
|
||||
get initialState() {
|
||||
return {
|
||||
id: "",
|
||||
version: "",
|
||||
|
||||
instances: [],
|
||||
|
||||
uploading: false,
|
||||
deleting: false,
|
||||
error: "",
|
||||
}
|
||||
}
|
||||
|
||||
upload = async event => {
|
||||
const file = event.target.files[0]
|
||||
this.setState({
|
||||
uploadingAvatar: true,
|
||||
})
|
||||
const data = await this.readFile(file)
|
||||
const resp = await api.uploadPlugin(data, this.state.id)
|
||||
if (resp.id) {
|
||||
if (this.isNew) {
|
||||
this.props.history.push(`/plugin/${resp.id}`)
|
||||
} else {
|
||||
this.setState({ saving: false, error: "" })
|
||||
}
|
||||
this.props.onChange(resp)
|
||||
} else {
|
||||
this.setState({ saving: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="plugin">
|
||||
{!this.isNew && <PrefTable>
|
||||
<PrefInput rowName="ID" type="text" value={this.state.id} disabled={true}
|
||||
className="id"/>
|
||||
<PrefInput rowName="Version" type="text" value={this.state.version}
|
||||
disabled={true}/>
|
||||
</PrefTable>}
|
||||
{api.getFeatures().plugin_upload &&
|
||||
<div className={`upload-box ${this.state.uploading ? "uploading" : ""}`}>
|
||||
<UploadButton className="upload"/>
|
||||
<input className="file-selector" type="file" accept="application/zip+mbp"
|
||||
onChange={this.upload} disabled={this.state.uploading || this.state.deleting}
|
||||
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
|
||||
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
|
||||
{this.state.uploading && <Spinner/>}
|
||||
</div>}
|
||||
{!this.isNew && <div className="buttons">
|
||||
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
|
||||
onClick={this.delete} disabled={this.loading || this.hasInstances}
|
||||
title={this.hasInstances ? "Can't delete plugin that is in use" : ""}>
|
||||
{this.state.deleting ? <Spinner/> : "Delete"}
|
||||
</button>
|
||||
</div>}
|
||||
{this.renderLogButton("loader.zip")}
|
||||
<div className="error">{this.state.error}</div>
|
||||
{this.renderInstances()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Plugin)
|
||||
@@ -0,0 +1,272 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2022 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { Component } from "react"
|
||||
import { Route, Switch, Link, withRouter } from "react-router-dom"
|
||||
import api from "../../api"
|
||||
import { ReactComponent as Plus } from "../../res/plus.svg"
|
||||
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) {
|
||||
super(props)
|
||||
this.state = {
|
||||
instances: {},
|
||||
clients: {},
|
||||
plugins: {},
|
||||
homeserversByName: {},
|
||||
homeserversByURL: {},
|
||||
sidebarOpen: false,
|
||||
modalOpen: false,
|
||||
logFocus: null,
|
||||
logLines: [],
|
||||
}
|
||||
this.logModal = {
|
||||
open: () => undefined,
|
||||
isOpen: () => false,
|
||||
}
|
||||
window.maubot = this
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.location !== prevProps.location) {
|
||||
this.setState({ sidebarOpen: false })
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const [instanceList, clientList, pluginList, homeservers] = await Promise.all([
|
||||
api.getInstances(), api.getClients(), api.getPlugins(), api.getClientAuthServers(),
|
||||
api.updateDebugOpenFileEnabled()])
|
||||
const instances = {}
|
||||
if (api.getFeatures().instance) {
|
||||
for (const instance of instanceList) {
|
||||
instances[instance.id] = instance
|
||||
}
|
||||
}
|
||||
const clients = {}
|
||||
if (api.getFeatures().client) {
|
||||
for (const client of clientList) {
|
||||
clients[client.id] = client
|
||||
}
|
||||
}
|
||||
const plugins = {}
|
||||
if (api.getFeatures().plugin) {
|
||||
for (const plugin of pluginList) {
|
||||
plugins[plugin.id] = plugin
|
||||
}
|
||||
}
|
||||
const homeserversByName = homeservers
|
||||
const homeserversByURL = {}
|
||||
if (api.getFeatures().client_auth) {
|
||||
for (const [key, value] of Object.entries(homeservers)) {
|
||||
homeserversByURL[value] = key
|
||||
}
|
||||
}
|
||||
this.setState({ instances, clients, plugins, homeserversByName, homeserversByURL })
|
||||
|
||||
await this.enableLogs()
|
||||
}
|
||||
|
||||
async enableLogs() {
|
||||
if (!api.getFeatures().log) {
|
||||
return
|
||||
}
|
||||
|
||||
const logs = await api.openLogSocket()
|
||||
|
||||
const processEntry = (entry) => {
|
||||
entry.time = new Date(entry.time)
|
||||
entry.nameSuffix = ""
|
||||
if (entry.name.startsWith("maubot.client.")) {
|
||||
if (entry.name.endsWith(".crypto")) {
|
||||
entry.name = entry.name.slice(0, -".crypto".length)
|
||||
entry.nameSuffix = "/crypto"
|
||||
}
|
||||
entry.name = `client/${entry.name.slice("maubot.client.".length)}`
|
||||
entry.nameLink = `/${entry.name}`
|
||||
} else if (entry.name.startsWith("maubot.instance.")) {
|
||||
entry.name = `instance/${entry.name.slice("maubot.instance.".length)}`
|
||||
entry.nameLink = `/${entry.name}`
|
||||
} else if (entry.name.startsWith("maubot.instance_db.")) {
|
||||
entry.nameSuffix = "/db"
|
||||
entry.name = `instance/${entry.name.slice("maubot.instance_db.".length)}`
|
||||
entry.nameLink = `/${entry.name}`
|
||||
}
|
||||
}
|
||||
|
||||
logs.onHistory = history => {
|
||||
for (const data of history) {
|
||||
processEntry(data)
|
||||
}
|
||||
this.setState({
|
||||
logLines: history,
|
||||
})
|
||||
}
|
||||
logs.onLog = data => {
|
||||
processEntry(data)
|
||||
this.setState({
|
||||
logLines: this.state.logLines.concat(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
renderList(field, type) {
|
||||
return this.state[field] && Object.values(this.state[field]).map(entry =>
|
||||
React.createElement(type, { key: entry.id, entry }))
|
||||
}
|
||||
|
||||
delete(stateField, id) {
|
||||
const data = Object.assign({}, this.state[stateField])
|
||||
delete data[id]
|
||||
this.setState({ [stateField]: data })
|
||||
}
|
||||
|
||||
add(stateField, entry, oldID = undefined) {
|
||||
const data = Object.assign({}, this.state[stateField])
|
||||
if (oldID && oldID !== entry.id) {
|
||||
delete data[oldID]
|
||||
}
|
||||
data[entry.id] = entry
|
||||
this.setState({ [stateField]: data })
|
||||
}
|
||||
|
||||
renderView(field, type, id) {
|
||||
const typeName = field.slice(0, -1)
|
||||
if (!api.getFeatures()[typeName]) {
|
||||
return this.renderDisabled(typeName)
|
||||
}
|
||||
const entry = this.state[field][id]
|
||||
if (!entry) {
|
||||
return this.renderNotFound(typeName)
|
||||
}
|
||||
return React.createElement(type, {
|
||||
entry,
|
||||
onDelete: () => this.delete(field, id),
|
||||
onChange: newEntry => this.add(field, newEntry, id),
|
||||
openLog: this.openLog,
|
||||
ctx: this.state,
|
||||
})
|
||||
}
|
||||
|
||||
openLog = filter => {
|
||||
this.setState({
|
||||
logFocus: typeof filter === "string" ? filter : null,
|
||||
})
|
||||
this.logModal.open()
|
||||
}
|
||||
|
||||
renderNotFound = (thing = "path") => (
|
||||
<div className="not-found">
|
||||
Oops! I'm afraid that {thing} couldn't be found.
|
||||
</div>
|
||||
)
|
||||
|
||||
renderDisabled = (thing = "path") => (
|
||||
<div className="not-found">
|
||||
The {thing} API has been disabled in the maubot config.
|
||||
</div>
|
||||
)
|
||||
|
||||
renderMain() {
|
||||
return <div className={`dashboard ${this.state.sidebarOpen ? "sidebar-open" : ""}`}>
|
||||
<Link to="/" className="title">
|
||||
<img src="favicon.png" alt=""/>
|
||||
Maubot Manager
|
||||
</Link>
|
||||
<div className="user">
|
||||
<span>{localStorage.username}</span>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar">
|
||||
<div className="buttons">
|
||||
{api.getFeatures().log && <button className="open-log" onClick={this.openLog}>
|
||||
<span>View logs</span>
|
||||
</button>}
|
||||
</div>
|
||||
{api.getFeatures().instance && <div className="instances list">
|
||||
<div className="title">
|
||||
<h2>Instances</h2>
|
||||
<Link to="/new/instance"><Plus/></Link>
|
||||
</div>
|
||||
{this.renderList("instances", Instance.ListEntry)}
|
||||
</div>}
|
||||
{api.getFeatures().client && <div className="clients list">
|
||||
<div className="title">
|
||||
<h2>Clients</h2>
|
||||
<Link to="/new/client"><Plus/></Link>
|
||||
</div>
|
||||
{this.renderList("clients", Client.ListEntry)}
|
||||
</div>}
|
||||
{api.getFeatures().plugin && <div className="plugins list">
|
||||
<div className="title">
|
||||
<h2>Plugins</h2>
|
||||
{api.getFeatures().plugin_upload && <Link to="/new/plugin"><Plus/></Link>}
|
||||
</div>
|
||||
{this.renderList("plugins", Plugin.ListEntry)}
|
||||
</div>}
|
||||
</nav>
|
||||
|
||||
<div className="topbar"
|
||||
onClick={evt => this.setState({ sidebarOpen: !this.state.sidebarOpen })}>
|
||||
<div className={`hamburger ${this.state.sidebarOpen ? "active" : ""}`}>
|
||||
<span/><span/><span/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="view">
|
||||
<Switch>
|
||||
<Route path="/" exact render={() => <Home openLog={this.openLog}/>}/>
|
||||
<Route path="/new/instance" render={() =>
|
||||
<Instance onChange={newEntry => this.add("instances", newEntry)}
|
||||
entry={{}} ctx={this.state}/>}/>
|
||||
<Route path="/new/client" render={() =>
|
||||
<Client onChange={newEntry => this.add("clients", newEntry)}
|
||||
entry={{}} ctx={this.state}/>}/>
|
||||
<Route path="/new/plugin" render={() =>
|
||||
<Plugin onChange={newEntry => this.add("plugins", newEntry)}
|
||||
entry={{}} ctx={this.state}/>}/>
|
||||
<Route path="/instance/:id" render={({ match }) =>
|
||||
this.renderView("instances", Instance, match.params.id)}/>
|
||||
<Route path="/client/:id" render={({ match }) =>
|
||||
this.renderView("clients", Client, match.params.id)}/>
|
||||
<Route path="/plugin/:id" render={({ match }) =>
|
||||
this.renderView("plugins", Plugin, match.params.id)}/>
|
||||
<Route render={() => this.renderNotFound()}/>
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
|
||||
renderModal() {
|
||||
return <Modal ref={ref => this.logModal = ref}>
|
||||
<Log lines={this.state.logLines} focus={this.state.logFocus}/>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
render() {
|
||||
return <>
|
||||
{this.renderMain()}
|
||||
{this.renderModal()}
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Dashboard)
|
||||
Reference in New Issue
Block a user