This commit is contained in:
Magnus Åhall 2025-08-07 19:25:05 +02:00
parent 38eef01e34
commit df4cee56af
7 changed files with 292 additions and 74 deletions

View file

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

View file

@ -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, '<host>')`, 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))

View file

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

View file

@ -3,12 +3,12 @@
<svg
width="109.69499mm"
height="47.615765mm"
viewBox="0 0 109.695 47.615764"
height="43.310902mm"
viewBox="0 0 109.695 43.310901"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4 (e7c3feb, 2024-10-09)"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="logo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
@ -24,13 +24,13 @@
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.6985744"
inkscape:cx="278.17446"
inkscape:cy="101.85012"
inkscape:window-width="2190"
inkscape:window-height="1404"
inkscape:window-x="1463"
inkscape:window-y="16"
inkscape:window-maximized="0"
inkscape:cx="278.17445"
inkscape:cy="102.14448"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showgrid="false" /><defs
id="defs1" /><g
@ -47,17 +47,7 @@
id="circle1"
cx="149.99937"
cy="115.46115"
r="4.4009533" /><text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:30.5104px;line-height:normal;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:center;text-decoration-color:#000000;text-anchor:middle;fill:#262626;stroke-width:1.71737;-inkscape-stroke:none"
x="62.183887"
y="178.34908"
id="text4"><tspan
sodipodi:role="line"
id="tspan4"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:30.5104px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.71737"
x="62.183887"
y="178.34908">JSON</tspan></text><circle
r="4.4009533" /><circle
style="fill:#1e8071;fill-opacity:1;stroke-width:0.529166;-inkscape-stroke:none"
id="circle5"
cx="113.69239"
@ -92,7 +82,7 @@
style="fill:#21608c;fill-opacity:1;stroke-width:0.529167;-inkscape-stroke:none"
id="circle2"
cx="107.89297"
cy="142.85443"
cy="138.54956"
r="4.3678799" /><path
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#1e6380;fill-opacity:1;fill-rule:evenodd;stroke:#205d7b;stroke-width:0.529167;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1"
d="m 145.92055,117.1137 -20.27428,8.21418"
@ -134,7 +124,7 @@
style="font-size:30.5104px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Normal';text-align:center;text-anchor:middle;fill:#262626;stroke-width:2.13619"
aria-label="GRAPH" /></g><path
style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 117.58714,131.21242 -6.89925,8.28551"
d="m 116.99677,130.63452 -5.8076,5.04925"
id="path13"
inkscape:connector-type="polyline"
inkscape:connector-curvature="0"

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

View file

@ -29,6 +29,7 @@ export class App {
'NODE_EDIT_NAME',
'NODE_MOVE',
'NODE_SELECTED',
'NODE_HOOKED',
'SCRIPT_CREATED',
'SCRIPT_DELETED',
'SCRIPT_EDIT',
@ -86,6 +87,10 @@ export class App {
this.nodeDelete(this.currentNode.ID)
break
case 'NODE_HOOKED':
this.edit(event.detail)
break
case 'NODE_MOVE':
const nodes = this.tree.markedNodes()
if (!confirm(`Are you sure you want to move ${nodes.length} nodes here?`))
@ -433,6 +438,29 @@ export class App {
return 0
}// }}}
async query(path, data) {// {{{
return new Promise((resolve, reject) => {
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 = `
<div class="label">Connected nodes</div>
<div class="add"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg" /></div>
<div class="label">
<div style="white-space: nowrap">Connected nodes</div>
<img class="add" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg" />
</div>
<div class="connected-nodes"></div>
`
@ -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 = `
<div class="label">Script hooks</div>
<div class="add"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg" /></div>
<div class="label">
<div style="white-space: nowrap">Script hooks</div>
<img class="add" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg" />
</div>
<div class="scripts-grid">
<div class="header" style="grid-column: 1 / 3;">Script</div>
<div class="header" style="grid-column: 3 / 5;">SSH</div>
@ -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 = `
<div class="script-icon"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/bash.svg" /></div>
<div class="script-name">${this.hook.Script.Name}</div>
<div class="script-ssh">${this.hook.SSH}</div>
<div class="script-ssh"></div>
<div class="script-unhook"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/trash-can.svg" /></div>
`
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 = `
<div class="header">Search for script</div>
<div><input class="search-for" type="text" value="%"></div>
<div><button>Search</button></div>
<div class="scripts"></div>
`
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

View file

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

View file

@ -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) { // {{{