diff --git a/.gitignore b/.gitignore index 32883c0..a5f7f5d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ reports releases/ dist/ installation-id.txt +tmp/ diff --git a/OliveTin.proto b/OliveTin.proto index 3ae3e4e..f320ebe 100644 --- a/OliveTin.proto +++ b/OliveTin.proto @@ -76,6 +76,9 @@ message LogEntry { string execution_uuid = 11; string datetime_finished = 12; string uuid = 13; + bool execution_started = 14; + bool execution_finished = 15; + bool blocked = 16; } message GetLogsResponse { diff --git a/config.yaml b/config.yaml index 4a03017..44a7c2b 100644 --- a/config.yaml +++ b/config.yaml @@ -13,13 +13,18 @@ actions: # This will run a simple script that you create. - title: Run backup script shell: /opt/backupScript.sh + maxConcurrent: 1 icon: backup +- title: date + shell: date + # This will send 1 ping (-c 1) # Docs: https://docs.olivetin.app/action-ping.html - title: Ping host shell: ping {{ host }} -c {{ count }} icon: ping + timeout: 100 arguments: - name: host title: host diff --git a/internal/config/config.go b/internal/config/config.go index c70e0ad..fdb6e98 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ type Action struct { Acls []string ExecOnStartup bool ExecOnCron []string + MaxConcurrent int Arguments []ActionArgument PopupOnStart bool } diff --git a/internal/config/sanitize.go b/internal/config/sanitize.go index 7f815de..8e127ad 100644 --- a/internal/config/sanitize.go +++ b/internal/config/sanitize.go @@ -30,6 +30,10 @@ func (action *Action) sanitize() { action.Icon = lookupHTMLIcon(action.Icon) + if action.MaxConcurrent < 1 { + action.MaxConcurrent = 1 + } + for idx := range action.Arguments { action.Arguments[idx].sanitize() } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 8277516..ae6bbab 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -8,6 +8,7 @@ import ( "bytes" "context" + "fmt" "io" "os/exec" "runtime" @@ -43,17 +44,18 @@ type ExecutionRequest struct { // state of execution (even if the command is not executed). It's designed to be // easily serializable. type InternalLogEntry struct { - DatetimeStarted string - DatetimeFinished string - Stdout string - Stderr string - StdoutBuffer io.ReadCloser - StderrBuffer io.ReadCloser - TimedOut bool - ExitCode int32 - Tags []string - ExecutionStarted bool - ExecutionCompleted bool + DatetimeStarted string + DatetimeFinished string + Stdout string + Stderr string + StdoutBuffer io.ReadCloser + StderrBuffer io.ReadCloser + TimedOut bool + Blocked bool + ExitCode int32 + Tags []string + ExecutionStarted bool + ExecutionFinished bool /* The following 3 properties are obviously on Action normally, but it's useful @@ -76,11 +78,11 @@ func DefaultExecutor() *Executor { e.chainOfCommand = []executorStepFunc{ stepLogRequested, stepFindAction, + stepConcurrencyCheck, stepACLCheck, stepParseArgs, stepLogStart, stepExec, - stepNotifyListeners, stepLogFinish, } @@ -103,14 +105,14 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) *pb.StartActionResponse { // duplicate UUIDs (or just random strings), but this is the only way. req.executor = e req.logEntry = &InternalLogEntry{ - DatetimeStarted: time.Now().Format("2006-01-02 15:04:05"), - ActionTitle: req.ActionName, - UUID: req.UUID, - Stdout: "", - Stderr: "", - ExitCode: -1337, // If an Action is not actually executed, this is the default exit code. - ExecutionStarted: false, - ExecutionCompleted: false, + DatetimeStarted: time.Now().Format("2006-01-02 15:04:05"), + ActionTitle: req.ActionName, + UUID: req.UUID, + Stdout: "", + Stderr: "", + ExitCode: -1337, // If an Action is not actually executed, this is the default exit code. + ExecutionStarted: false, + ExecutionFinished: false, } e.Logs[req.UUID] = req.logEntry @@ -132,6 +134,41 @@ func (e *Executor) execChain(req *ExecutionRequest) { break } } + + req.logEntry.ExecutionFinished = true + + // This isn't a step, because we want to notify all listeners, irrespective + // of how many steps were actually executed. + notifyListeners(req) +} + +func getConcurrentCount(req *ExecutionRequest) int { + concurrentCount := 0 + + for _, log := range req.executor.Logs { + if log.ActionTitle == req.ActionName && !log.ExecutionFinished { + concurrentCount += 1 + } + } + + return concurrentCount +} + +func stepConcurrencyCheck(req *ExecutionRequest) bool { + concurrentCount := getConcurrentCount(req) + + // Note that the current execution is counted int the logs, so when checking we +1 + if concurrentCount >= (req.action.MaxConcurrent + 1) { + msg := fmt.Sprintf("Blocked from executing. This would mean this action is running %d times concurrently, but this action has maxExecutions set to %d.", concurrentCount, req.action.MaxConcurrent) + + log.Warnf(msg) + + req.logEntry.Stdout = msg + req.logEntry.Blocked = true + return false + } + + return true } func stepFindAction(req *ExecutionRequest) bool { @@ -202,12 +239,10 @@ func stepLogFinish(req *ExecutionRequest) bool { return true } -func stepNotifyListeners(req *ExecutionRequest) bool { +func notifyListeners(req *ExecutionRequest) { for _, listener := range req.executor.listeners { listener.OnExecutionFinished(req.logEntry) } - - return true } func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cmd { @@ -240,7 +275,6 @@ func stepExec(req *ExecutionRequest) bool { // req.logEntry.Stdout = req.logEntry.StdoutBuffer.String() // req.logEntry.Stderr = req.logEntry.StderrBuffer.String() - req.logEntry.ExecutionCompleted = true req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode()) req.logEntry.Stdout = stdout.String() req.logEntry.Stderr = stderr.String() diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index 0c4dc0b..431333c 100644 --- a/internal/grpcapi/grpcApi.go +++ b/internal/grpcapi/grpcApi.go @@ -55,16 +55,19 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *pb.ExecutionStatus } res.LogEntry = &pb.LogEntry{ - ActionTitle: logEntry.ActionTitle, - ActionIcon: logEntry.ActionIcon, - DatetimeStarted: logEntry.DatetimeStarted, - DatetimeFinished: logEntry.DatetimeFinished, - Stdout: logEntry.Stdout, - Stderr: logEntry.Stderr, - TimedOut: logEntry.TimedOut, - ExitCode: logEntry.ExitCode, - Tags: logEntry.Tags, - ExecutionUuid: logEntry.UUID, + ActionTitle: logEntry.ActionTitle, + ActionIcon: logEntry.ActionIcon, + DatetimeStarted: logEntry.DatetimeStarted, + DatetimeFinished: logEntry.DatetimeFinished, + Stdout: logEntry.Stdout, + Stderr: logEntry.Stderr, + TimedOut: logEntry.TimedOut, + Blocked: logEntry.Blocked, + ExitCode: logEntry.ExitCode, + Tags: logEntry.Tags, + ExecutionUuid: logEntry.UUID, + ExecutionStarted: logEntry.ExecutionStarted, + ExecutionFinished: logEntry.ExecutionFinished, } return res, nil @@ -119,16 +122,19 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge for uuid, logEntry := range api.executor.Logs { ret.Logs = append(ret.Logs, &pb.LogEntry{ - ActionTitle: logEntry.ActionTitle, - ActionIcon: logEntry.ActionIcon, - DatetimeStarted: logEntry.DatetimeStarted, - DatetimeFinished: logEntry.DatetimeFinished, - Stdout: logEntry.Stdout, - Stderr: logEntry.Stderr, - TimedOut: logEntry.TimedOut, - ExitCode: logEntry.ExitCode, - Tags: logEntry.Tags, - ExecutionUuid: uuid, + ActionTitle: logEntry.ActionTitle, + ActionIcon: logEntry.ActionIcon, + DatetimeStarted: logEntry.DatetimeStarted, + DatetimeFinished: logEntry.DatetimeFinished, + Stdout: logEntry.Stdout, + Stderr: logEntry.Stderr, + TimedOut: logEntry.TimedOut, + Blocked: logEntry.Blocked, + ExitCode: logEntry.ExitCode, + Tags: logEntry.Tags, + ExecutionUuid: uuid, + ExecutionStarted: logEntry.ExecutionStarted, + ExecutionFinished: logEntry.ExecutionFinished, }) } @@ -136,7 +142,7 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge return ret.Logs[i].DatetimeStarted < ret.Logs[j].DatetimeStarted } - sort.Slice(ret.Logs, sorter); + sort.Slice(ret.Logs, sorter) return ret, nil } diff --git a/internal/websocket/websocket.go b/internal/websocket/websocket.go index 23b0061..8a61287 100644 --- a/internal/websocket/websocket.go +++ b/internal/websocket/websocket.go @@ -37,16 +37,19 @@ func (WebsocketExecutionListener) OnExecutionStarted(title string) { func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.InternalLogEntry) { le := &pb.LogEntry{ - ActionTitle: logEntry.ActionTitle, - ActionIcon: logEntry.ActionIcon, - DatetimeStarted: logEntry.DatetimeStarted, - DatetimeFinished: logEntry.DatetimeFinished, - Stdout: logEntry.Stdout, - Stderr: logEntry.Stderr, - TimedOut: logEntry.TimedOut, - ExitCode: logEntry.ExitCode, - Tags: logEntry.Tags, - Uuid: logEntry.UUID, + ActionTitle: logEntry.ActionTitle, + ActionIcon: logEntry.ActionIcon, + DatetimeStarted: logEntry.DatetimeStarted, + DatetimeFinished: logEntry.DatetimeFinished, + Stdout: logEntry.Stdout, + Stderr: logEntry.Stderr, + TimedOut: logEntry.TimedOut, + Blocked: logEntry.Blocked, + ExitCode: logEntry.ExitCode, + Tags: logEntry.Tags, + Uuid: logEntry.UUID, + ExecutionStarted: logEntry.ExecutionStarted, + ExecutionFinished: logEntry.ExecutionFinished, } broadcast("ExecutionFinished", le) diff --git a/webui/index.html b/webui/index.html index b00beab..7eccdad 100644 --- a/webui/index.html +++ b/webui/index.html @@ -83,12 +83,16 @@

- Started: unknown + Started: unknown. Finished: unknown

Exit Code: unknown

+

+ Status: unknown +

+
stdout diff --git a/webui/js/ExecutionButton.js b/webui/js/ExecutionButton.js index 1b35b31..a05aa3f 100644 --- a/webui/js/ExecutionButton.js +++ b/webui/js/ExecutionButton.js @@ -51,8 +51,8 @@ class ExecutionButton extends window.HTMLElement { onFinished (LogEntry) { if (LogEntry.timedOut) { this.onActionResult('action-timeout', 'Timed out') - } else if (LogEntry.exitCode === -1337) { - this.onActionError('Error') + } else if (LogEntry.blocked) { + this.onActionResult('action-blocked', 'Blocked!') } else if (LogEntry.exitCode !== 0) { this.onActionResult('action-nonzero-exit', 'Exit code ' + LogEntry.exitCode) } else { @@ -61,7 +61,6 @@ class ExecutionButton extends window.HTMLElement { } onActionResult (cssClass, temporaryStatusMessage) { - this.btn.disabled = false this.temporaryStatusMessage = '[ ' + temporaryStatusMessage + ' ]' this.updateDom() this.btn.classList.add(cssClass) diff --git a/webui/js/ExecutionDialog.js b/webui/js/ExecutionDialog.js index 18ed0e1..2e6399a 100644 --- a/webui/js/ExecutionDialog.js +++ b/webui/js/ExecutionDialog.js @@ -14,6 +14,7 @@ export class ExecutionDialog { this.domDatetimeStarted = this.dlg.querySelector('.datetimeStarted') this.domDatetimeFinished = this.dlg.querySelector('.datetimeFinished') this.domExitCode = this.dlg.querySelector('.exitCode') + this.domStatus = this.dlg.querySelector('.status') } show () { @@ -23,17 +24,24 @@ export class ExecutionDialog { renderResult (res) { this.executionUuid = res.logEntry.executionUuid - if (res.logEntry.datetimeFinished === '') { - this.domExitCode.innerText = 'Still running...' - this.domDatetimeFinished.innerText = 'Still running...' - } else { + if (res.logEntry.executionFinished) { + this.domStatus.innerText = 'Completed' + this.domDatetimeFinished.innerText = res.logEntry.datetimeFinished + + if (res.logEntry.blocked) { + this.domStatus.innerText = 'Blocked' + } + if (res.logEntry.timedOut) { this.domExitCode.innerText = 'Timed out' + this.domStatus.innerText = 'Timed out' } else { this.domExitCode.innerText = res.logEntry.exitCode } - - this.domDatetimeFinished.innerText = res.logEntry.datetimeFinished + } else { + this.domDatetimeFinished.innerText = 'Still running...' + this.domExitCode.innerText = 'Still running...' + this.domStatus.innerText = 'Still running...' } this.domIcon.innerHTML = res.logEntry.actionIcon diff --git a/webui/style.css b/webui/style.css index 6ebd8dc..efbb71c 100644 --- a/webui/style.css +++ b/webui/style.css @@ -270,6 +270,14 @@ button.active-section { 20% { background-color: cyan; } } +.action-blocked { + animation: kf-action-blocked 1s; +} + +@keyframes kf-action-blocked { + 20% { background-color: purple; } +} + footer, footer a { color: black;