From 04b1325031935d056602b05374eafc458e0fa2dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 24 Jul 2025 23:22:44 +0200 Subject: [PATCH] Added script management --- script.go | 110 ++++++++++++++++ sql/0010.sql | 1 + static/css/main.css | 53 ++++++++ static/js/app.mjs | 271 +++++++++++++++++++++++++++++++++++++++- static/js/component.mjs | 10 +- static/less/main.less | 69 ++++++++++ webserver.go | 65 ++++++++++ 7 files changed, 575 insertions(+), 4 deletions(-) create mode 100644 script.go diff --git a/script.go b/script.go new file mode 100644 index 0000000..6d73ad0 --- /dev/null +++ b/script.go @@ -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 +} diff --git a/sql/0010.sql b/sql/0010.sql index 4f4cf84..794e00b 100644 --- a/sql/0010.sql +++ b/sql/0010.sql @@ -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, diff --git a/static/css/main.css b/static/css/main.css index 62dd257..7dc5907 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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; +} diff --git a/static/js/app.mjs b/static/js/app.mjs index 45becb6..4fd4c09 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -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 = ` +
+
${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)) + }// }}} +} + // vim: foldmethod=marker diff --git a/static/js/component.mjs b/static/js/component.mjs index a6a207e..93f32e4 100644 --- a/static/js/component.mjs +++ b/static/js/component.mjs @@ -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'; diff --git a/static/less/main.less b/static/less/main.less index 3ba2805..2dc9bdd 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -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; + } +} diff --git a/webserver.go b/webserver.go index 947b47e..68ed9cb 100644 --- a/webserver.go +++ b/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