Script execution list

This commit is contained in:
Magnus Åhall 2025-08-08 18:15:13 +02:00
parent 13a5b9a973
commit 55724b36b5
8 changed files with 517 additions and 14 deletions

View file

@ -226,7 +226,7 @@ func ScheduleHook(hookID int) (err error) { // {{{
return
}
scriptLogID, err := ScriptPreservedID(hook.Script.Source)
scriptLogID, err := ScriptPreservedID(hook.Script.Name, hook.Script.Source)
if err != nil {
err = werr.Wrap(err)
return
@ -260,7 +260,7 @@ func ScheduleHook(hookID int) (err error) { // {{{
return
} // }}}
func ScriptPreservedID(source string) (id int, err error) { // {{{
func ScriptPreservedID(name, source string) (id int, err error) { // {{{
sum := md5.Sum([]byte(source))
md5sum := hex.EncodeToString(sum[:])
@ -274,7 +274,7 @@ func ScriptPreservedID(source string) (id int, err error) { // {{{
return
}
row = db.QueryRow(`INSERT INTO script_log(md5sum, source) VALUES($1, $2) RETURNING id`, md5sum, source)
row = db.QueryRow(`INSERT INTO script_log(md5sum, name, source) VALUES($1, $2, $3) RETURNING id`, md5sum, name, source)
err = row.Scan(&id)
if err != nil {
err = werr.Wrap(err)

View file

@ -3,6 +3,7 @@ package main
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
"github.com/jmoiron/sqlx"
// Standard
"bytes"
@ -25,15 +26,30 @@ type ScriptExecution struct {
ID int
TimeStart sql.NullTime `db:"time_start"`
TimeEnd sql.NullTime `db:"time_end"`
Source []byte
Data []byte
ScriptName string `db:"script_name"`
Source string
Data string
SSH string
Env []byte
Env string
OutputStdout sql.NullString `db:"output_stdout"`
OutputStderr sql.NullString `db:"output_stderr"`
ExitCode sql.NullInt16
}
type ScriptExecutionBrief struct {
ID int
TimeStart sql.NullTime `db:"time_start"`
TimeEnd sql.NullTime `db:"time_end"`
ScriptName string `db:"script_name"`
SSH string
ExitCode sql.NullInt16
HasSource bool `db:"has_source"`
HasData bool `db:"has_data"`
HasEnv bool `db:"has_env"`
HasOutputStdout bool `db:"has_output_stdout"`
HasOutputStderr bool `db:"has_output_stderr"`
}
func NewScriptScheduler() (sched ScriptScheduler) {
sched.EventQueue = make(chan string, 64)
return
@ -200,7 +216,7 @@ func (se *ScriptExecution) SSHCommand(stdin []byte, log bool, args ...string) (s
func (se *ScriptExecution) UploadScript() (fnames []string, err error) { // {{{
var filenames string
filenames, err = se.SSHCommand(
se.Source,
[]byte(se.Source),
true,
`sh -c 'RUNENV=$(mktemp -t datagraph.XXXXXX) && SCRIPT=$(mktemp -t datagraph.XXXXXX) && touch $RUNENV $SCRIPT && chmod 700 $RUNENV $SCRIPT && cat >$SCRIPT && echo $RUNENV $SCRIPT'`,
)
@ -219,7 +235,7 @@ func (se *ScriptExecution) UploadScript() (fnames []string, err error) { // {{{
} // }}}
func (se *ScriptExecution) UploadEnv(envFname, scriptFname string) (err error) { // {{{
env := make(map[string]string)
err = json.Unmarshal(se.Env, &env)
err = json.Unmarshal([]byte(se.Env), &env)
if err != nil {
err = werr.Wrap(err)
return
@ -243,9 +259,79 @@ func (se *ScriptExecution) UploadEnv(envFname, scriptFname string) (err error) {
return
} // }}}
func (se *ScriptExecution) RunScript(fname string) (err error) { // {{{
_, err = se.SSHCommand(se.Data, true, fname)
_, err = se.SSHCommand([]byte(se.Data), true, fname)
if err != nil {
err = werr.Wrap(err)
}
return
} // }}}
func GetScriptExecutions() (executions []ScriptExecutionBrief, err error) { // {{{
executions = []ScriptExecutionBrief{}
var rows *sqlx.Rows
rows, err = db.Queryx(`
SELECT
e.id,
time_start,
time_end,
ssh,
sl.name AS script_name,
exitcode,
LENGTH(source) > 0 AS has_source,
LENGTH(data::varchar) > 0 AS has_data,
LENGTH(env::varchar) > 0 AS has_env,
LENGTH(output_stdout) > 0 AS has_output_stdout,
LENGTH(output_stderr) > 0 AS has_output_stderr
FROM execution e
INNER JOIN script_log sl ON e.script_log_id = sl.id
ORDER BY
id DESC
LIMIT 100
`)
if err != nil {
err = werr.Wrap(err)
return
}
defer rows.Close()
for rows.Next() {
var execution ScriptExecutionBrief
err = rows.StructScan(&execution)
if err != nil {
err = werr.Wrap(err)
return
}
executions = append(executions, execution)
}
return
} // }}}
func GetScriptExecution(id int) (e ScriptExecution, err error) {
row := db.QueryRowx(`
SELECT
e.id,
time_start,
time_end,
ssh,
sl.name AS script_name,
sl.source,
exitcode,
data,
env,
output_stdout,
output_stderr
FROM execution e
INNER JOIN script_log sl ON e.script_log_id = sl.id
WHERE
e.id = $1`,
id,
)
err = row.StructScan(&e)
if err != nil {
err = werr.Wrap(err)
return
}
return
}

1
sql/0017.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE public.script_log ADD "name" varchar DEFAULT '' NOT NULL;

View file

@ -54,7 +54,7 @@ button {
#menu {
grid-area: menu;
grid-template-columns: repeat(100, min-content);
grid-gap: 16px;
grid-gap: 24px;
align-items: center;
}
#menu.page {
@ -64,6 +64,8 @@ button {
#menu .item {
font-size: 1.1em;
cursor: pointer;
white-space: nowrap;
user-select: none;
}
#menu .item.selected {
font-weight: bold;
@ -548,3 +550,88 @@ dialog#connection-data div.button {
width: 50vw;
font-family: monospace;
}
#script-executions > .label {
font-weight: bold;
font-size: 1.5em;
color: var(--section-color);
white-space: nowrap;
}
#script-executions .executions {
display: grid;
grid-template-columns: repeat(11, min-content);
grid-gap: 4px 32px;
margin-top: 32px;
align-items: center;
}
#script-executions .executions .header {
font-weight: bold;
}
#script-executions .executions div {
white-space: nowrap;
}
#script-executions .executions img {
display: block;
height: 24px;
}
#script-executions .executions .time {
font-size: 0.9em;
color: #555;
}
#script-executions .executions .source img,
#script-executions .executions .data img,
#script-executions .executions .env img,
#script-executions .executions .stdout img,
#script-executions .executions .stderr img {
cursor: pointer;
}
#script-executions .executions .exitcode {
text-align: right;
}
#script-executions .executions .exitcode div {
padding: 2px 8px;
width: 50px;
border-radius: 4px;
color: #fff;
font-weight: bold;
text-align: center;
}
#script-executions .executions .exitcode div.code-ok {
background-color: #6f9753;
}
#script-executions .executions .exitcode div.code-error {
background-color: #a12f2f;
}
#script-execution-value-dialog {
display: grid;
}
#script-execution-value-dialog .top {
display: grid;
grid-template-columns: 1fr min-content;
}
#script-execution-value-dialog .top .header {
font-size: 1.25em;
font-weight: bold;
color: var(--section-color);
}
#script-execution-value-dialog .top .copy {
height: 32px;
cursor: pointer;
}
#script-execution-value-dialog .top .copy.clicked {
filter: invert(50%);
}
#script-execution-value-dialog .label {
font-weight: bold;
}
#script-execution-value-dialog .value {
margin-top: 16px;
padding: 16px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
height: 75vh;
width: 80vw;
white-space: pre;
overflow: scroll;
}

