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 = ` +