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 = `
` 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 = `
` 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 = `
${t.Schema.title || t.Name}
` 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 = `
${this.type.Schema.title}
` 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 = `
Connected nodes
` 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 = `
${n.TypeSchema.title}
` 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 = `
${this.node.Name}
` 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 = `
Script hooks
Script
SSH
` 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 = `
${this.hook.Script.Name}
` 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 = `
${s.Name}
` 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 = `
Group
Name
Source
` 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 = `
Search for script
` 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 = `
SSH
Schedule automatically
Environment
A map with keys and values as strings.
` div.querySelector('.header').innerText = `Hook for ${this.hook.Script.Name}` div.querySelector('.trash').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 = `
Script executions
ID
SSH
Script
Start
End
Script
Data
Env
Out
Err
Exitcode
` 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 `${year}-${month}-${date} ${hour}:${min}:${sec}` }// }}} icon(name) {// {{{ return `` }// }}} renderComponent() {// {{{ const tmpl = document.createElement('template') tmpl.innerHTML = `
${this.execution.ID}
${this.execution.SSH}
${this.execution.ScriptName}
${this.formatTime(this.execution.TimeStart.Time)}
${this.formatTime(this.execution.TimeEnd.Time)}
${this.execution.HasSource ? this.icon('bash') : ''}
${this.execution.HasData ? this.icon('code-json') : ''}
${this.execution.HasEnv ? this.icon('application-braces-outline') : ''}
${this.execution.HasOutputStdout ? this.icon('text-box-check-outline') : ''}
${this.execution.HasOutputStderr ? this.icon('text-box-remove-outline') : ''}
${this.execution.ExitCode.Int16}
` 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 = `
${this.valueName}
${this.execution.ID}
` 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