View file

@ -10,6 +10,7 @@ export class App {
this.typesList = null
this.scriptsList = null
this.scriptEditor = null
this.scriptExecutionList = null
this.currentNode = null
this.currentNodeID = null
@ -21,6 +22,7 @@ export class App {
const events = [
'EDITOR_NODE_SAVE',
'EXECUTIONS_LIST_FETCHED',
'MENU_ITEM_SELECTED',
'NODE_CONNECT',
'NODE_COPY_PATH',
@ -102,6 +104,11 @@ export class App {
this.nodeUpdate()
break
case 'EXECUTIONS_LIST_FETCHED':
const executions = document.getElementById('script-executions')
executions.replaceChildren(this.scriptExecutionList.render())
break
case 'TYPES_LIST_FETCHED':
const types = document.getElementById('types')
types.replaceChildren(this.typesList.render())
@ -258,6 +265,18 @@ export class App {
this.scriptsList.fetchScripts()
.catch(err => showError(err))
break
case 'script-execution':
document.getElementById('script-executions').classList.add('show')
if (this.scriptExecutionList === null)
this.scriptExecutionList = new ScriptExecutionList()
this.scriptExecutionList.fetchExecutions()
.then(() => {
mbus.dispatch('EXECUTIONS_LIST_FETCHED')
})
.catch(err => showError(err))
break
}
}// }}}
@ -1532,4 +1551,164 @@ class ScriptHookDialog extends Component {
}// }}}
}
class ScriptExecutionList extends Component {
constructor() {// {{{
super()
this.executions = []
}// }}}
async fetchExecutions() {// {{{
return new Promise((resolve, reject) => {
window._app.query('/scriptexecutions/')
.then(data => {
this.executions = data.ScriptExecutions
resolve()
})
.catch(err => reject(err))
})
}// }}}
renderComponent() {// {{{
const tmpl = document.createElement('template')
tmpl.innerHTML = `
<div class="label">Script executions</div>
<div class="executions">
<div class="header">ID</div>
<div class="header">SSH</div>
<div class="header">Script</div>
<div class="header">Start</div>
<div class="header">End</div>
<div class="header">Script</div>
<div class="header">Data</div>
<div class="header">Env</div>
<div class="header">Out</div>
<div class="header">Err</div>
<div class="header">Exitcode</div>
</div>
`
const executions = tmpl.content.querySelector('.executions')
for (const e of this.executions) {
const se = new ScriptExecution(e)
executions.append(se.render())
}
return tmpl.content
}// }}}
}
class ScriptExecution extends Component {
constructor(execution) {// {{{
super()
this.execution = execution
}// }}}
formatTime(t) {// {{{
const d = new Date(t)
const year = d.getYear() + 1900
const month = `0${d.getMonth() + 1}`.slice(-2)
const date = `0${d.getDate()}`.slice(-2)
const hour = `0${d.getHours()}`.slice(-2)
const min = `0${d.getMinutes()}`.slice(-2)
const sec = `0${d.getSeconds()}`.slice(-2)
return `<span class="date">${year}-${month}-${date}</span> <span class="time">${hour}:${min}:${sec}</span>`
}// }}}
icon(name) {// {{{
return `<img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/${name}.svg" />`
}// }}}
renderComponent() {// {{{
const tmpl = document.createElement('template')
tmpl.innerHTML = `
<div class="id">${this.execution.ID}</div>
<div class="ssh">${this.execution.SSH}</div>
<div class="name">${this.execution.ScriptName}</div>
<div class="start">${this.formatTime(this.execution.TimeStart.Time)}</div>
<div class="end">${this.formatTime(this.execution.TimeEnd.Time)}</div>
<div class="source">${this.execution.HasSource ? this.icon('bash') : ''}</div>
<div class="data">${this.execution.HasData ? this.icon('code-json') : ''}</div>
<div class="env">${this.execution.HasEnv ? this.icon('application-braces-outline') : ''}</div>
<div class="stdout">${this.execution.HasOutputStdout ? this.icon('text-box-check-outline') : ''}</div>
<div class="stderr">${this.execution.HasOutputStderr ? this.icon('text-box-remove-outline') : ''}</div>
<div class="exitcode"><div class="code-${this.execution.ExitCode.Int16 == 0 ? 'ok' : 'error'}">${this.execution.ExitCode.Int16}</div></div>
`
const classValues = new Map()
classValues.set('source', 'Source')
classValues.set('data', 'Data')
classValues.set('env', 'Env')
classValues.set('stdout', 'OutputStdout')
classValues.set('stderr', 'OutputStderr')
for (const [cls, value] of classValues) {
tmpl.content.querySelector(`.${cls}`).addEventListener('click', () => {
new ScriptExecutionValueDialog(this.execution, value).render()
})
}
return tmpl.content
}// }}}
}
class ScriptExecutionValueDialog extends Component {
constructor(execution, valueName) {// {{{
super()
this.execution = execution
this.valueName = valueName
this.dlg = document.createElement('dialog')
this.dlg.id = 'script-execution-value-dialog'
this.dlg.addEventListener('close', () => this.dlg.remove())
this.value = null
}// }}}
getValue(execution) {
switch (this.valueName) {
case 'Source':
return execution.Source
case 'Data':
case 'Env':
return JSON.stringify(
JSON.parse(execution[this.valueName])
,
null,
' '
)
case 'OutputStdout':
case 'OutputStderr':
return execution[this.valueName]?.String
}
}
renderComponent() {// {{{
const div = document.createElement('div')
div.innerHTML = `
<div class="top">
<div class="header">${this.valueName}</div>
<img class="copy" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/content-copy.svg" />
</div>
<div class="label">${this.execution.ID}</div>
<div class="value"></div>
`
this.value = div.querySelector('.value')
div.querySelector('.copy').addEventListener('click', event=>{
event.target.classList.add('clicked')
setTimeout(()=>event.target.classList.remove('clicked'), 250)
navigator.clipboard.writeText(this.value.innerText)
})
window._app.query(`/scriptexecutions/${this.execution.ID}`)
.then(data => {
this.value.innerText = this.getValue(data.ScriptExecution)
})
.catch(err => showError(err))
this.dlg.append(...div.children)
document.body.append(this.dlg)
this.dlg.showModal()
return []
}// }}}
}
// vim: foldmethod=marker

View file

@ -70,7 +70,7 @@ button {
#menu {
grid-area: menu;
grid-template-columns: repeat(100, min-content);
grid-gap: 16px;
grid-gap: 24px;
align-items: center;
&.page {
@ -81,6 +81,8 @@ button {
.item {
font-size: 1.1em;
cursor: pointer;
white-space: nowrap;
user-select: none;
&.selected {
font-weight: bold;
@ -700,3 +702,105 @@ dialog#connection-data {
font-family: monospace;
}
}
#script-executions {
& > .label {
font-weight: bold;
font-size: 1.5em;
color: var(--section-color);
white-space: nowrap;
}
.executions {
display: grid;
grid-template-columns: repeat(11, min-content);
grid-gap: 4px 32px;
margin-top: 32px;
align-items: center;
.header {
font-weight: bold;
}
div {
white-space: nowrap;
}
img {
display: block;
height: 24px;
}
.time {
font-size: 0.9em;
color: #555;
}
.source, .data, .env, .stdout, .stderr {
img {
cursor: pointer;
}
}
.exitcode {
text-align: right;
div {
padding: 2px 8px;
width: 50px;
border-radius: 4px;
color: #fff;
font-weight: bold;
text-align: center;
&.code-ok {
background-color: #6f9753;
}
&.code-error {
background-color: #a12f2f;
}
}
}
}
}
#script-execution-value-dialog {
display: grid;
.top {
display: grid;
grid-template-columns: 1fr min-content;
.header {
font-size: 1.25em;
font-weight: bold;
color: var(--section-color);
}
.copy {
height: 32px;
cursor: pointer;
&.clicked {
filter: invert(50%);
}
}
}
.label {
font-weight: bold;
}
.value {
margin-top: 16px;
padding: 16px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
height: 75vh;
width: 80vw;
white-space: pre;
overflow: scroll;
}
}

View file

@ -22,9 +22,10 @@
<div id="layout">
<div class="page section" id="menu">
<div class="item" id="logo"><img src="/images/{{ .VERSION }}/logo.svg" /></div>
<div class="item" data-section='node' onclick="mbus.dispatch('MENU_ITEM_SELECTED', 'node')">Nodes</div>
<div class="item" data-section='type' onclick="mbus.dispatch('MENU_ITEM_SELECTED', 'type')">Types</div>
<div class="item" data-section='script' onclick="mbus.dispatch('MENU_ITEM_SELECTED', 'script')">Scripts</div>
<div class="item" data-section='node' onclick="mbus.dispatch('MENU_ITEM_SELECTED', 'node')">Nodes</div>
<div class="item" data-section='type' onclick="mbus.dispatch('MENU_ITEM_SELECTED', 'type')">Types</div>
<div class="item" data-section='script' onclick="mbus.dispatch('MENU_ITEM_SELECTED', 'script')">Scripts</div>
<div class="item" data-section='script-execution' onclick="mbus.dispatch('MENU_ITEM_SELECTED', 'script-execution')">Script executions</div>
</div>
<div class="page section" id="nodes"></div>
@ -68,6 +69,8 @@
<div class="page section" id="scripts"></div>
<div class="page section" id="editor-script"></div>
<div class="page section" id="script-executions"></div>
</div>
{{ end }}

View file

@ -55,6 +55,8 @@ func initWebserver() (err error) {
http.HandleFunc("/hooks/update", actionHookUpdate)
http.HandleFunc("/hooks/delete/{hookID}", actionHookDelete)
http.HandleFunc("/hooks/schedule/{hookID}", actionHookSchedule)
http.HandleFunc("/scriptexecutions/", actionScriptExecutions)
http.HandleFunc("/scriptexecutions/{executionID}", actionScriptExecutionGet)
err = http.ListenAndServe(address, nil)
return
@ -752,4 +754,45 @@ func actionHookSchedule(w http.ResponseWriter, r *http.Request) { // {{{
w.Write(j)
} // }}}
func actionScriptExecutions(w http.ResponseWriter, r *http.Request) { // {{{
execs, err := GetScriptExecutions()
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
out := struct {
OK bool
ScriptExecutions []ScriptExecutionBrief
}{
true,
execs,
}
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
func actionScriptExecutionGet(w http.ResponseWriter, r *http.Request) { // {{{
executionID := 0
executionIDStr := r.PathValue("executionID")
executionID, _ = strconv.Atoi(executionIDStr)
execution, err := GetScriptExecution(executionID)
if err != nil {
err = werr.Wrap(err)
httpError(w, err)
return
}
out := struct {
OK bool
ScriptExecution ScriptExecution
}{
true,
execution,
}
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
// vim: foldmethod=marker