From 83f858285fe28109248dc4dfc2eeb3855b42d069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 7 Aug 2025 17:46:37 +0200 Subject: [PATCH 1/3] Rendering and unhooking of hooks --- script.go | 29 ++++++++++++---- sql/0012.sql | 1 + static/js/app.mjs | 84 +++++++++++++++++++++++++++++++++++++++-------- webserver.go | 32 +++++++++++++++--- 4 files changed, 122 insertions(+), 24 deletions(-) create mode 100644 sql/0012.sql diff --git a/script.go b/script.go index 96af03b..2a8a27e 100644 --- a/script.go +++ b/script.go @@ -26,7 +26,7 @@ type Hook struct { SSH string } -func GetScripts() (scripts []Script, err error) { +func GetScripts() (scripts []Script, err error) {// {{{ scripts = []Script{} var rows *sqlx.Rows @@ -54,8 +54,8 @@ func GetScripts() (scripts []Script, err error) { } return -} -func UpdateScript(scriptID int, data []byte) (script Script, err error) { +}// }}} +func UpdateScript(scriptID int, data []byte) (script Script, err error) {// {{{ err = json.Unmarshal(data, &script) if err != nil { err = werr.Wrap(err) @@ -106,12 +106,29 @@ func UpdateScript(scriptID int, data []byte) (script Script, err error) { } return -} -func DeleteScript(scriptID int) (err error) { +}// }}} +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 -} +}// }}} + +func UpdateHook(hook Hook) (err error) {// {{{ + _, err = db.Exec(`UPDATE hook SET ssh=$2 WHERE id=$1`, hook.ID, strings.TrimSpace(hook.SSH)) + if err != nil { + err = werr.Wrap(err) + return + } + return +}// }}} +func DeleteHook(hookID int) (err error) {// {{{ + _, err = db.Exec(`DELETE FROM hook WHERE id=$1`, hookID) + if err != nil { + err = werr.Wrap(err) + return + } + return +}// }}} diff --git a/sql/0012.sql b/sql/0012.sql new file mode 100644 index 0000000..1297758 --- /dev/null +++ b/sql/0012.sql @@ -0,0 +1 @@ +ALTER TABLE public.hook ADD CONSTRAINT hook_unique UNIQUE (node_id,script_id); diff --git a/static/js/app.mjs b/static/js/app.mjs index 503ffc4..fac5cfe 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -973,11 +973,18 @@ class ConnectedNode { } class ScriptHooks extends Component { - constructor(hooks) { + constructor(hooks) {// {{{ super() this.hooks = hooks - } - renderComponent() { + this.scriptGrid = null + + mbus.subscribe('hook_deleted', event => { + const deletedHook = event.detail + this.hooks = this.hooks.filter(h => h.ID !== deletedHook.ID) + this.renderHooks() + }) + }// }}} + renderComponent() {// {{{ const div = document.createElement('div') div.innerHTML = `
Script hooks
@@ -988,24 +995,30 @@ class ScriptHooks extends Component { ` - div.querySelector('.add').addEventListener('click', ()=>{ + div.querySelector('.add').addEventListener('click', () => { alert('FIXME') }) - const scriptsGrid = div.querySelector('.scripts-grid') - for(const hook of this.hooks) { - const h = new ScriptHook(hook) - scriptsGrid.append(h.render()) - } + this.scriptGrid = div.querySelector('.scripts-grid') + this.renderHooks() return div.children - } + }// }}} + renderHooks() {// {{{ + this.scriptGrid.innerHTML = '' + + for (const hook of this.hooks) { + const h = new ScriptHook(hook) + this.scriptGrid.append(h.render()) + } + }// }}} } class ScriptHook extends Component { constructor(hook) {// {{{ super() this.hook = hook + this.element_ssh = null }// }}} renderComponent() {// {{{ const tmpl = document.createElement('template') @@ -1015,14 +1028,57 @@ class ScriptHook extends Component {
${this.hook.SSH}
` + this.element_ssh = tmpl.content.querySelector('.script-ssh') - tmpl.content.querySelector('.script-ssh').addEventListener('click', () => { - prompt('SSH', this.hook.SSH) - //new ConnectionDataDialog(this.hook, () => _app.edit(_app.currentNode.ID)).render() - }) + tmpl.content.querySelector('.script-ssh').addEventListener('click', () => this.update()) + tmpl.content.querySelector('.script-unhook').addEventListener('click', () => this.delete()) return tmpl.content }// }}} + update() {// {{{ + const ssh = prompt('SSH', this.hook.SSH) + if (ssh === null) + return + if (ssh.trim() === '') { + alert(`SSH can't be empty.`) + return + } + + const request = { + ID: this.hook.ID, + SSH: ssh, + } + fetch('/hooks/update', { + method: 'POST', + body: JSON.stringify(request), + + }) + .then(data => data.json()) + .then(json => { + if (!json.OK) { + showError(json.Error) + return + } + this.hook.SSH = ssh + this.element_ssh.innerText = this.hook.SSH + }) + .catch(err => showError(err)) + }// }}} + delete() {// {{{ + if (!confirm(`Unhook the '${this.hook.Script.Name}' script?`)) + return + + fetch(`/hooks/delete/${this.hook.ID}`) + .then(data => data.json()) + .then(json => { + if (!json.OK) { + showError(json.Error) + return + } + mbus.dispatch('hook_deleted', this.hook) + }) + .catch(err => showError(err)) + }// }}} } class ScriptsList extends Component { diff --git a/webserver.go b/webserver.go index cd8d9b3..1b6e90e 100644 --- a/webserver.go +++ b/webserver.go @@ -50,7 +50,8 @@ func initWebserver() (err error) { http.HandleFunc("/scripts/", actionScripts) http.HandleFunc("/scripts/update/{scriptID}", actionScriptUpdate) http.HandleFunc("/scripts/delete/{scriptID}", actionScriptDelete) - http.HandleFunc("/hooks/update/{hookID}", actionHookUpdate) + http.HandleFunc("/hooks/update", actionHookUpdate) + http.HandleFunc("/hooks/delete/{hookID}", actionHookDelete) err = http.ListenAndServe(address, nil) return @@ -623,13 +624,36 @@ func actionScriptDelete(w http.ResponseWriter, r *http.Request) { // {{{ } // }}} func actionHookUpdate(w http.ResponseWriter, r *http.Request) { // {{{ + var hook Hook + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &hook) + if err != nil { + err = werr.Wrap(err) + httpError(w, err) + return + } + + err = UpdateHook(hook) + if err != nil { + err = werr.Wrap(err) + httpError(w, err) + return + } + + out := struct { + OK bool + }{ + true, + } + j, _ := json.Marshal(out) + w.Write(j) +} // }}} +func actionHookDelete(w http.ResponseWriter, r *http.Request) { // {{{ hookID := 0 hookIDStr := r.PathValue("hookID") hookID, _ = strconv.Atoi(hookIDStr) - // XXX - here - - err := UpdateHook(hook) + err := DeleteHook(hookID) if err != nil { err = werr.Wrap(err) httpError(w, err) From 38eef01e34f9ec60019a8776b16d8ab8ffac1229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 7 Aug 2025 17:55:04 +0200 Subject: [PATCH 2/3] Search for scripts --- script.go | 48 ++++++++++++++++++++++++++++++++++++++---------- webserver.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/script.go b/script.go index 2a8a27e..5dc8bf3 100644 --- a/script.go +++ b/script.go @@ -26,7 +26,7 @@ type Hook struct { SSH string } -func GetScripts() (scripts []Script, err error) {// {{{ +func GetScripts() (scripts []Script, err error) { // {{{ scripts = []Script{} var rows *sqlx.Rows @@ -54,8 +54,8 @@ func GetScripts() (scripts []Script, err error) {// {{{ } return -}// }}} -func UpdateScript(scriptID int, data []byte) (script Script, err error) {// {{{ +} // }}} +func UpdateScript(scriptID int, data []byte) (script Script, err error) { // {{{ err = json.Unmarshal(data, &script) if err != nil { err = werr.Wrap(err) @@ -106,29 +106,57 @@ func UpdateScript(scriptID int, data []byte) (script Script, err error) {// {{{ } return -}// }}} -func DeleteScript(scriptID int) (err error) {// {{{ +} // }}} +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 -}// }}} +} // }}} +func SearchScripts(search string) (scripts []Script, err error) { // {{{ + scripts = []Script{} -func UpdateHook(hook Hook) (err error) {// {{{ + row := db.QueryRow(` + SELECT + json_agg(script) AS scripts + FROM public.script + WHERE + name ILIKE $1 + `, + search, + ) + + var jsonBody []byte + err = row.Scan(&jsonBody) + if err != nil { + err = werr.Wrap(err) + return + } + + err = json.Unmarshal(jsonBody, &scripts) + if err != nil { + err = werr.Wrap(err) + return + } + + return +} // }}} + +func UpdateHook(hook Hook) (err error) { // {{{ _, err = db.Exec(`UPDATE hook SET ssh=$2 WHERE id=$1`, hook.ID, strings.TrimSpace(hook.SSH)) if err != nil { err = werr.Wrap(err) return } return -}// }}} -func DeleteHook(hookID int) (err error) {// {{{ +} // }}} +func DeleteHook(hookID int) (err error) { // {{{ _, err = db.Exec(`DELETE FROM hook WHERE id=$1`, hookID) if err != nil { err = werr.Wrap(err) return } return -}// }}} +} // }}} diff --git a/webserver.go b/webserver.go index 1b6e90e..f707802 100644 --- a/webserver.go +++ b/webserver.go @@ -50,6 +50,7 @@ func initWebserver() (err error) { http.HandleFunc("/scripts/", actionScripts) http.HandleFunc("/scripts/update/{scriptID}", actionScriptUpdate) http.HandleFunc("/scripts/delete/{scriptID}", actionScriptDelete) + http.HandleFunc("/hooks/search", actionScriptsSearch) http.HandleFunc("/hooks/update", actionHookUpdate) http.HandleFunc("/hooks/delete/{hookID}", actionHookDelete) @@ -622,6 +623,35 @@ func actionScriptDelete(w http.ResponseWriter, r *http.Request) { // {{{ j, _ := json.Marshal(out) w.Write(j) } // }}} +func actionScriptsSearch(w http.ResponseWriter, r *http.Request) { // {{{ + var search struct { + Search string + } + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &search) + if err != nil { + err = werr.Wrap(err) + httpError(w, err) + return + } + + scripts, err := SearchScripts(search.Search) + if err != nil { + err = werr.Wrap(err) + httpError(w, err) + return + } + + out := struct { + OK bool + Scripts []Script + }{ + true, + scripts, + } + j, _ := json.Marshal(out) + w.Write(j) +} // }}} func actionHookUpdate(w http.ResponseWriter, r *http.Request) { // {{{ var hook Hook From df4cee56af3de3aab1777cbe175098a5df445825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 7 Aug 2025 19:25:05 +0200 Subject: [PATCH 3/3] Hooking --- node.go | 3 + script.go | 7 ++ static/css/main.css | 59 ++++++++++----- static/images/logo.svg | 36 ++++------ static/js/app.mjs | 159 ++++++++++++++++++++++++++++++++++++----- static/less/main.less | 70 +++++++++++++----- webserver.go | 32 +++++++++ 7 files changed, 292 insertions(+), 74 deletions(-) diff --git a/node.go b/node.go index bac98bf..85738d6 100644 --- a/node.go +++ b/node.go @@ -98,6 +98,9 @@ func GetNode(nodeID int) (node Node, err error) { // {{{ INNER JOIN public.script s ON h.script_id = s.id WHERE h.node_id = n.id + ORDER BY + s.group ASC, + s.name ASC ) AS res ) , '[]'::jsonb diff --git a/script.go b/script.go index 5dc8bf3..6f54d03 100644 --- a/script.go +++ b/script.go @@ -124,6 +124,9 @@ func SearchScripts(search string) (scripts []Script, err error) { // {{{ FROM public.script WHERE name ILIKE $1 + ORDER BY + "group" ASC, + name ASC `, search, ) @@ -143,6 +146,10 @@ func SearchScripts(search string) (scripts []Script, err error) { // {{{ return } // }}} +func HookScript(nodeID, scriptID int) (err error) {// {{{ + _, err = db.Exec(`INSERT INTO hook(node_id, script_id, ssh) VALUES($1, $2, '')`, nodeID, scriptID) + return +}// }}} func UpdateHook(hook Hook) (err error) { // {{{ _, err = db.Exec(`UPDATE hook SET ssh=$2 WHERE id=$1`, hook.ID, strings.TrimSpace(hook.SSH)) diff --git a/static/css/main.css b/static/css/main.css index b87e152..191b218 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -210,17 +210,18 @@ select:focus { outline-offset: -2px; } #connected-nodes > .label { + display: grid; + grid-template-columns: min-content min-content; + align-items: center; color: var(--section-color); font-weight: bold; font-size: 1.25em; - margin-bottom: 8px; + margin-bottom: 16px; } -#connected-nodes > .add { - margin-bottom: 8px; -} -#connected-nodes > .add img { +#connected-nodes > .label > img.add { height: 24px; cursor: pointer; + margin-left: 8px; } #connected-nodes .connected-nodes { display: flex; @@ -248,9 +249,9 @@ select:focus { } #script-hooks .scripts-grid { display: grid; - grid-template-columns: repeat(4, min-content); + grid-template-columns: repeat(3, min-content); align-items: center; - grid-gap: 4px 0px; + grid-gap: 2px 0px; } #script-hooks .scripts-grid .header { font-weight: bold; @@ -259,10 +260,11 @@ select:focus { #script-hooks .scripts-grid div { white-space: nowrap; } -#script-hooks .scripts-grid .script-icon { - margin-right: 4px; +#script-hooks .scripts-grid .script-group { + grid-column: 1 / -1; + font-weight: bold; + margin-top: 8px; } -#script-hooks .scripts-grid .script-icon img, #script-hooks .scripts-grid .script-unhook img { display: block; height: 24px; @@ -274,19 +276,20 @@ select:focus { #script-hooks .scripts-grid .script-ssh { cursor: pointer; } -#script-hooks > .add { - margin-bottom: 8px; -} -#script-hooks > .add img { - height: 24px; - cursor: pointer; -} #script-hooks > .label { + display: grid; + grid-template-columns: min-content min-content; + align-items: center; color: var(--section-color); font-weight: bold; font-size: 1.25em; margin-bottom: 8px; } +#script-hooks > .label img.add { + height: 24px; + cursor: pointer; + margin-left: 8px; +} #select-node { padding: 32px; display: grid; @@ -298,6 +301,10 @@ select:focus { min-width: 300px; width: 100%; } +#select-node .label { + font-weight: bold; + color: var(--section-color); +} #select-node button { width: 100px !important; } @@ -456,3 +463,21 @@ dialog#connection-data div.button { #editor-script button { margin-top: 8px; } +#script-select-dialog { + display: grid; + grid-gap: 8px; + padding: 32px; +} +#script-select-dialog > .header { + font-weight: bold; + color: var(--section-color); +} +#script-select-dialog .scripts .group { + font-weight: bold; + color: var(--section-color); + margin-top: 16px; +} +#script-select-dialog .scripts .script { + cursor: pointer; + margin-top: 4px; +} diff --git a/static/images/logo.svg b/static/images/logo.svg index 1107d10..446dd4c 100644 --- a/static/images/logo.svg +++ b/static/images/logo.svg @@ -3,12 +3,12 @@ JSON { + 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 { @@ -921,8 +949,10 @@ class ConnectedNodes { render() {// {{{ const div = document.createElement('template') div.innerHTML = ` -
Connected nodes
-
+
+
Connected nodes
+ +
` @@ -977,18 +1007,14 @@ class ScriptHooks extends Component { super() this.hooks = hooks this.scriptGrid = null - - mbus.subscribe('hook_deleted', event => { - const deletedHook = event.detail - this.hooks = this.hooks.filter(h => h.ID !== deletedHook.ID) - this.renderHooks() - }) }// }}} renderComponent() {// {{{ const div = document.createElement('div') div.innerHTML = ` -
Script hooks
-
+
+
Script hooks
+ +
Script
SSH
@@ -996,7 +1022,10 @@ class ScriptHooks extends Component { ` div.querySelector('.add').addEventListener('click', () => { - alert('FIXME') + const dlg = new ScriptSelectDialog(s => { + this.hookScript(s) + }) + dlg.render() }) this.scriptGrid = div.querySelector('.scripts-grid') @@ -1004,31 +1033,53 @@ class ScriptHooks extends Component { return div.children }// }}} + hookDeleted(deletedHookID) {// {{{ + this.hooks = this.hooks.filter(h => h.ID !== deletedHookID) + this.renderHooks() + }// }}} renderHooks() {// {{{ this.scriptGrid.innerHTML = '' + let prevGroup = null for (const hook of this.hooks) { - const h = new ScriptHook(hook) + if (hook.Script.Group !== prevGroup) { + const g = document.createElement('div') + g.classList.add('script-group') + g.innerText = hook.Script.Group + this.scriptGrid.append(g) + prevGroup = hook.Script.Group + } + + const h = new ScriptHook(hook, this) this.scriptGrid.append(h.render()) } }// }}} + 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) {// {{{ + constructor(hook, parentList) {// {{{ super() this.hook = hook + this.parentList = parentList this.element_ssh = null }// }}} renderComponent() {// {{{ const tmpl = document.createElement('template') tmpl.innerHTML = ` -
${this.hook.Script.Name}
-
${this.hook.SSH}
+
` this.element_ssh = tmpl.content.querySelector('.script-ssh') + this.element_ssh.innerText = this.hook.SSH tmpl.content.querySelector('.script-ssh').addEventListener('click', () => this.update()) tmpl.content.querySelector('.script-unhook').addEventListener('click', () => this.delete()) @@ -1075,7 +1126,7 @@ class ScriptHook extends Component { showError(json.Error) return } - mbus.dispatch('hook_deleted', this.hook) + this.parentList.hookDeleted(this.hook.ID) }) .catch(err => showError(err)) }// }}} @@ -1294,4 +1345,80 @@ class ScriptEditor extends Component { }// }}} } +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) + } + }// }}} +} + // vim: foldmethod=marker diff --git a/static/less/main.less b/static/less/main.less index c0a303b..709684c 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -284,17 +284,19 @@ select:focus { #connected-nodes { & > .label { + display: grid; + grid-template-columns: min-content min-content; + align-items: center; + color: var(--section-color); font-weight: bold; font-size: 1.25em; - margin-bottom: 8px; - } + margin-bottom: 16px; - & > .add { - margin-bottom: 8px; - img { + & > img.add { height: 24px; cursor: pointer; + margin-left: 8px; } } @@ -337,19 +339,21 @@ select:focus { } display: grid; - grid-template-columns: repeat(4, min-content); + grid-template-columns: repeat(3, min-content); align-items: center; - grid-gap: 4px 0px; + grid-gap: 2px 0px; div { white-space: nowrap; } - .script-icon { - margin-right: 4px; + .script-group { + grid-column: 1 / -1; + font-weight: bold; + margin-top: 8px; } - .script-icon, .script-unhook { + .script-unhook { img { display: block; height: 24px; @@ -365,19 +369,21 @@ select:focus { } } - & > .add { - margin-bottom: 8px; - img { - height: 24px; - cursor: pointer; - } - } - & > .label { + display: grid; + grid-template-columns: min-content min-content; + align-items: center; + color: var(--section-color); font-weight: bold; font-size: 1.25em; margin-bottom: 8px; + + img.add { + height: 24px; + cursor: pointer; + margin-left: 8px; + } } } @@ -394,6 +400,11 @@ select:focus { width: 100%; } + .label { + font-weight: bold; + color: var(--section-color); + } + button { width: 100px !important; } @@ -589,3 +600,26 @@ dialog#connection-data { margin-top: 8px; } } + +#script-select-dialog { + display: grid; + grid-gap: 8px; + padding: 32px; + + & > .header { + font-weight: bold; + color: var(--section-color); + } + + .scripts { + .group { + font-weight: bold; + color: var(--section-color); + margin-top: 16px; + } + .script { + cursor: pointer; + margin-top: 4px; + } + } +} diff --git a/webserver.go b/webserver.go index f707802..ba2d293 100644 --- a/webserver.go +++ b/webserver.go @@ -41,6 +41,7 @@ func initWebserver() (err error) { http.HandleFunc("/nodes/move", actionNodeMove) http.HandleFunc("/nodes/search", actionNodeSearch) http.HandleFunc("/nodes/connect", actionNodeConnect) + http.HandleFunc("/nodes/hook", actionNodeHook) http.HandleFunc("/types/{typeID}", actionType) http.HandleFunc("/types/", actionTypesAll) http.HandleFunc("/types/create", actionTypeCreate) @@ -374,6 +375,37 @@ func actionNodeConnect(w http.ResponseWriter, r *http.Request) { // {{{ j, _ := json.Marshal(res) w.Write(j) +} // }}} +func actionNodeHook(w http.ResponseWriter, r *http.Request) { // {{{ + var req struct { + NodeID int + ScriptID int + } + + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &req) + if err != nil { + err = werr.Wrap(err) + httpError(w, err) + return + } + + err = HookScript(req.NodeID, req.ScriptID) + if err != nil { + pqErr, ok := err.(*pq.Error) + if ok && pqErr.Code == "23505" { + err = errors.New("This script is already hooked.") + } else { + err = werr.Wrap(err) + } + httpError(w, err) + return + } + + res := struct{ OK bool }{true} + j, _ := json.Marshal(res) + w.Write(j) + } // }}} func actionType(w http.ResponseWriter, r *http.Request) { // {{{