Added script management

This commit is contained in:
Magnus Åhall 2025-07-24 23:22:44 +02:00
parent ba7375fe15
commit 04b1325031
7 changed files with 575 additions and 4 deletions

110
script.go Normal file
View 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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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