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 96af03b..6f54d03 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,64 @@ 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{} + + row := db.QueryRow(` + SELECT + json_agg(script) AS scripts + FROM public.script + WHERE + name ILIKE $1 + ORDER BY + "group" ASC, + name ASC + `, + 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 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)) + 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/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
+ +
` @@ -973,56 +1003,133 @@ class ConnectedNode { } class ScriptHooks extends Component { - constructor(hooks) { + constructor(hooks) {// {{{ super() this.hooks = hooks - } - renderComponent() { + this.scriptGrid = null + }// }}} + renderComponent() {// {{{ const div = document.createElement('div') div.innerHTML = ` -
Script hooks
-
+
+
Script hooks
+ +
Script
SSH
` - div.querySelector('.add').addEventListener('click', ()=>{ - alert('FIXME') + div.querySelector('.add').addEventListener('click', () => { + const dlg = new ScriptSelectDialog(s => { + this.hookScript(s) + }) + dlg.render() }) - 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 - } + }// }}} + 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) { + 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', () => { - 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 + } + this.parentList.hookDeleted(this.hook.ID) + }) + .catch(err => showError(err)) + }// }}} } class ScriptsList extends Component { @@ -1238,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 cd8d9b3..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) @@ -50,7 +51,9 @@ 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/search", actionScriptsSearch) + http.HandleFunc("/hooks/update", actionHookUpdate) + http.HandleFunc("/hooks/delete/{hookID}", actionHookDelete) err = http.ListenAndServe(address, nil) return @@ -372,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) { // {{{ @@ -621,15 +655,67 @@ 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 + 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)