1725 lines
45 KiB
JavaScript
1725 lines
45 KiB
JavaScript
import { Component } from '@component'
|
|
import { Editor } from '@editor'
|
|
import { MessageBus } from '@mbus'
|
|
import { SelectType, SelectNodeDialog, ConnectionDataDialog } from '@select_node'
|
|
|
|
export class App {
|
|
constructor() {// {{{
|
|
window.mbus = new MessageBus()
|
|
this.editor = null
|
|
this.typesList = null
|
|
this.scriptsList = null
|
|
this.scriptEditor = null
|
|
this.scriptExecutionList = null
|
|
|
|
this.currentNode = null
|
|
this.currentNodeID = null
|
|
this.types = []
|
|
this.currentPage = null
|
|
this.tree = new Tree(document.getElementById('nodes'))
|
|
|
|
this.createdScript = null
|
|
|
|
const events = [
|
|
'EDITOR_NODE_SAVE',
|
|
'EXECUTIONS_LIST_FETCHED',
|
|
'MENU_ITEM_SELECTED',
|
|
'NODE_CONNECT',
|
|
'NODE_COPY_PATH',
|
|
'NODE_CREATE_DIALOG',
|
|
'NODE_DELETE',
|
|
'NODE_EDIT_NAME',
|
|
'NODE_MOVE',
|
|
'NODE_SELECTED',
|
|
'NODE_HOOKED',
|
|
'SCRIPT_CREATED',
|
|
'SCRIPT_DELETED',
|
|
'SCRIPT_EDIT',
|
|
'SCRIPTS_LIST_FETCHED',
|
|
'SCRIPT_UPDATED',
|
|
'TREE_RELOAD_NODE',
|
|
'TYPE_EDIT',
|
|
'TYPES_LIST_FETCHED',
|
|
]
|
|
for (const eventName of events)
|
|
mbus.subscribe(eventName, event => this.eventHandler(event))
|
|
|
|
document.addEventListener('keydown', event => this.keyHandler(event))
|
|
|
|
mbus.dispatch('MENU_ITEM_SELECTED', 'node')
|
|
}// }}}
|
|
|
|
eventHandler(event) {// {{{
|
|
switch (event.type) {
|
|
case 'MENU_ITEM_SELECTED':
|
|
const item = document.querySelector(`#menu [data-section="${event.detail}"]`)
|
|
this.page(item, event.detail)
|
|
break
|
|
|
|
case 'NODE_CONNECT':
|
|
const selectnode = new SelectNodeDialog(selectedNode => {
|
|
this.nodeConnect(this.currentNode, selectedNode)
|
|
.then(() => this.edit(this.currentNode.ID))
|
|
})
|
|
selectnode.render()
|
|
break
|
|
|
|
case 'NODE_COPY_PATH':
|
|
const uri = `${location.protocol}//${location.host}/nodes/${event.detail}`
|
|
const img = document.querySelector('#editor-node .copy-path')
|
|
img.classList.add('highlight')
|
|
setTimeout(() => img.classList.remove('highlight'), 150)
|
|
navigator.clipboard.writeText(uri)
|
|
break
|
|
|
|
case 'NODE_SELECTED':
|
|
for (const n of document.querySelectorAll('#nodes .node.selected'))
|
|
n.classList.remove('selected')
|
|
|
|
if (event.detail !== null)
|
|
for (const n of document.querySelectorAll(`#nodes .node[data-node-id="${event.detail}"]`))
|
|
n.classList?.add('selected')
|
|
|
|
this.edit(event.detail)
|
|
break
|
|
|
|
case 'NODE_DELETE':
|
|
if (!confirm('Are you sure you want to delete this node?'))
|
|
return
|
|
this.nodeDelete(this.currentNode.ID)
|
|
break
|
|
|
|
case 'NODE_HOOKED':
|
|
this.edit(event.detail)
|
|
break
|
|
|
|
case 'NODE_MOVE':
|
|
const nodes = this.tree.markedNodes()
|
|
if (!confirm(`Are you sure you want to move ${nodes.length} nodes here?`))
|
|
return
|
|
this.nodesMove(nodes, this.currentNode.ID)
|
|
break
|
|
|
|
case 'EDITOR_NODE_SAVE':
|
|
this.nodeUpdate()
|
|
break
|
|
|
|
case 'EXECUTIONS_LIST_FETCHED':
|
|
const executions = document.getElementById('script-executions')
|
|
executions.replaceChildren(this.scriptExecutionList.render())
|
|
break
|
|
|
|
case 'TYPES_LIST_FETCHED':
|
|
const types = document.getElementById('types')
|
|
types.replaceChildren(this.typesList.render())
|
|
break
|
|
|
|
case 'SCRIPTS_LIST_FETCHED':
|
|
const scripts = document.getElementById('scripts')
|
|
scripts.replaceChildren(this.scriptsList.render())
|
|
|
|
if (this.createdScript !== null) {
|
|
mbus.dispatch('SCRIPT_EDIT', this.createdScript)
|
|
this.createdScript = null
|
|
}
|
|
|
|
break
|
|
|
|
case 'SCRIPT_CREATED':
|
|
this.createdScript = event.detail
|
|
this.scriptsList.fetchScripts()
|
|
.catch(err => showError(err))
|
|
break
|
|
|
|
case 'SCRIPT_DELETED':
|
|
this.scriptsList.setSelected(null)
|
|
this.scriptsList.fetchScripts().catch(err => showError(err))
|
|
break
|
|
|
|
case 'SCRIPT_EDIT':
|
|
const scriptEditor = document.getElementById('editor-script')
|
|
this.scriptEditor.script = event.detail
|
|
|
|
this.scriptsList.setSelected(event.detail.ID)
|
|
|
|
scriptEditor.replaceChildren(this.scriptEditor.render())
|
|
break
|
|
|
|
case 'SCRIPT_UPDATED':
|
|
this.scriptsList.fetchScripts()
|
|
.catch(err => showError(err))
|
|
break
|
|
|
|
case 'NODE_CREATE_DIALOG':
|
|
if (this.currentPage !== 'node' || this.currentNode === null)
|
|
return
|
|
|
|
new NodeCreateDialog(this.currentNode.ID)
|
|
break
|
|
|
|
case 'NODE_EDIT_NAME':
|
|
const newName = prompt('Rename node', this.currentNode.Name)
|
|
if (newName === null)
|
|
return
|
|
|
|
this.nodeRename(this.currentNode.ID, newName)
|
|
.then(() => mbus.dispatch('TREE_RELOAD_NODE', { parentNodeID: this.currentNode.ParentID }))
|
|
break
|
|
|
|
case 'TREE_RELOAD_NODE':
|
|
this.tree.updateNode(parseInt(event.detail.parentNodeID))
|
|
.then(() => {
|
|
if (event.detail.callback)
|
|
event.detail.callback()
|
|
})
|
|
.catch(err => showError(err))
|
|
break
|
|
|
|
case 'TYPE_EDIT':
|
|
const typeID = event.detail
|
|
fetch(`/types/${typeID}`)
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
|
|
const editor = new TypeSchemaEditor(json.Type)
|
|
document.getElementById('editor-type-schema').replaceChildren(editor.render())
|
|
})
|
|
.catch(err => showError(err))
|
|
break
|
|
|
|
default:
|
|
alert(`Unhandled event: ${event.type}`)
|
|
console.log(event)
|
|
}
|
|
}// }}}
|
|
keyHandler(event) {// {{{
|
|
if (!event.shiftKey || !event.altKey)
|
|
return
|
|
|
|
let handled = true
|
|
switch (event.key.toUpperCase()) {
|
|
case 'D':
|
|
mbus.dispatch('NODE_DELETE')
|
|
break
|
|
|
|
case 'M':
|
|
mbus.dispatch('NODE_MOVE')
|
|
break
|
|
|
|
case 'N':
|
|
mbus.dispatch('NODE_CREATE_DIALOG')
|
|
break
|
|
|
|
default:
|
|
handled = false
|
|
}
|
|
|
|
if (handled) {
|
|
event.stopPropagation()
|
|
event.preventDefault()
|
|
}
|
|
}// }}}
|
|
page(item, name) {// {{{
|
|
for (const el of document.querySelectorAll('#menu .item'))
|
|
el.classList.remove('selected')
|
|
item.classList.add('selected')
|
|
|
|
for (const el of document.querySelectorAll('.page.show'))
|
|
el.classList.remove('show')
|
|
|
|
this.currentPage = name
|
|
|
|
// This one is special and needs to be hidden separately since the HTML elements are built differently.
|
|
document.getElementById('editor-node').style.display = 'none'
|
|
|
|
switch (name) {
|
|
case 'node':
|
|
document.getElementById('nodes').classList.add('show')
|
|
document.getElementById('editor-node').classList.add('show')
|
|
break
|
|
|
|
case 'type':
|
|
document.getElementById('types').classList.add('show')
|
|
document.getElementById('editor-type-schema').classList.add('show')
|
|
|
|
if (this.typesList === null)
|
|
this.typesList = new TypesList()
|
|
this.typesList.fetchTypes().then(() => {
|
|
mbus.dispatch('TYPES_LIST_FETCHED')
|
|
})
|
|
break
|
|
|
|
case 'script':
|
|
document.getElementById('scripts').classList.add('show')
|
|
document.getElementById('editor-script').classList.add('show')
|
|
|
|
if (this.scriptsList === null) {
|
|
this.scriptsList = new ScriptsList()
|
|
this.scriptEditor = new ScriptEditor()
|
|
}
|
|
|
|
this.scriptsList.fetchScripts()
|
|
.catch(err => showError(err))
|
|
break
|
|
|
|
case 'script-execution':
|
|
document.getElementById('script-executions').classList.add('show')
|
|
|
|
if (this.scriptExecutionList === null)
|
|
this.scriptExecutionList = new ScriptExecutionList()
|
|
this.scriptExecutionList.fetchExecutions()
|
|
.then(() => {
|
|
mbus.dispatch('EXECUTIONS_LIST_FETCHED')
|
|
})
|
|
.catch(err => showError(err))
|
|
break
|
|
}
|
|
}// }}}
|
|
|
|
edit(nodeID) {// {{{
|
|
if (nodeID === null) {
|
|
document.getElementById('editor-node').style.display = 'none'
|
|
this.currentNode = null
|
|
return
|
|
}
|
|
|
|
fetch(`/nodes/${nodeID}`)
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
|
|
this.currentNode = json.Node
|
|
|
|
// The JSON editor is created each time. Could probably be reused.
|
|
const editorEl = document.querySelector('#editor-node .editor')
|
|
this.editor = new Editor(json.Node.TypeSchema)
|
|
|
|
if (json.Node.Data['x-new'])
|
|
editorEl.replaceChildren(this.editor.render(null))
|
|
else
|
|
editorEl.replaceChildren(this.editor.render(json.Node.Data))
|
|
|
|
// Name is separate from the JSON node.
|
|
const name = document.getElementById('editor-node-name')
|
|
name.innerText = json.Node.Name
|
|
|
|
// The editor-node div is hidden from the start as a lot of the elements
|
|
// doesn't make any sense before a node is selected.
|
|
document.getElementById('editor-node').style.display = 'grid'
|
|
|
|
const connectedNodes = new ConnectedNodes(json.Node.ConnectedNodes)
|
|
document.getElementById('connected-nodes').replaceChildren(connectedNodes.render())
|
|
|
|
const scriptHooks = new ScriptHooks(json.Node.ScriptHooks)
|
|
document.getElementById('script-hooks').replaceChildren(scriptHooks.render())
|
|
})
|
|
.catch(err => showError(err))
|
|
}// }}}
|
|
async nodeRename(nodeID, name) {// {{{
|
|
return new Promise((resolve, reject) => {
|
|
name = name.trim()
|
|
if (name.length === 0) {
|
|
alert('A name must be provided.')
|
|
return
|
|
}
|
|
|
|
fetch(`/nodes/rename/${nodeID}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
Name: name,
|
|
}),
|
|
})
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
|
|
this.edit(nodeID)
|
|
resolve()
|
|
})
|
|
.catch(err => reject(err))
|
|
})
|
|
}// }}}
|
|
nodeUpdate() {// {{{
|
|
if (this.editor === null)
|
|
return
|
|
|
|
const btn = document.querySelector('#editor-node .controls button')
|
|
btn.disabled = true
|
|
const buttonPressed = Date.now()
|
|
|
|
const nodeData = this.editor.data()
|
|
|
|
fetch(`/nodes/update/${this.currentNode.ID}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(nodeData),
|
|
})
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
|
|
const timePassed = Date.now() - buttonPressed
|
|
if (timePassed < 250)
|
|
setTimeout(() => btn.disabled = false, 250 - timePassed)
|
|
else
|
|
btn.disabled = false
|
|
})
|
|
}// }}}
|
|
nodeDelete(nodeID) {// {{{
|
|
const node = this.tree.treeNodes.get(nodeID)
|
|
const parentID = node.node.ParentID
|
|
|
|
fetch(`/nodes/delete/${nodeID}`)
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
|
|
this.tree.updateNode(parseInt(parentID))
|
|
mbus.dispatch('NODE_SELECTED', null)
|
|
})
|
|
.catch(err => showError(err))
|
|
}// }}}
|
|
nodesMove(nodes, newParentID) {// {{{
|
|
const req = {
|
|
NewParentID: parseInt(newParentID),
|
|
NodeIDs: nodes.map(n => n.ID),
|
|
}
|
|
fetch(`/nodes/move`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(req),
|
|
})
|
|
.then(data => data.json())
|
|
.then(async json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
|
|
const updateParents = new Map()
|
|
for (const n of nodes)
|
|
updateParents.set(n.ParentID, true)
|
|
updateParents.set(newParentID, true)
|
|
|
|
for (const nodeID of updateParents.keys())
|
|
this.tree.updateNode(nodeID)
|
|
})
|
|
.catch(err => showError(err))
|
|
}// }}}
|
|
async nodeConnect(parentNode, nodeToConnect) {// {{{
|
|
return new Promise((resolve, reject) => {
|
|
const req = {
|
|
ParentNodeID: parentNode.ID,
|
|
ChildNodeID: nodeToConnect.ID,
|
|
}
|
|
|
|
fetch(`/nodes/connect`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(req),
|
|
})
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
resolve()
|
|
})
|
|
.catch(err => reject(err))
|
|
})
|
|
}// }}}
|
|
typeSort(a, b) {// {{{
|
|
if (a.Schema['x-group'] === undefined)
|
|
a.Schema['x-group'] = 'No group'
|
|
|
|
if (b.Schema['x-group'] === undefined)
|
|
b.Schema['x-group'] = 'No group'
|
|
|
|
if (a.Schema['x-group'] < b.Schema['x-group']) return -1
|
|
if (a.Schema['x-group'] > b.Schema['x-group']) return 1
|
|
|
|
if ((a.Schema.title || a.Name) < (b.Schema.title || b.Name)) return -1
|
|
if ((a.Schema.title || a.Name) > (b.Schema.title || b.Name)) return 1
|
|
|
|
return 0
|
|
}// }}}
|
|
async query(path, data) {// {{{
|
|
return new Promise((resolve, reject) => {
|
|
let request = {}
|
|
|
|
if (data !== undefined) {
|
|
request.method = 'POST'
|
|
request.body = JSON.stringify(data)
|
|
}
|
|
|
|
fetch(path, request)
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
reject(json.Error)
|
|
return
|
|
}
|
|
resolve(json)
|
|
})
|
|
.catch(err => {
|
|
reject(err)
|
|
})
|
|
})
|
|
}// }}}
|
|
}
|
|
|
|
class NodeCreateDialog {
|
|
constructor(parentNodeID) {// {{{
|
|
this.parentNodeID = parentNodeID
|
|
this.dialog = null
|
|
this.types = null
|
|
this.select = null
|
|
this.input = null
|
|
|
|
this.createElements()
|
|
|
|
this.dialog.showModal()
|
|
//this.select.focus()
|
|
}// }}}
|
|
createElements() {// {{{
|
|
this.dialog = document.createElement('dialog')
|
|
this.dialog.id = 'create-type'
|
|
this.dialog.addEventListener('close', () => this.dialog.remove())
|
|
this.dialog.innerHTML = `
|
|
<div style="padding: 16px">
|
|
<select></select>
|
|
<input type="text" placeholder="Name">
|
|
<button>Create</button>
|
|
</div>
|
|
`
|
|
|
|
new SelectType().render()
|
|
.then(select => {
|
|
this.select = select
|
|
this.dialog.querySelector('select').replaceWith(this.select)
|
|
this.select.focus()
|
|
})
|
|
|
|
this.dialog.querySelector('button').addEventListener('click', () => this.commit())
|
|
this.select = this.dialog.querySelector('select')
|
|
this.input = this.dialog.querySelector('input')
|
|
this.input.addEventListener('keydown', event => {
|
|
if (event.key === 'Enter')
|
|
this.commit()
|
|
})
|
|
|
|
document.body.appendChild(this.dialog)
|
|
}// }}}
|
|
commit() {// {{{
|
|
if (this.input.value.trim().length === 0) {
|
|
alert('Give a name.')
|
|
return
|
|
}
|
|
|
|
const req = {
|
|
ParentNodeID: this.parentNodeID,
|
|
TypeID: parseInt(this.select.value),
|
|
Name: this.input.value.trim(),
|
|
}
|
|
|
|
fetch('/nodes/create', {
|
|
method: 'POST',
|
|
body: JSON.stringify(req),
|
|
})
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
mbus.dispatch('TREE_RELOAD_NODE', {
|
|
parentNodeID: this.parentNodeID,
|
|
callback: () => mbus.dispatch('NODE_SELECTED', json.NodeID)
|
|
})
|
|
this.dialog.close()
|
|
})
|
|
.catch(err => showError(err))
|
|
}// }}}
|
|
}
|
|
|
|
export class Tree {
|
|
constructor() {// {{{
|
|
this.treeNodes = new Map()
|
|
|
|
const events = [
|
|
'NODE_EXPAND',
|
|
]
|
|
for (const e of events)
|
|
mbus.subscribe(e, event => this.eventHandler(event))
|
|
|
|
|
|
// click on the empty tree list to unmark all nodes.
|
|
const nodesEl = document.getElementById('nodes')
|
|
nodesEl.addEventListener('click', event => {
|
|
// To prevent accidentally removing all node marks,
|
|
// shift is required to be unpressed, since it is required to
|
|
// be pressed when marking nodes.
|
|
if (event.shiftKey)
|
|
return
|
|
|
|
const markedElements = document.querySelectorAll('#nodes .node.marked')
|
|
for (const e of markedElements)
|
|
e.classList.remove('marked')
|
|
})
|
|
|
|
// Fetch the top node to start
|
|
this.fetchNodes(0)
|
|
.then(node => {
|
|
const top = document.getElementById('nodes')
|
|
const topNode = this.treeNodes.get(0)
|
|
topNode.expanded = true
|
|
top.appendChild(topNode.render())
|
|
//this.updateNode(0)
|
|
})
|
|
.catch(err => showError(err))
|
|
}// }}}
|
|
eventHandler(event) {// {{{
|
|
switch (event.type) {
|
|
case 'NODE_EXPAND':
|
|
this.updateNode(event.detail.node.ID)
|
|
break
|
|
|
|
default:
|
|
alert(`Unhandled event: ${event.type}`)
|
|
console.log(event)
|
|
}
|
|
}// }}}
|
|
async fetchNodes(topNode) {// {{{
|
|
return new Promise((resolve, reject) => {
|
|
fetch(`/nodes/tree/${topNode}?depth=1`)
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
reject(json.Error)
|
|
return
|
|
}
|
|
|
|
// Make sure treenodes are updated with the latest fetched data.
|
|
// The parent node is processed after the children, since the render function needs to have the children already available.
|
|
const nodes = []
|
|
nodes.push(...json.Nodes.Children)
|
|
nodes.push(json.Nodes)
|
|
|
|
for (const n of nodes) {
|
|
let treenode = this.treeNodes.get(n.ID)
|
|
if (treenode === undefined) {
|
|
treenode = new TreeNode(this, n)
|
|
treenode.render()
|
|
this.treeNodes.set(n.ID, treenode)
|
|
} else {
|
|
// Since the depth is set to 1, the childrens' children array will be empty.
|
|
// If children have been fetched, these should be kept.
|
|
if (n.NumChildren > 0 && n.Children.length == 0)
|
|
n.Children = treenode.node.Children
|
|
treenode.node = n
|
|
}
|
|
}
|
|
|
|
resolve(json.Nodes)
|
|
})
|
|
})
|
|
}// }}}
|
|
updateNode(nodeID) {// {{{
|
|
return new Promise((resolve, reject) => {
|
|
// updateNode retrieves a node and its' immediate children.
|
|
// Node and each child is found in the treeNodes map and the names are updated.
|
|
// If not found, created and added.
|
|
//
|
|
// Newly created nodes are found and added, existing but renamed nodes are modified, and unchanged are left as is.
|
|
this.fetchNodes(nodeID, true)
|
|
.then(node => {
|
|
const thisTreeNode = this.treeNodes.get(nodeID)
|
|
thisTreeNode.render()
|
|
|
|
for (const n of thisTreeNode.node.Children)
|
|
this.treeNodes.get(n.ID)?.render()
|
|
|
|
resolve()
|
|
|
|
})
|
|
.catch(err => { showError(err); reject(err) })
|
|
})
|
|
}// }}}
|
|
sortChildren(children) {// {{{
|
|
children.sort((a, b) => {
|
|
if (a.TypeName < b.TypeName) return -1
|
|
if (a.TypeName > b.TypeName) return 1
|
|
|
|
if (a.Name < b.Name) return -1
|
|
if (a.Name > b.Name) return 1
|
|
|
|
return 0
|
|
})
|
|
}// }}}
|
|
markedNodes() {// {{{
|
|
const markedElements = document.querySelectorAll('#nodes .node.marked')
|
|
const marked = []
|
|
for (const n of markedElements) {
|
|
const nodeID = n.getAttribute('data-node-id')
|
|
marked.push(this.treeNodes.get(parseInt(nodeID)).node)
|
|
}
|
|
return marked
|
|
}// }}}
|
|
}
|
|
|
|
export class TreeNode {
|
|
constructor(tree, data) {// {{{
|
|
this.tree = tree
|
|
this.node = data
|
|
this.childrenFetched = false
|
|
this.element = null
|
|
this.children = null
|
|
this.nameElement = null
|
|
this.expandEventListenerAdded = false
|
|
this.expanded = this.retrieveExpanded()
|
|
|
|
if (this.expanded)
|
|
this.tree.fetchNodes(this.node.ID)
|
|
.then(() => this.render())
|
|
}// }}}
|
|
render() {// {{{
|
|
if (this.element === null) {
|
|
this.element = document.createElement('div')
|
|
this.element.classList.add('node')
|
|
this.element.setAttribute('data-node-id', this.node.ID)
|
|
|
|
const nodeHTML = `
|
|
<div class="expand-status"><img /></div>
|
|
<div class="type-icon"><img /></div>
|
|
<div class="name"></div>
|
|
<div class="children"></div>
|
|
`
|
|
this.element.innerHTML = nodeHTML
|
|
|
|
this.nameElement = this.element.querySelector('.name')
|
|
this.children = this.element.querySelector('.children')
|
|
this.expandImg = this.element.querySelector('.expand-status img')
|
|
this.expandStatus = this.element.querySelector('.expand-status img')
|
|
|
|
this.nameElement.addEventListener('click', event => this.clickNode(event))
|
|
}
|
|
|
|
// data.NumChildren is set regardless of having fetched the children or not.
|
|
this.updateExpandImages()
|
|
|
|
if (this.node.TypeIcon) {
|
|
const img = this.element.querySelector('.type-icon img')
|
|
img.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.node.TypeIcon}.svg`)
|
|
}
|
|
|
|
this.nameElement.innerText = this.name()
|
|
|
|
this.tree.sortChildren(this.node.Children)
|
|
const children = []
|
|
for (const c of this.node.Children)
|
|
children.push(this.tree.treeNodes.get(c.ID).element)
|
|
|
|
this.children.replaceChildren(...children)
|
|
|
|
return this.element
|
|
}// }}}
|
|
|
|
name() {// {{{
|
|
if (this.node.TypeName === 'root_node')
|
|
return 'Start'
|
|
return this.node.Name
|
|
}// }}}
|
|
clickNode(event) {// {{{
|
|
if (!event.shiftKey)
|
|
mbus.dispatch('NODE_SELECTED', this.node.ID)
|
|
else
|
|
this.element.classList.toggle('marked')
|
|
event.stopPropagation()
|
|
}// }}}
|
|
hasChildren() {// {{{
|
|
return this.node.NumChildren > 0
|
|
}// }}}
|
|
updateExpandImages() {// {{{
|
|
if (this.hasChildren()) {
|
|
if (this.expanded)
|
|
this.element.classList.add('expanded')
|
|
else
|
|
this.element.classList.remove('expanded')
|
|
|
|
this.expandStatus.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.expanded ? 'minus-box-outline.svg' : 'plus-box-outline.svg'}`)
|
|
|
|
if (!this.expandEventListenerAdded) {
|
|
this.expandStatus.addEventListener('click', () => this.toggleExpand())
|
|
this.expandEventListenerAdded = true
|
|
}
|
|
} else
|
|
this.expandStatus.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/circle-medium.svg`)
|
|
}// }}}
|
|
toggleExpand(expanded) {// {{{
|
|
if (expanded === undefined)
|
|
this.expanded = !this.expanded
|
|
else
|
|
this.expanded = expanded
|
|
|
|
this.storeExpanded()
|
|
this.updateExpandImages()
|
|
|
|
if (!this.childrenFetched && this.node.NumChildren > 0 && this.node.Children.length == 0) {
|
|
mbus.dispatch('NODE_EXPAND', this)
|
|
}
|
|
}// }}}
|
|
async fetchChildren() {// {{{
|
|
return new Promise((resolve, reject) => {
|
|
fetch(`/nodes/tree/${this.node.ID}?depth=1`)
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (json.OK)
|
|
resolve(json.Nodes)
|
|
else
|
|
reject(json.Error)
|
|
})
|
|
.catch(err => reject(err))
|
|
})
|
|
}// }}}
|
|
|
|
storeExpanded() {// {{{
|
|
sessionStorage.setItem(`tree_expand_${this.node.ID}`, this.expanded ? '1' : '0')
|
|
}// }}}
|
|
retrieveExpanded() {// {{{
|
|
return sessionStorage.getItem(`tree_expand_${this.node.ID}`) === '1'
|
|
}// }}}
|
|
}
|
|
|
|
class TypesList extends Component {
|
|
constructor() {// {{{
|
|
super()
|
|
this.types = []
|
|
}// }}}
|
|
async fetchTypes() {// {{{
|
|
return new Promise((resolve, reject) => {
|
|
fetch('/types/')
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
this.types = json.Types
|
|
resolve()
|
|
})
|
|
.catch(err => reject(err))
|
|
})
|
|
}// }}}
|
|
renderComponent() {// {{{
|
|
const div = document.createElement('div')
|
|
|
|
const label = document.createElement('div')
|
|
const inner = document.createElement('div')
|
|
label.classList.add('label')
|
|
inner.innerText = 'Types'
|
|
label.appendChild(inner)
|
|
|
|
const create = document.createElement('img')
|
|
create.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg`)
|
|
create.style.height = '32px'
|
|
create.style.cursor = 'pointer';
|
|
create.addEventListener('click', () => this.createType())
|
|
|
|
label.append(create)
|
|
div.appendChild(label)
|
|
|
|
this.types.sort(_app.typeSort)
|
|
|
|
let prevGroup = null
|
|
|
|
for (const t of this.types) {
|
|
if (t.Name == 'root_node')
|
|
continue
|
|
|
|
if (t.Schema['x-group'] != prevGroup) {
|
|
prevGroup = t.Schema['x-group']
|
|
const group = document.createElement('div')
|
|
group.classList.add('group')
|
|
group.innerText = t.Schema['x-group']
|
|
div.appendChild(group)
|
|
}
|
|
|
|
const tDiv = document.createElement('div')
|
|
tDiv.classList.add('type')
|
|
tDiv.innerHTML = `
|
|
<div class="img"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/${t.Schema.icon}.svg" /></div>
|
|
<div class="title">${t.Schema.title || t.Name}</div>
|
|
`
|
|
|
|
tDiv.addEventListener('click', () => mbus.dispatch('TYPE_EDIT', t.ID))
|
|
div.appendChild(tDiv)
|
|
}
|
|
|
|
return div
|
|
}// }}}
|
|
createType() {// {{{
|
|
const name = prompt("Type name")
|
|
if (name === null)
|
|
return
|
|
fetch(`/types/create`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name }),
|
|
})
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
|
|
_app.typesList.fetchTypes().then(() => {
|
|
mbus.dispatch('TYPES_LIST_FETCHED')
|
|
mbus.dispatch('TYPE_EDIT', json.Type.ID)
|
|
})
|
|
})
|
|
.catch(err => showError(err))
|
|
}// }}}
|
|
}
|
|
|
|
class TypeSchemaEditor extends Component {
|
|
constructor(dataType) {// {{{
|
|
super()
|
|
this.type = dataType
|
|
}// }}}
|
|
renderComponent() {// {{{
|
|
const tmpl = document.createElement('template')
|
|
tmpl.innerHTML = `
|
|
<div>
|
|
<div style="float: left;" class="label">${this.type.Schema.title}</div>
|
|
<div style="float: right;"><img class="save" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/content-save.svg" /></div>
|
|
</div>
|
|
<div style="clear: both;">
|
|
<input type="text" class="name">
|
|
</div>
|
|
<textarea class="type-schema"></textarea>
|
|
`
|
|
|
|
this.textarea = tmpl.content.querySelector('textarea')
|
|
this.textarea.value = JSON.stringify(this.type.Schema, null, 4)
|
|
this.textarea.addEventListener('keydown', event => {
|
|
if (!event.ctrlKey || event.key !== 's')
|
|
return
|
|
event.stopPropagation()
|
|
event.preventDefault()
|
|
this.save()
|
|
})
|
|
|
|
this.name = tmpl.content.querySelector('.name')
|
|
this.name.value = this.type.Name
|
|
|
|
this.img_save = tmpl.content.querySelector('img.save')
|
|
this.img_save.addEventListener('click', () => this.save())
|
|
|
|
return tmpl.content
|
|
}// }}}
|
|
save() {// {{{
|
|
const req = {
|
|
Name: this.name.value,
|
|
Schema: this.textarea.value,
|
|
}
|
|
|
|
const start_update = Date.now()
|
|
this.img_save.classList.add('saving')
|
|
|
|
fetch(`/types/update/${this.type.ID}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(req),
|
|
})
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
|
|
const time_left = 100 - (Date.now() - start_update)
|
|
setTimeout(() => {
|
|
this.img_save.classList.remove('saving')
|
|
this.refreshTypeUI()
|
|
}, Math.max(time_left, 0))
|
|
|
|
})
|
|
.catch(err => showError(err))
|
|
|
|
}// }}}
|
|
async refreshTypeUI() {// {{{
|
|
_app.typesList.fetchTypes().then(() => {
|
|
mbus.dispatch('TYPES_LIST_FETCHED')
|
|
mbus.dispatch('TYPE_EDIT', this.type.ID)
|
|
})
|
|
}// }}}
|
|
}
|
|
|
|
class ConnectedNodes {
|
|
constructor(nodes) {// {{{
|
|
this.nodes = nodes
|
|
}// }}}
|
|
render() {// {{{
|
|
const div = document.createElement('template')
|
|
div.innerHTML = `
|
|
<div class="label">
|
|
<div style="white-space: nowrap">Connected nodes</div>
|
|
<img class="add" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg" />
|
|
</div>
|
|
<div class="connected-nodes"></div>
|
|
`
|
|
|
|
div.content.querySelector('.add').addEventListener('click', () => mbus.dispatch('NODE_CONNECT'))
|
|
|
|
const types = new Map()
|
|
for (const n of this.nodes) {
|
|
let typeGroup = types.get(n.TypeSchema.title)
|
|
if (typeGroup === undefined) {
|
|
typeGroup = document.createElement('div')
|
|
typeGroup.classList.add('type-group')
|
|
typeGroup.innerHTML = `<div class="type-name">${n.TypeSchema.title}</div>`
|
|
types.set(n.TypeSchema.title, typeGroup)
|
|
}
|
|
|
|
typeGroup.appendChild(
|
|
new ConnectedNode(n).render()
|
|
)
|
|
}
|
|
|
|
const connectedNodes = div.content.querySelector('.connected-nodes')
|
|
for (const t of Array.from(types.keys()).sort()) {
|
|
connectedNodes.append(types.get(t))
|
|
}
|
|
|
|
return div.content
|
|
}// }}}
|
|
}
|
|
|
|
class ConnectedNode {
|
|
constructor(node) {// {{{
|
|
this.node = node
|
|
}// }}}
|
|
render() {// {{{
|
|
const tmpl = document.createElement('template')
|
|
tmpl.innerHTML = `
|
|
<div class="type-icon"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.node.TypeIcon}.svg" /></div>
|
|
<div class="node-name">${this.node.Name}</div>
|
|
`
|
|
|
|
for (const el of tmpl.content.children) {
|
|
el.addEventListener('click', () => {
|
|
new ConnectionDataDialog(this.node, () => _app.edit(_app.currentNode.ID)).render()
|
|
})
|
|
}
|
|
return tmpl.content
|
|
}// }}}
|
|
}
|
|
|
|
class ScriptHooks extends Component {
|
|
constructor(hooks) {// {{{
|
|
super()
|
|
this.hooks = hooks
|
|
this.scriptGrid = null
|
|
}// }}}
|
|
renderComponent() {// {{{
|
|
const div = document.createElement('div')
|
|
div.innerHTML = `
|
|
<div class="label">
|
|
<div style="white-space: nowrap">Script hooks</div>
|
|
<img class="add" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg" />
|
|
</div>
|
|
<div class="scripts-grid">
|
|
<div class="header" style="grid-column: 1 / 3;">Script</div>
|
|
<div class="header" style="grid-column: 3 / 5;">SSH</div>
|
|
</div>
|
|
`
|
|
|
|
div.querySelector('.add').addEventListener('click', () => {
|
|
const dlg = new ScriptSelectDialog(s => {
|
|
this.hookScript(s)
|
|
})
|
|
dlg.render()
|
|
})
|
|
|
|
this.scriptGrid = div.querySelector('.scripts-grid')
|
|
this.renderHooks()
|
|
|
|
return div.children
|
|
}// }}}
|
|
hookDeleted(deletedHookID) {// {{{
|
|
this.hooks = this.hooks.filter(h => h.ID !== deletedHookID)
|
|
this.renderHooks()
|
|
}// }}}
|
|
renderHooks() {// {{{
|
|
this.scriptGrid.innerHTML = ''
|
|
|
|
let curGroupName = null
|
|
let group = document.createElement('div')
|
|
group.classList.add('script-group')
|
|
|
|
for (const hook of this.hooks) {
|
|
if (hook.Script.Group !== curGroupName) {
|
|
const g = document.createElement('div')
|
|
g.classList.add('script-group-title')
|
|
g.innerText = hook.Script.Group
|
|
|
|
group = document.createElement('div')
|
|
group.classList.add('script-group')
|
|
group.append(g)
|
|
this.scriptGrid.append(group)
|
|
curGroupName = hook.Script.Group
|
|
}
|
|
|
|
const h = new ScriptHook(hook, this)
|
|
group.append(h.render())
|
|
}
|
|
|
|
if (group.children.length > 1)
|
|
this.scriptGrid.append(group)
|
|
}// }}}
|
|
hookScript(script) {// {{{
|
|
_app.query(`/nodes/hook`, {
|
|
NodeID: _app.currentNode.ID,
|
|
ScriptID: script.ID,
|
|
})
|
|
.then(() => mbus.dispatch('NODE_HOOKED', _app.currentNode.ID))
|
|
.catch(err => showError(err))
|
|
}// }}}
|
|
}
|
|
|
|
class ScriptHook extends Component {
|
|
constructor(hook, parentList) {// {{{
|
|
super()
|
|
this.hook = hook
|
|
this.parentList = parentList
|
|
this.elementSSH = null
|
|
this.elementSchedule = null
|
|
this.scheduleDisable = false
|
|
}// }}}
|
|
renderComponent() {// {{{
|
|
const tmpl = document.createElement('template')
|
|
tmpl.innerHTML = `
|
|
<div class="script-name">${this.hook.Script.Name}</div>
|
|
<div class="script-ssh"></div>
|
|
<div class="script-schedule"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/play-box.svg" /></div>
|
|
`
|
|
|
|
|
|
this.elementSchedule = tmpl.content.querySelector('.script-schedule')
|
|
this.elementSSH = tmpl.content.querySelector('.script-ssh')
|
|
this.elementSSH.innerText = `[ ${this.hook.SSH} ]`
|
|
|
|
tmpl.content.querySelector('.script-name').addEventListener('click', () => this.update())
|
|
tmpl.content.querySelector('.script-ssh').addEventListener('click', () => this.update())
|
|
tmpl.content.querySelector('.script-schedule').addEventListener('click', () => this.run())
|
|
|
|
return tmpl.content
|
|
}// }}}
|
|
update() {// {{{
|
|
new ScriptHookDialog(this.hook, this.parentList, () => {
|
|
this.elementSSH.innerText = `[ ${this.hook.SSH} ]`
|
|
}).render()
|
|
}// }}}
|
|
run() {// {{{
|
|
if (this.scheduleDisable)
|
|
return
|
|
|
|
this.scheduleDisable = true
|
|
this.elementSchedule.classList.add('disabled')
|
|
const start = Date.now()
|
|
|
|
_app.query(`/hooks/schedule/${this.hook.ID}`)
|
|
.catch(err => showError(err))
|
|
.finally(() => {
|
|
const duration = Date.now() - start
|
|
setTimeout(() => {
|
|
this.scheduleDisable = false
|
|
this.elementSchedule.classList.remove('disabled')
|
|
}, 250 - duration)
|
|
})
|
|
}// }}}
|
|
}
|
|
|
|
class ScriptsList extends Component {
|
|
constructor() {// {{{
|
|
super()
|
|
this.scripts = []
|
|
this.selectedID = 0
|
|
}// }}}
|
|
|
|
async fetchScripts() {// {{{
|
|
return new Promise((resolve, reject) => {
|
|
fetch('/scripts/')
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
reject(json.Error)
|
|
return
|
|
}
|
|
this.scripts = json.Scripts
|
|
mbus.dispatch('SCRIPTS_LIST_FETCHED')
|
|
resolve(this.scripts)
|
|
})
|
|
})
|
|
}// }}}
|
|
renderComponent() {// {{{
|
|
const label = document.createElement('div')
|
|
const inner = document.createElement('div')
|
|
label.classList.add('label')
|
|
inner.innerText = 'Scripts'
|
|
label.appendChild(inner)
|
|
|
|
let prevGroup = null
|
|
const elements = []
|
|
const imgAdd = document.createElement('img')
|
|
imgAdd.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg`)
|
|
imgAdd.style.height = '32px'
|
|
imgAdd.style.cursor = 'pointer'
|
|
imgAdd.addEventListener('click', () => this.createScript())
|
|
|
|
label.append(imgAdd)
|
|
elements.push(label)
|
|
|
|
for (const s of this.scripts) {
|
|
if (prevGroup != s.Group) {
|
|
const gEl = document.createElement('div')
|
|
gEl.classList.add('group')
|
|
gEl.innerText = s.Group
|
|
elements.push(gEl)
|
|
prevGroup = s.Group
|
|
}
|
|
|
|
const sEl = document.createElement('div')
|
|
sEl.classList.add('script')
|
|
if (s.ID === this.selectedID)
|
|
sEl.classList.add('selected')
|
|
sEl.setAttribute('data-script-id', s.ID)
|
|
sEl.innerHTML = `
|
|
<div><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/bash.svg"></div>
|
|
<div>${s.Name}</div>
|
|
`
|
|
|
|
for (const el of sEl.children)
|
|
el.addEventListener('click', () => mbus.dispatch('SCRIPT_EDIT', s))
|
|
|
|
elements.push(sEl)
|
|
|
|
}
|
|
|
|
return elements
|
|
}// }}}
|
|
setSelected(scriptID) {// {{{
|
|
this.selectedID = scriptID
|
|
|
|
const scripts = document.getElementById('scripts')
|
|
for (const el of scripts.querySelectorAll('.selected'))
|
|
el.classList.remove('selected')
|
|
const script = scripts.querySelector(`[data-script-id="${this.selectedID}"]`)
|
|
script?.classList.add('selected')
|
|
}// }}}
|
|
createScript() {// {{{
|
|
const name = prompt('Script name')
|
|
if (name === null)
|
|
return
|
|
|
|
if (name.trim() === '') {
|
|
alert("Name can't be empty.")
|
|
return
|
|
}
|
|
|
|
const script = {
|
|
Group: 'Uncategorized',
|
|
Name: name,
|
|
Source: "#!/bin/bash\n",
|
|
}
|
|
fetch(`/scripts/update/0`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(script),
|
|
})
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
|
|
mbus.dispatch('SCRIPT_CREATED', json.Script)
|
|
})
|
|
.catch(err => showError(err))
|
|
}// }}}
|
|
}
|
|
|
|
class ScriptEditor extends Component {
|
|
constructor() {// {{{
|
|
super()
|
|
this.elements = this.createElements()
|
|
this.script = {
|
|
Group: '',
|
|
Name: '',
|
|
Source: '',
|
|
}
|
|
}// }}}
|
|
createElements() {// {{{
|
|
const div = document.createElement('div')
|
|
div.innerHTML = `
|
|
<div>
|
|
<div class="label">Group</div>
|
|
<div><input class="group" type="text"></div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="label">Name</div>
|
|
<div><input class="name" type="text"></div>
|
|
</div>
|
|
|
|
<div>
|
|
<img class="delete" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/trash-can.svg" style="height: 32px; cursor: pointer">
|
|
</div>
|
|
|
|
<div style="grid-column: 1 / -1">
|
|
<div class="label">Source</div>
|
|
<div><textarea class="source"></textarea></div>
|
|
|
|
<button>Update</button>
|
|
</div>
|
|
`
|
|
|
|
this.groupElement = div.querySelector('.group')
|
|
this.nameElement = div.querySelector('.name')
|
|
this.sourceElement = div.querySelector('.source')
|
|
|
|
this.button = div.querySelector('button')
|
|
this.button.addEventListener('click', () => this.updateScript())
|
|
|
|
div.querySelector('.delete').addEventListener('click', () => this.deleteScript())
|
|
|
|
div.addEventListener('keydown', event => this.keyHandler(event))
|
|
|
|
return div
|
|
}// }}}
|
|
keyHandler(event) {// {{{
|
|
if (event.key !== 's' || !event.ctrlKey)
|
|
return
|
|
event.stopPropagation()
|
|
event.preventDefault()
|
|
|
|
this.updateScript()
|
|
}// }}}
|
|
renderComponent() {// {{{
|
|
this.groupElement.value = this.script.Group
|
|
this.nameElement.value = this.script.Name
|
|
this.sourceElement.value = this.script.Source
|
|
|
|
return this.elements
|
|
}// }}}
|
|
updateScript() {// {{{
|
|
this.button.disabled = true
|
|
const start = Date.now()
|
|
|
|
const script = {
|
|
Group: this.groupElement.value,
|
|
Name: this.nameElement.value,
|
|
Source: this.sourceElement.value,
|
|
}
|
|
|
|
fetch(`/scripts/update/${this.script.ID}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(script),
|
|
})
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
mbus.dispatch('SCRIPT_UPDATED')
|
|
})
|
|
.finally(() => {
|
|
const timePassed = Date.now() - start
|
|
setTimeout(() => this.button.disabled = false, 250 - timePassed)
|
|
})
|
|
|
|
}// }}}
|
|
deleteScript() {// {{{
|
|
if (!confirm('Delete script?'))
|
|
return
|
|
|
|
fetch(`/scripts/delete/${this.script.ID}`)
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
this.script.Name = ''
|
|
this.script.Group = ''
|
|
this.script.Source = ''
|
|
this.render()
|
|
mbus.dispatch('SCRIPT_DELETED', this.script.ID)
|
|
})
|
|
.catch(err => showError(err))
|
|
}// }}}
|
|
}
|
|
|
|
class ScriptSelectDialog extends Component {
|
|
constructor(callback) {// {{{
|
|
super()
|
|
this.dlg = document.createElement('dialog')
|
|
this.dlg.id = 'script-select-dialog'
|
|
this.dlg.addEventListener('close', () => this.dlg.remove())
|
|
this.searchFor = null
|
|
this.scripts = null
|
|
this.callback = callback
|
|
}// }}}
|
|
renderComponent() {// {{{
|
|
const div = document.createElement('div')
|
|
div.innerHTML = `
|
|
<div class="header">Search for script</div>
|
|
<div><input class="search-for" type="text" value="%"></div>
|
|
<div><button>Search</button></div>
|
|
<div class="scripts"></div>
|
|
`
|
|
|
|
this.searchFor = div.querySelector('.search-for')
|
|
this.scripts = div.querySelector('.scripts')
|
|
const button = div.querySelector('button')
|
|
this.searchFor.addEventListener('keydown', event => {
|
|
if (event.key == 'Enter')
|
|
this.searchScripts()
|
|
})
|
|
button.addEventListener('click', () => this.searchScripts())
|
|
|
|
this.dlg.append(...div.children)
|
|
document.body.append(this.dlg)
|
|
this.dlg.showModal()
|
|
|
|
return []
|
|
}// }}}
|
|
searchScripts() {// {{{
|
|
fetch('/scripts/search', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
Search: this.searchFor.value,
|
|
}),
|
|
})
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
showError(json.Error)
|
|
return
|
|
}
|
|
this.populateScripts(json.Scripts)
|
|
})
|
|
.catch(err => showError(err))
|
|
}// }}}
|
|
populateScripts(scripts) {// {{{
|
|
this.scripts.innerHTML = ''
|
|
|
|
let prevGroup = null
|
|
for (const s of scripts) {
|
|
if (s.Group !== prevGroup) {
|
|
const group = document.createElement('div')
|
|
group.classList.add('group')
|
|
group.innerText = s.Group
|
|
this.scripts.append(group)
|
|
prevGroup = s.Group
|
|
}
|
|
|
|
const div = document.createElement('div')
|
|
div.innerText = s.Name
|
|
div.classList.add('script')
|
|
div.addEventListener('click', () => {
|
|
this.dlg.close()
|
|
this.callback(s)
|
|
})
|
|
this.scripts.append(div)
|
|
}
|
|
}// }}}
|
|
}
|
|
|
|
class ScriptHookDialog extends Component {
|
|
constructor(hook, parentList, callback) {// {{{
|
|
super()
|
|
this.hook = hook
|
|
this.callback = callback
|
|
this.parentList = parentList
|
|
|
|
this.dlg = document.createElement('dialog')
|
|
this.dlg.id = 'script-hook-dialog'
|
|
this.dlg.addEventListener('close', () => this.dlg.remove())
|
|
|
|
this.env = null
|
|
this.ssh = null
|
|
this.schedule_on_child_update = null
|
|
}// }}}
|
|
renderComponent() {// {{{
|
|
const div = document.createElement('div')
|
|
div.innerHTML = `
|
|
<div class="top">
|
|
<div class="header"></div>
|
|
<img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/trash-can.svg">
|
|
</div>
|
|
|
|
<div class="label">SSH</div>
|
|
<div><input type="text" class="ssh" style="width: 100%" /></div>
|
|
|
|
<div class="label">Schedule automatically</div>
|
|
<div>
|
|
<input type="checkbox" class="schedule-on-child" id="schedule-on-child" />
|
|
<label for="schedule-on-child">on child update</label>
|
|
</div>
|
|
|
|
<div class="label">
|
|
Environment
|
|
</div>
|
|
<div style="font-size: 0.9em">A map with keys and values as strings.</div>
|
|
|
|
<div><textarea class="env"></textarea></div>
|
|
<div><button>Update</button></div>
|
|
`
|
|
|
|
div.querySelector('.header').innerText = `Hook for ${this.hook.Script.Name}`
|
|
div.querySelector('.top img').addEventListener('click', () => this.delete())
|
|
|
|
this.ssh = div.querySelector('.ssh')
|
|
this.ssh.value = this.hook.SSH
|
|
this.ssh.addEventListener('keydown', event => {
|
|
if (event.key == 'Enter') {
|
|
event.stopPropagation()
|
|
this.save()
|
|
}
|
|
})
|
|
|
|
this.env = div.querySelector('.env')
|
|
this.env.value = JSON.stringify(this.hook.Env, null, " ")
|
|
|
|
this.schedule_on_child_update = div.querySelector('.schedule-on-child')
|
|
this.schedule_on_child_update.checked = this.hook.ScheduleOnChildUpdate
|
|
|
|
const button = div.querySelector('button')
|
|
this.env.addEventListener('keydown', event => {
|
|
if (event.ctrlKey && event.key == 's') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
this.save()
|
|
}
|
|
})
|
|
button.addEventListener('click', () => this.save())
|
|
|
|
this.dlg.append(...div.children)
|
|
document.body.append(this.dlg)
|
|
this.dlg.showModal()
|
|
|
|
return []
|
|
}// }}}
|
|
save() {// {{{
|
|
if (this.ssh.value.trim() === '') {
|
|
alert('SSH has to be filled in.')
|
|
return
|
|
}
|
|
|
|
if (this.env.value.trim() === '')
|
|
this.env.value = '{}'
|
|
|
|
try {
|
|
this.hook.Env = JSON.parse(this.env.value)
|
|
this.hook.SSH = this.ssh.value.trim()
|
|
this.hook.ScheduleOnChildUpdate = this.schedule_on_child_update.checked
|
|
window._app.query('/hooks/update', this.hook)
|
|
.then(() => {
|
|
this.callback()
|
|
this.dlg.close()
|
|
})
|
|
.catch(err => showError(err))
|
|
} catch (err) {
|
|
alert(`A JSON error occured:\n\n${err}`)
|
|
}
|
|
|
|
}// }}}
|
|
delete() {// {{{
|
|
if (!confirm(`Unhook the '${this.hook.Script.Name}' script?`))
|
|
return
|
|
|
|
window._app.query(`/hooks/delete/${this.hook.ID}`)
|
|
.then(() => {
|
|
this.dlg.close()
|
|
this.parentList.hookDeleted(this.hook.ID)
|
|
})
|
|
.catch(err => showError(err))
|
|
}// }}}
|
|
}
|
|
|
|
class ScriptExecutionList extends Component {
|
|
constructor() {// {{{
|
|
super()
|
|
this.executions = []
|
|
}// }}}
|
|
async fetchExecutions() {// {{{
|
|
return new Promise((resolve, reject) => {
|
|
window._app.query('/scriptexecutions/')
|
|
.then(data => {
|
|
this.executions = data.ScriptExecutions
|
|
resolve()
|
|
})
|
|
.catch(err => reject(err))
|
|
})
|
|
}// }}}
|
|
renderComponent() {// {{{
|
|
const tmpl = document.createElement('template')
|
|
tmpl.innerHTML = `
|
|
<div class="label">Script executions</div>
|
|
<div class="executions">
|
|
<div class="header">ID</div>
|
|
<div class="header">SSH</div>
|
|
<div class="header">Script</div>
|
|
<div class="header">Start</div>
|
|
<div class="header">End</div>
|
|
<div class="header">Script</div>
|
|
<div class="header">Data</div>
|
|
<div class="header">Env</div>
|
|
<div class="header">Out</div>
|
|
<div class="header">Err</div>
|
|
<div class="header">Exitcode</div>
|
|
</div>
|
|
`
|
|
const executions = tmpl.content.querySelector('.executions')
|
|
|
|
for (const e of this.executions) {
|
|
const se = new ScriptExecution(e)
|
|
executions.append(se.render())
|
|
}
|
|
|
|
return tmpl.content
|
|
}// }}}
|
|
}
|
|
|
|
class ScriptExecution extends Component {
|
|
constructor(execution) {// {{{
|
|
super()
|
|
this.execution = execution
|
|
}// }}}
|
|
formatTime(t) {// {{{
|
|
const d = new Date(t)
|
|
const year = d.getYear() + 1900
|
|
const month = `0${d.getMonth() + 1}`.slice(-2)
|
|
const date = `0${d.getDate()}`.slice(-2)
|
|
const hour = `0${d.getHours()}`.slice(-2)
|
|
const min = `0${d.getMinutes()}`.slice(-2)
|
|
const sec = `0${d.getSeconds()}`.slice(-2)
|
|
return `<span class="date">${year}-${month}-${date}</span> <span class="time">${hour}:${min}:${sec}</span>`
|
|
}// }}}
|
|
icon(name) {// {{{
|
|
return `<img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/${name}.svg" />`
|
|
}// }}}
|
|
renderComponent() {// {{{
|
|
const tmpl = document.createElement('template')
|
|
|
|
tmpl.innerHTML = `
|
|
<div class="id">${this.execution.ID}</div>
|
|
<div class="ssh">${this.execution.SSH}</div>
|
|
<div class="name">${this.execution.ScriptName}</div>
|
|
<div class="start">${this.formatTime(this.execution.TimeStart.Time)}</div>
|
|
<div class="end">${this.formatTime(this.execution.TimeEnd.Time)}</div>
|
|
<div class="source">${this.execution.HasSource ? this.icon('bash') : ''}</div>
|
|
<div class="data">${this.execution.HasData ? this.icon('code-json') : ''}</div>
|
|
<div class="env">${this.execution.HasEnv ? this.icon('application-braces-outline') : ''}</div>
|
|
<div class="stdout">${this.execution.HasOutputStdout ? this.icon('text-box-check-outline') : ''}</div>
|
|
<div class="stderr">${this.execution.HasOutputStderr ? this.icon('text-box-remove-outline') : ''}</div>
|
|
<div class="exitcode"><div class="code-${this.execution.ExitCode.Int16 == 0 ? 'ok' : 'error'}">${this.execution.ExitCode.Int16}</div></div>
|
|
`
|
|
|
|
const classValues = new Map()
|
|
classValues.set('source', 'Source')
|
|
classValues.set('data', 'Data')
|
|
classValues.set('env', 'Env')
|
|
classValues.set('stdout', 'OutputStdout')
|
|
classValues.set('stderr', 'OutputStderr')
|
|
|
|
for (const [cls, value] of classValues) {
|
|
tmpl.content.querySelector(`.${cls}`).addEventListener('click', () => {
|
|
new ScriptExecutionValueDialog(this.execution, value).render()
|
|
})
|
|
}
|
|
|
|
return tmpl.content
|
|
}// }}}
|
|
}
|
|
|
|
class ScriptExecutionValueDialog extends Component {
|
|
constructor(execution, valueName) {// {{{
|
|
super()
|
|
this.execution = execution
|
|
this.valueName = valueName
|
|
|
|
this.dlg = document.createElement('dialog')
|
|
this.dlg.id = 'script-execution-value-dialog'
|
|
this.dlg.addEventListener('close', () => this.dlg.remove())
|
|
|
|
this.value = null
|
|
}// }}}
|
|
getValue(execution) {// {{{
|
|
switch (this.valueName) {
|
|
case 'Source':
|
|
return execution.Source
|
|
|
|
case 'Data':
|
|
case 'Env':
|
|
return JSON.stringify(
|
|
JSON.parse(execution[this.valueName])
|
|
,
|
|
null,
|
|
' '
|
|
)
|
|
|
|
case 'OutputStdout':
|
|
case 'OutputStderr':
|
|
return execution[this.valueName]?.String
|
|
}
|
|
}// }}}
|
|
renderComponent() {// {{{
|
|
const div = document.createElement('div')
|
|
div.innerHTML = `
|
|
<div class="top">
|
|
<div class="header">${this.valueName}</div>
|
|
<img class="copy" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/content-copy.svg" />
|
|
</div>
|
|
<div class="label">${this.execution.ID}</div>
|
|
<div class="value"></div>
|
|
`
|
|
|
|
this.value = div.querySelector('.value')
|
|
div.querySelector('.copy').addEventListener('click', event=>{
|
|
event.target.classList.add('clicked')
|
|
setTimeout(()=>event.target.classList.remove('clicked'), 250)
|
|
navigator.clipboard.writeText(this.value.innerText)
|
|
})
|
|
|
|
window._app.query(`/scriptexecutions/${this.execution.ID}`)
|
|
.then(data => {
|
|
this.value.innerText = this.getValue(data.ScriptExecution)
|
|
})
|
|
.catch(err => showError(err))
|
|
|
|
this.dlg.append(...div.children)
|
|
document.body.append(this.dlg)
|
|
this.dlg.showModal()
|
|
|
|
return []
|
|
}// }}}
|
|
}
|
|
|
|
// vim: foldmethod=marker
|