diff --git a/script.go b/script.go index 237f23a..f93b199 100644 --- a/script.go +++ b/script.go @@ -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) diff --git a/script_scheduler.go b/script_scheduler.go index 96fc913..9284093 100644 --- a/script_scheduler.go +++ b/script_scheduler.go @@ -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 +} diff --git a/sql/0017.sql b/sql/0017.sql new file mode 100644 index 0000000..35fd9f7 --- /dev/null +++ b/sql/0017.sql @@ -0,0 +1 @@ +ALTER TABLE public.script_log ADD "name" varchar DEFAULT '' NOT NULL; diff --git a/static/css/main.css b/static/css/main.css index 31d6281..9c94767 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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; +} diff --git a/static/js/app.mjs b/static/js/app.mjs index 994c592..b449705 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -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 = ` +
Script executions
+
+
ID
+
SSH
+
Script
+
Start
+
End
+
Script
+
Data
+
Env
+
Out
+
Err
+
Exitcode
+
+ ` + 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 `${year}-${month}-${date} ${hour}:${min}:${sec}` + }// }}} + icon(name) {// {{{ + return `` + }// }}} + renderComponent() {// {{{ + const tmpl = document.createElement('template') + + tmpl.innerHTML = ` +
${this.execution.ID}
+
${this.execution.SSH}
+
${this.execution.ScriptName}
+
${this.formatTime(this.execution.TimeStart.Time)}
+
${this.formatTime(this.execution.TimeEnd.Time)}
+
${this.execution.HasSource ? this.icon('bash') : ''}
+
${this.execution.HasData ? this.icon('code-json') : ''}
+
${this.execution.HasEnv ? this.icon('application-braces-outline') : ''}
+
${this.execution.HasOutputStdout ? this.icon('text-box-check-outline') : ''}
+
${this.execution.HasOutputStderr ? this.icon('text-box-remove-outline') : ''}
+
${this.execution.ExitCode.Int16}
+ ` + + 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 = ` +
+
${this.valueName}
+ +
+
${this.execution.ID}
+
+ ` + + 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 diff --git a/static/less/main.less b/static/less/main.less index fdf99b6..fc23af2 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -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; + } +} diff --git a/views/pages/app.gotmpl b/views/pages/app.gotmpl index 93b85ee..5909db8 100644 --- a/views/pages/app.gotmpl +++ b/views/pages/app.gotmpl @@ -22,9 +22,10 @@
@@ -68,6 +69,8 @@
+ +
{{ end }} diff --git a/webserver.go b/webserver.go index 738122e..2cded8b 100644 --- a/webserver.go +++ b/webserver.go @@ -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