Added script management
This commit is contained in:
parent
ba7375fe15
commit
04b1325031
7 changed files with 575 additions and 4 deletions
110
script.go
Normal file
110
script.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
// External
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
// Standard
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Script struct {
|
||||
ID int
|
||||
Group string
|
||||
Name string
|
||||
Source string
|
||||
Updated time.Time
|
||||
}
|
||||
|
||||
func GetScripts() (scripts []Script, err error) {
|
||||
scripts = []Script{}
|
||||
|
||||
var rows *sqlx.Rows
|
||||
rows, err = db.Queryx(`
|
||||
SELECT *
|
||||
FROM script
|
||||
ORDER BY
|
||||
"group" ASC,
|
||||
name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var script Script
|
||||
err = rows.StructScan(&script)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err)
|
||||
return
|
||||
}
|
||||
scripts = append(scripts, script)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
func UpdateScript(scriptID int, data []byte) (script Script, err error) {
|
||||
err = json.Unmarshal(data, &script)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
script.ID = scriptID
|
||||
script.Group = strings.TrimSpace(script.Group)
|
||||
script.Name = strings.TrimSpace(script.Name)
|
||||
|
||||
if script.Group == "" || script.Name == "" {
|
||||
err = werr.New("Group and name must be provided.")
|
||||
return
|
||||
}
|
||||
|
||||
if script.ID < 1 {
|
||||
row := db.QueryRowx(`
|
||||
INSERT INTO script("group", "name", "source")
|
||||
VALUES($1, $2, $3)
|
||||
RETURNING
|
||||
id
|
||||
`,
|
||||
strings.TrimSpace(script.Group),
|
||||
strings.TrimSpace(script.Name),
|
||||
script.Source,
|
||||
)
|
||||
err = row.Scan(&script.ID)
|
||||
} else {
|
||||
_, err = db.Exec(`
|
||||
UPDATE script
|
||||
SET
|
||||
"group" = $2,
|
||||
"name" = $3,
|
||||
"source" = $4
|
||||
WHERE
|
||||
id = $1
|
||||
`,
|
||||
scriptID,
|
||||
strings.TrimSpace(script.Group),
|
||||
strings.TrimSpace(script.Name),
|
||||
script.Source,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
err = werr.Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
func DeleteScript(scriptID int) (err error) {
|
||||
_, err = db.Exec(`DELETE FROM script WHERE id = $1`, scriptID)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
CREATE TABLE public.script (
|
||||
id serial NOT NULL,
|
||||
"group" varchar NOT NULL,
|
||||
name varchar NOT NULL,
|
||||
"source" text NOT NULL,
|
||||
updated timestamptz DEFAULT NOW() NOT NULL,
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ button {
|
|||
white-space: nowrap;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--section-color);
|
||||
}
|
||||
#types .group:first-child {
|
||||
margin-top: 0px;
|
||||
|
|
@ -361,3 +362,55 @@ dialog#connection-data div.button {
|
|||
color: #fff;
|
||||
z-index: 8192;
|
||||
}
|
||||
#scripts .group {
|
||||
font-weight: bold;
|
||||
margin-top: 32px;
|
||||
color: var(--section-color);
|
||||
}
|
||||
#scripts .group:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
#scripts .script {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
grid-gap: 4px;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
#scripts .script.selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
#scripts .script img {
|
||||
display: block;
|
||||
height: 24px;
|
||||
}
|
||||
#scripts .script div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#editor-script > div {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr min-content;
|
||||
grid-gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
#editor-script .label {
|
||||
margin-top: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--section-color);
|
||||
}
|
||||
#editor-script .label:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
#editor-script input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
#editor-script textarea {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
#editor-script button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,17 @@ export class App {
|
|||
window.mbus = new MessageBus()
|
||||
this.editor = null
|
||||
this.typesList = null
|
||||
this.scriptsList = null
|
||||
this.scriptEditor = 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',
|
||||
'MENU_ITEM_SELECTED',
|
||||
|
|
@ -24,6 +29,11 @@ export class App {
|
|||
'NODE_EDIT_NAME',
|
||||
'NODE_MOVE',
|
||||
'NODE_SELECTED',
|
||||
'SCRIPT_CREATED',
|
||||
'SCRIPT_DELETED',
|
||||
'SCRIPT_EDIT',
|
||||
'SCRIPTS_LIST_FETCHED',
|
||||
'SCRIPT_UPDATED',
|
||||
'TREE_RELOAD_NODE',
|
||||
'TYPE_EDIT',
|
||||
'TYPES_LIST_FETCHED',
|
||||
|
|
@ -92,6 +102,42 @@ export class App {
|
|||
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
|
||||
|
|
@ -175,6 +221,9 @@ export class App {
|
|||
|
||||
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')
|
||||
|
|
@ -184,7 +233,6 @@ export class App {
|
|||
case 'type':
|
||||
document.getElementById('types').classList.add('show')
|
||||
document.getElementById('editor-type-schema').classList.add('show')
|
||||
document.getElementById('editor-node').style.display = 'none'
|
||||
|
||||
if (this.typesList === null)
|
||||
this.typesList = new TypesList()
|
||||
|
|
@ -196,6 +244,14 @@ export class App {
|
|||
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
|
||||
}
|
||||
}// }}}
|
||||
|
|
@ -913,4 +969,217 @@ class ConnectedNode {
|
|||
}// }}}
|
||||
}
|
||||
|
||||
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() {// {{{
|
||||
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())
|
||||
elements.push(imgAdd)
|
||||
|
||||
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))
|
||||
}// }}}
|
||||
}
|
||||
|
||||
// vim: foldmethod=marker
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ export class Component {
|
|||
}
|
||||
render() {
|
||||
const component = this.renderComponent()
|
||||
this._template.content.appendChild(component)
|
||||
|
||||
if (typeof component[Symbol.iterator] === 'function')
|
||||
this._template.content.replaceChildren(...component)
|
||||
else
|
||||
this._template.content.replaceChildren(component)
|
||||
|
||||
for (const e of this._template.content.children) {
|
||||
e.setAttribute('data-component-name', this._component_name)
|
||||
|
|
@ -16,13 +20,13 @@ export class Component {
|
|||
e.classList.add('tooltip')
|
||||
e.classList.add('left')
|
||||
|
||||
e.addEventListener('mouseover', event=>{
|
||||
e.addEventListener('mouseover', event => {
|
||||
if (event.target !== e)
|
||||
return
|
||||
e.style.border = '1px solid #f0f';
|
||||
})
|
||||
|
||||
e.addEventListener('mouseout', event=>{
|
||||
e.addEventListener('mouseout', event => {
|
||||
if (event.target !== e)
|
||||
return
|
||||
e.style.border = 'none';
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ button {
|
|||
white-space: nowrap;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--section-color);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0px;
|
||||
|
|
@ -467,3 +468,71 @@ dialog#connection-data {
|
|||
z-index: 8192;
|
||||
}
|
||||
}
|
||||
|
||||
#scripts {
|
||||
.group {
|
||||
font-weight: bold;
|
||||
margin-top: 32px;
|
||||
color: var(--section-color);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.script {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
grid-gap: 4px;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#editor-script {
|
||||
& > div {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr min-content;
|
||||
grid-gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--section-color);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
webserver.go
65
webserver.go
|
|
@ -47,6 +47,9 @@ func initWebserver() (err error) {
|
|||
http.HandleFunc("/types/update/{typeID}", actionTypeUpdate)
|
||||
http.HandleFunc("/connection/update/{connID}", actionConnectionUpdate)
|
||||
http.HandleFunc("/connection/delete/{connID}", actionConnectionDelete)
|
||||
http.HandleFunc("/scripts/", actionScripts)
|
||||
http.HandleFunc("/scripts/update/{scriptID}", actionScriptUpdate)
|
||||
http.HandleFunc("/scripts/delete/{scriptID}", actionScriptDelete)
|
||||
|
||||
err = http.ListenAndServe(address, nil)
|
||||
return
|
||||
|
|
@ -551,4 +554,66 @@ func actionConnectionDelete(w http.ResponseWriter, r *http.Request) { // {{{
|
|||
w.Write(j)
|
||||
} // }}}
|
||||
|
||||
func actionScripts(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
scripts, err := GetScripts()
|
||||
if err != nil {
|
||||
httpError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := struct {
|
||||
OK bool
|
||||
Scripts []Script
|
||||
}{
|
||||
true,
|
||||
scripts,
|
||||
}
|
||||
j, _ := json.Marshal(out)
|
||||
w.Write(j)
|
||||
} // }}}
|
||||
func actionScriptUpdate(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
scriptID := 0
|
||||
scriptIDStr := r.PathValue("scriptID")
|
||||
scriptID, _ = strconv.Atoi(scriptIDStr)
|
||||
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
|
||||
script, err := UpdateScript(scriptID, data)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err)
|
||||
httpError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := struct {
|
||||
OK bool
|
||||
Script Script
|
||||
}{
|
||||
true,
|
||||
script,
|
||||
}
|
||||
j, _ := json.Marshal(out)
|
||||
w.Write(j)
|
||||
} // }}}
|
||||
func actionScriptDelete(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
scriptID := 0
|
||||
scriptIDStr := r.PathValue("scriptID")
|
||||
scriptID, _ = strconv.Atoi(scriptIDStr)
|
||||
|
||||
err := DeleteScript(scriptID)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err)
|
||||
httpError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := struct {
|
||||
OK bool
|
||||
}{
|
||||
true,
|
||||
}
|
||||
j, _ := json.Marshal(out)
|
||||
w.Write(j)
|
||||
} // }}}
|
||||
|
||||
// vim: foldmethod=marker
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue