feature: Live command output! (#325)

* feature: Live command output WIP

* cicd: Fix live command output build errors

* feature: Live command output WIP, remove stdout/stderr split

* feature: Live command output

* Restore output im stepExecAfter, rename so to ost for OutputStreamer

* Update executor.go

* feature: Remove output from log message, bad for long messages, binary output, and security

* feature: Live command output WIP
This commit is contained in:
James Read 2024-05-31 21:56:49 +01:00 committed by GitHub
parent 4bac315568
commit ac0f3ab6f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 184 additions and 121 deletions

View File

@ -102,8 +102,7 @@ message GetLogsRequest{};
message LogEntry { message LogEntry {
string datetime_started = 1; string datetime_started = 1;
string action_title = 2; string action_title = 2;
string stdout = 3; string output = 3;
string stderr = 4;
bool timed_out = 5; bool timed_out = 5;
int32 exit_code = 6; int32 exit_code = 6;
string user = 7; string user = 7;
@ -185,6 +184,12 @@ message GetReadyzResponse {
string status = 1; string status = 1;
} }
message EventOutputChunk {
string execution_tracking_id = 1;
string output = 2;
}
message EventEntityChanged {} message EventEntityChanged {}
message EventConfigChanged {} message EventConfigChanged {}
message EventExecutionFinished { message EventExecutionFinished {

View File

@ -20,8 +20,9 @@ actions:
# If you are running OliveTin in a container remember to pass through the # If you are running OliveTin in a container remember to pass through the
# docker socket! https://docs.olivetin.app/action-container-control.html # docker socket! https://docs.olivetin.app/action-container-control.html
- title: Ping the Internet - title: Ping the Internet
shell: ping -c 1 1.1.1.1 shell: ping -c 3 1.1.1.1
icon: ping icon: ping
popupOnStart: execution-dialog-stdout-only
# This uses `popupOnStart: execution-dialog-stdout-only` to simply show just # This uses `popupOnStart: execution-dialog-stdout-only` to simply show just
# the command output. # the command output.
@ -65,9 +66,10 @@ actions:
shell: ping {{ host }} -c {{ count }} shell: ping {{ host }} -c {{ count }}
icon: ping icon: ping
timeout: 100 timeout: 100
popupOnStart: execution-dialog-stdout-only
arguments: arguments:
- name: host - name: host
title: host title: Host
type: ascii_identifier type: ascii_identifier
default: example.com default: example.com
description: The host that you want to ping description: The host that you want to ping
@ -75,7 +77,7 @@ actions:
- name: count - name: count
title: Count title: Count
type: int type: int
default: 1 default: 3
description: How many times to do you want to ping? description: How many times to do you want to ping?
# OliveTin can control containers - docker is just a command line app. # OliveTin can control containers - docker is just a command line app.

View File

@ -14,7 +14,6 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path" "path"
@ -76,10 +75,7 @@ type ExecutionRequest struct {
type InternalLogEntry struct { type InternalLogEntry struct {
DatetimeStarted time.Time DatetimeStarted time.Time
DatetimeFinished time.Time DatetimeFinished time.Time
Stdout string Output string
Stderr string
StdoutBuffer io.ReadCloser
StderrBuffer io.ReadCloser
TimedOut bool TimedOut bool
Blocked bool Blocked bool
ExitCode int32 ExitCode int32
@ -130,6 +126,7 @@ func DefaultExecutor(cfg *config.Config) *Executor {
type listener interface { type listener interface {
OnExecutionStarted(actionTitle string) OnExecutionStarted(actionTitle string)
OnExecutionFinished(logEntry *InternalLogEntry) OnExecutionFinished(logEntry *InternalLogEntry)
OnOutputChunk(o []byte, executionTrackingId string)
OnActionMapRebuilt() OnActionMapRebuilt()
} }
@ -148,8 +145,7 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
req.logEntry = &InternalLogEntry{ req.logEntry = &InternalLogEntry{
DatetimeStarted: time.Now(), DatetimeStarted: time.Now(),
ExecutionTrackingID: req.TrackingID, ExecutionTrackingID: req.TrackingID,
Stdout: "", Output: "",
Stderr: "",
ExitCode: -1337, // If an Action is not actually executed, this is the default exit code. ExitCode: -1337, // If an Action is not actually executed, this is the default exit code.
ExecutionStarted: false, ExecutionStarted: false,
ExecutionFinished: false, ExecutionFinished: false,
@ -214,7 +210,7 @@ func stepConcurrencyCheck(req *ExecutionRequest) bool {
"actionTitle": req.logEntry.ActionTitle, "actionTitle": req.logEntry.ActionTitle,
}).Warnf(msg) }).Warnf(msg)
req.logEntry.Stdout = msg req.logEntry.Output = msg
req.logEntry.Blocked = true req.logEntry.Blocked = true
return false return false
} }
@ -262,7 +258,7 @@ func stepRateCheck(req *ExecutionRequest) bool {
"actionTitle": req.logEntry.ActionTitle, "actionTitle": req.logEntry.ActionTitle,
}).Infof(msg) }).Infof(msg)
req.logEntry.Stdout = msg req.logEntry.Output = msg
req.logEntry.Blocked = true req.logEntry.Blocked = true
return false return false
} }
@ -281,7 +277,7 @@ func stepParseArgs(req *ExecutionRequest) bool {
req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action, req.logEntry.ActionTitle, req.EntityPrefix) req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
if err != nil { if err != nil {
req.logEntry.Stdout = err.Error() req.logEntry.Output = err.Error()
log.Warnf(err.Error()) log.Warnf(err.Error())
@ -305,7 +301,7 @@ func stepRequestAction(req *ExecutionRequest) bool {
"actionTitle": req.ActionTitle, "actionTitle": req.ActionTitle,
}).Warnf("Action requested, but not found") }).Warnf("Action requested, but not found")
req.logEntry.Stderr = "Action not found: " + req.ActionTitle req.logEntry.Output = "Action not found: " + req.ActionTitle
return false return false
} }
@ -344,11 +340,10 @@ func stepLogFinish(req *ExecutionRequest) bool {
req.logEntry.ExecutionFinished = true req.logEntry.ExecutionFinished = true
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"actionTitle": req.logEntry.ActionTitle, "actionTitle": req.logEntry.ActionTitle,
"stdout": req.logEntry.Stdout, "outputLength": len(req.logEntry.Output),
"stderr": req.logEntry.Stderr, "timedOut": req.logEntry.TimedOut,
"timedOut": req.logEntry.TimedOut, "exit": req.logEntry.ExitCode,
"exit": req.logEntry.ExitCode,
}).Infof("Action finished") }).Infof("Action finished")
return true return true
@ -370,10 +365,27 @@ func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cm
func appendErrorToStderr(err error, logEntry *InternalLogEntry) { func appendErrorToStderr(err error, logEntry *InternalLogEntry) {
if err != nil { if err != nil {
logEntry.Stderr = err.Error() + "\n\n" + logEntry.Stderr logEntry.Output = err.Error() + "\n\n" + logEntry.Output
} }
} }
type OutputStreamer struct {
Req *ExecutionRequest
output bytes.Buffer
}
func (ost *OutputStreamer) Write(o []byte) (n int, err error) {
for _, listener := range ost.Req.executor.listeners {
listener.OnOutputChunk(o, ost.Req.TrackingID)
}
return ost.output.Write(o)
}
func (ost *OutputStreamer) String() string {
return ost.output.String()
}
func buildEnv(req *ExecutionRequest) []string { func buildEnv(req *ExecutionRequest) []string {
ret := append(os.Environ(), "OLIVETIN=1") ret := append(os.Environ(), "OLIVETIN=1")
@ -388,15 +400,12 @@ func stepExec(req *ExecutionRequest) bool {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Action.Timeout)*time.Second) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Action.Timeout)*time.Second)
defer cancel() defer cancel()
var stdout bytes.Buffer streamer := &OutputStreamer{Req: req}
var stderr bytes.Buffer
cmd := wrapCommandInShell(ctx, req.finalParsedCommand) cmd := wrapCommandInShell(ctx, req.finalParsedCommand)
cmd.Stdout = streamer
cmd.Stderr = streamer
cmd.Env = buildEnv(req) cmd.Env = buildEnv(req)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
req.logEntry.StdoutBuffer, _ = cmd.StdoutPipe()
req.logEntry.StderrBuffer, _ = cmd.StderrPipe()
req.logEntry.ExecutionStarted = true req.logEntry.ExecutionStarted = true
@ -407,8 +416,7 @@ func stepExec(req *ExecutionRequest) bool {
waiterr := cmd.Wait() waiterr := cmd.Wait()
req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode()) req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
req.logEntry.Stdout = stdout.String() req.logEntry.Output = streamer.String()
req.logEntry.Stderr = stderr.String()
appendErrorToStderr(runerr, req.logEntry) appendErrorToStderr(runerr, req.logEntry)
appendErrorToStderr(waiterr, req.logEntry) appendErrorToStderr(waiterr, req.logEntry)
@ -435,7 +443,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
var stderr bytes.Buffer var stderr bytes.Buffer
args := map[string]string{ args := map[string]string{
"stdout": req.logEntry.Stdout, "output": req.logEntry.Output,
"exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode), "exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode),
} }
@ -449,17 +457,17 @@ func stepExecAfter(req *ExecutionRequest) bool {
waiterr := cmd.Wait() waiterr := cmd.Wait()
req.logEntry.Stdout += "---\n" + stdout.String() req.logEntry.Output += "---\n" + stdout.String()
req.logEntry.Stderr += "---\n" + stderr.String() req.logEntry.Output += "---\n" + stderr.String()
appendErrorToStderr(runerr, req.logEntry) appendErrorToStderr(runerr, req.logEntry)
appendErrorToStderr(waiterr, req.logEntry) appendErrorToStderr(waiterr, req.logEntry)
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
req.logEntry.Stderr += "Your shellAfterCommand command timed out." req.logEntry.Output += "Your shellAfterCommand command timed out."
} }
req.logEntry.Stdout += fmt.Sprintf("Your shellAfterCommand exited with code %v", cmd.ProcessState.ExitCode()) req.logEntry.Output += fmt.Sprintf("Your shellAfterCommand exited with code %v", cmd.ProcessState.ExitCode())
return true return true
} }
@ -520,7 +528,7 @@ func saveLogOutput(req *ExecutionRequest, filename string) {
dir := firstNonEmpty(req.Action.SaveLogs.OutputDirectory, req.Cfg.SaveLogs.OutputDirectory) dir := firstNonEmpty(req.Action.SaveLogs.OutputDirectory, req.Cfg.SaveLogs.OutputDirectory)
if dir != "" { if dir != "" {
data := req.logEntry.Stdout + "\n" + req.logEntry.Stderr data := req.logEntry.Output
filepath := path.Join(dir, filename+".log") filepath := path.Join(dir, filename+".log")
err := os.WriteFile(filepath, []byte(data), 0644) err := os.WriteFile(filepath, []byte(data), 0644)

View File

@ -154,8 +154,7 @@ func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *pb.LogEntry {
ActionId: logEntry.ActionId, ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"), DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"), DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
Stdout: logEntry.Stdout, Output: logEntry.Output,
Stderr: logEntry.Stderr,
TimedOut: logEntry.TimedOut, TimedOut: logEntry.TimedOut,
Blocked: logEntry.Blocked, Blocked: logEntry.Blocked,
ExitCode: logEntry.ExitCode, ExitCode: logEntry.ExitCode,

View File

@ -68,6 +68,17 @@ func checkOriginPermissive(r *http.Request) bool {
return true return true
} }
func (WebsocketExecutionListener) OnOutputChunk(chunk []byte, executionTrackingId string) {
log.Tracef("outputchunk: %s", string(chunk))
oc := &pb.EventOutputChunk{
Output: string(chunk),
ExecutionTrackingId: executionTrackingId,
}
broadcast(oc)
}
func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.InternalLogEntry) { func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.InternalLogEntry) {
evt := &pb.EventExecutionFinished{ evt := &pb.EventExecutionFinished{
LogEntry: &pb.LogEntry{ LogEntry: &pb.LogEntry{
@ -76,8 +87,7 @@ func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.Interna
ActionId: logEntry.ActionId, ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"), DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"), DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
Stdout: logEntry.Stdout, Output: logEntry.Output,
Stderr: logEntry.Stderr,
TimedOut: logEntry.TimedOut, TimedOut: logEntry.TimedOut,
Blocked: logEntry.Blocked, Blocked: logEntry.Blocked,
ExitCode: logEntry.ExitCode, ExitCode: logEntry.ExitCode,

View File

@ -2,13 +2,16 @@
<html lang = "en"> <html lang = "en">
<head> <head>
<meta name = "viewport" content = "width=device-width, initial-scale=1.0"> <meta charset = "UTF-8" />
<meta name = "viewport" content = "width=device-width, initial-scale=1.0" />
<meta name = "description" content = "Give safe and simple access to predefined shell commands from a web interface." /> <meta name = "description" content = "Give safe and simple access to predefined shell commands from a web interface." />
<title>OliveTin</title> <title>OliveTin</title>
<link rel = "stylesheet" type = "text/css" href = "style.css" /> <link rel = "stylesheet" type = "text/css" href = "style.css" />
<link rel = "stylesheet" type = "text/css" href = "theme.css" /> <link rel = "stylesheet" type = "text/css" href = "theme.css" />
<link rel = "stylesheet" href = "node_modules/@xterm/xterm/css/xterm.css" />
<link rel = "shortcut icon" type = "image/png" href = "OliveTinLogo.png" /> <link rel = "shortcut icon" type = "image/png" href = "OliveTinLogo.png" />
<link rel = "apple-touch-icon" sizes="57x57" href="OliveTinLogo-57px.png" /> <link rel = "apple-touch-icon" sizes="57x57" href="OliveTinLogo-57px.png" />
@ -84,12 +87,12 @@
</p> </p>
</footer> </footer>
<dialog title = "Big Error Message" id = "big-error" class = "error"> <dialog title = "Big Error Message" id = "big-error" class = "error padded-content">
</dialog> </dialog>
<dialog title = "Execution Results" id = "execution-results-popup"> <dialog title = "Execution Results" id = "execution-results-popup">
<div class = "action-header"> <div class = "action-header padded-content">
<span id = "execution-dialog-icon" class = "icon" role = "img"></span> <span id = "execution-dialog-icon" class = "icon" role = "img"></span>
<h2> <h2>
@ -98,10 +101,10 @@
<button id = "execution-dialog-toggle-size" title = "Toggle dialog size">&#128470;</button> <button id = "execution-dialog-toggle-size" title = "Toggle dialog size">&#128470;</button>
</div> </div>
<div id = "execution-dialog-basics"> <div id = "execution-dialog-basics" class = "padded-content">
<strong>Started: </strong><span id = "execution-dialog-datetime-started">unknown</span> <strong>Started: </strong><span id = "execution-dialog-datetime-started">unknown</span>
</div> </div>
<div id = "execution-dialog-details"> <div id = "execution-dialog-details" class = "padded-content">
<p> <p>
<strong>Finished: </strong><span id = "execution-dialog-datetime-finished">unknown</span> <strong>Finished: </strong><span id = "execution-dialog-datetime-finished">unknown</span>
</p> </p>
@ -114,18 +117,13 @@
</div> </div>
<div id = "execution-dialog-output"> <div id = "execution-dialog-output">
<details> <details id = "execution-dialog-output-details">
<summary>Standard Output</summary> <summary class = "padded-content">Output</summary>
<pre id = "execution-dialog-stdout">?</pre> <div id = "execution-dialog-xterm" />
</details>
<details>
<summary>Standard Error</summary>
<pre id = "execution-dialog-stderr">?</pre>
</details> </details>
</div> </div>
<div class = "buttons"> <div class = "buttons padded-content">
<button name = "kill" title = "Kill" id = "execution-dialog-kill-action">Kill</button> <button name = "kill" title = "Kill" id = "execution-dialog-kill-action">Kill</button>
<form method = "dialog"> <form method = "dialog">
@ -136,7 +134,7 @@
<template id = "tplArgumentForm"> <template id = "tplArgumentForm">
<dialog title = "Arguments" id = "argument-popup"> <dialog title = "Arguments" id = "argument-popup">
<form class = "action-arguments"> <form class = "action-arguments padded-content">
<div class = "wrapper"> <div class = "wrapper">
<div class = "action-header"> <div class = "action-header">
<span class = "icon" role = "img"></span> <span class = "icon" role = "img"></span>
@ -170,16 +168,6 @@
<span role = "img" class = "icon"></span> <span role = "img" class = "icon"></span>
<a href = "#" class = "content">?</a> <a href = "#" class = "content">?</a>
<details>
<summary>stdout</summary>
<pre class = "stdout">?</pre>
</details>
<details>
<summary>stderr</summary>
<pre class = "stderr">?</pre>
</details>
<div class = "tags"></div> <div class = "tags"></div>
</td> </td>
<td class = "exit-code">?</td> <td class = "exit-code">?</td>

View File

@ -7,10 +7,10 @@ export class ExecutionDialog {
this.domIcon = document.getElementById('execution-dialog-icon') this.domIcon = document.getElementById('execution-dialog-icon')
this.domTitle = document.getElementById('execution-dialog-title') this.domTitle = document.getElementById('execution-dialog-title')
this.domStdout = document.getElementById('execution-dialog-stdout') this.domOutput = document.getElementById('execution-dialog-xterm')
this.domStderr = document.getElementById('execution-dialog-stderr') this.domOutputDetails = document.getElementById('execution-dialog-output-details')
this.domStdoutToggleBig = document.getElementById('execution-dialog-toggle-size') this.domOutputToggleBig = document.getElementById('execution-dialog-toggle-size')
this.domStdoutToggleBig.onclick = () => { this.domOutputToggleBig.onclick = () => {
this.toggleSize() this.toggleSize()
} }
@ -24,15 +24,23 @@ export class ExecutionDialog {
this.domExecutionBasics = document.getElementById('execution-dialog-basics') this.domExecutionBasics = document.getElementById('execution-dialog-basics')
this.domExecutionDetails = document.getElementById('execution-dialog-details') this.domExecutionDetails = document.getElementById('execution-dialog-details')
this.domExecutionOutput = document.getElementById('execution-dialog-output') this.domExecutionOutput = document.getElementById('execution-dialog-output')
window.terminal.open(this.domOutput)
}
showOutput () {
this.domOutput.hidden = false
this.domOutputDetails.open = true
this.domExecutionOutput.hidden = false
} }
toggleSize () { toggleSize () {
if (this.dlg.classList.contains('big')) { if (this.dlg.classList.contains('big')) {
this.dlg.classList.remove('big') this.dlg.classList.remove('big')
this.domStdout.parentElement.open = false this.domOutputDetails.open = false
} else { } else {
this.dlg.classList.add('big') this.dlg.classList.add('big')
this.domStdout.parentElement.open = true this.domOutputDetails.open = true
} }
} }
@ -41,12 +49,8 @@ export class ExecutionDialog {
this.executionTrackingId = 'notset' this.executionTrackingId = 'notset'
this.dlg.classList.remove('big') this.dlg.classList.remove('big')
this.dlg.style.maxWidth = 'calc(100vw - 2em)'
this.dlg.style.width = ''
this.dlg.style.height = ''
this.dlg.style.border = ''
this.domStdoutToggleBig.hidden = false this.domOutputToggleBig.hidden = false
this.domIcon.innerText = '' this.domIcon.innerText = ''
this.domTitle.innerText = 'Waiting for result... ' this.domTitle.innerText = 'Waiting for result... '
@ -54,8 +58,8 @@ export class ExecutionDialog {
this.domStatus.className = '' this.domStatus.className = ''
this.domDatetimeStarted.innerText = '' this.domDatetimeStarted.innerText = ''
this.domDatetimeFinished.innerText = '' this.domDatetimeFinished.innerText = ''
this.domStdout.innerText = ''
this.domStderr.innerText = '' // window.terminal.close()
this.domBtnKill.disabled = true this.domBtnKill.disabled = true
this.domBtnKill.onclick = () => {} this.domBtnKill.onclick = () => {}
@ -64,7 +68,9 @@ export class ExecutionDialog {
this.domExecutionBasics.hidden = false this.domExecutionBasics.hidden = false
this.domExecutionDetails.hidden = true this.domExecutionDetails.hidden = true
this.domStdout.parentElement.open = false this.domOutputDetails.open = false
window.terminal.reset()
this.domExecutionOutput.hidden = true this.domExecutionOutput.hidden = true
} }
@ -105,7 +111,7 @@ export class ExecutionDialog {
}, },
body: JSON.stringify(killActionArgs) body: JSON.stringify(killActionArgs)
}).then((res) => { }).then((res) => {
console.log(res) console.log(res.json())
}).catch(err => { }).catch(err => {
throw err throw err
}) })
@ -157,7 +163,7 @@ export class ExecutionDialog {
if (!this.hideDetailsOnResult) { if (!this.hideDetailsOnResult) {
this.domExecutionDetails.hidden = false this.domExecutionDetails.hidden = false
} else { } else {
this.domStdout.parentElement.open = true this.domOutputDetails.open = true
} }
this.executionTrackingId = res.logEntry.executionTrackingId this.executionTrackingId = res.logEntry.executionTrackingId
@ -190,18 +196,10 @@ export class ExecutionDialog {
this.domIcon.innerHTML = res.logEntry.actionIcon this.domIcon.innerHTML = res.logEntry.actionIcon
this.domTitle.innerText = res.logEntry.actionTitle this.domTitle.innerText = res.logEntry.actionTitle
this.domStdout.innerText = res.logEntry.stdout
this.domStdout.innerText = res.logEntry.stdout
if (res.logEntry.stderr === '(empty)') {
this.domStderr.parentElement.style.display = 'none'
this.domStderr.innerText = res.logEntry.stderr
} else {
this.domStderr.parentElement.style.display = 'block'
this.domStderr.innerText = res.logEntry.stderr
}
this.domDatetimeStarted.innerText = res.logEntry.datetimeStarted this.domDatetimeStarted.innerText = res.logEntry.datetimeStarted
window.terminal.reset()
window.terminal.write(res.logEntry.output)
} }
renderError (err) { renderError (err) {

View File

@ -1,5 +1,6 @@
import './ActionButton.js' // To define action-button import './ActionButton.js' // To define action-button
import { ExecutionDialog } from './ExecutionDialog.js' import { ExecutionDialog } from './ExecutionDialog.js'
import { Terminal } from '@xterm/xterm'
/** /**
* This is a weird function that just sets some globals. * This is a weird function that just sets some globals.
@ -8,6 +9,10 @@ export function initMarshaller () {
window.changeDirectory = changeDirectory window.changeDirectory = changeDirectory
window.showSection = showSection window.showSection = showSection
window.terminal = new Terminal({
convertEol: true
})
window.executionDialog = new ExecutionDialog() window.executionDialog = new ExecutionDialog()
window.logEntries = {} window.logEntries = {}
@ -17,6 +22,7 @@ export function initMarshaller () {
window.currentSection = '' window.currentSection = ''
window.addEventListener('EventExecutionFinished', onExecutionFinished) window.addEventListener('EventExecutionFinished', onExecutionFinished)
window.addEventListener('EventOutputChunk', onOutputChunk)
} }
export function marshalDashboardComponentsJsonToHtml (json) { export function marshalDashboardComponentsJsonToHtml (json) {
@ -57,6 +63,16 @@ function marshalActionsJsonToHtml (json) {
} }
} }
function onOutputChunk (evt) {
const chunk = evt.payload
if (chunk.executionTrackingId === window.executionDialog.executionTrackingId) {
window.terminal.write(chunk.output)
window.executionDialog.showOutput()
}
}
function onExecutionFinished (evt) { function onExecutionFinished (evt) {
const logEntry = evt.payload.logEntry const logEntry = evt.payload.logEntry
@ -78,7 +94,7 @@ function onExecutionFinished (evt) {
// have it, so we open the dialog and it will get updated below. // have it, so we open the dialog and it will get updated below.
window.executionDialog.show() window.executionDialog.show()
window.executionDialog.executionUuid = logEntry.uuid window.executionDialog.executionTrackingId = logEntry.uuid
break break
default: default:
@ -476,14 +492,6 @@ export function marshalLogsJsonToHtml (json) {
const tpl = document.getElementById('tplLogRow') const tpl = document.getElementById('tplLogRow')
const row = tpl.content.querySelector('tr').cloneNode(true) const row = tpl.content.querySelector('tr').cloneNode(true)
if (logEntry.stdout.length === 0) {
logEntry.stdout = '(empty)'
}
if (logEntry.stderr.length === 0) {
logEntry.stderr = '(empty)'
}
let logTableExitCode = logEntry.exitCode let logTableExitCode = logEntry.exitCode
if (logEntry.exitCode === 0) { if (logEntry.exitCode === 0) {
@ -497,8 +505,6 @@ export function marshalLogsJsonToHtml (json) {
row.querySelector('.timestamp').innerText = logEntry.datetimeStarted row.querySelector('.timestamp').innerText = logEntry.datetimeStarted
row.querySelector('.content').innerText = logEntry.actionTitle row.querySelector('.content').innerText = logEntry.actionTitle
row.querySelector('.icon').innerHTML = logEntry.actionIcon row.querySelector('.icon').innerHTML = logEntry.actionIcon
row.querySelector('pre.stdout').innerText = logEntry.stdout
row.querySelector('pre.stderr').innerText = logEntry.stderr
row.querySelector('.exit-code').innerText = logTableExitCode row.querySelector('.exit-code').innerText = logTableExitCode
row.setAttribute('title', logEntry.actionTitle) row.setAttribute('title', logEntry.actionTitle)

View File

@ -51,6 +51,7 @@ function websocketOnMessage (msg) {
e.payload = j.payload e.payload = j.payload
switch (j.type) { switch (j.type) {
case 'EventOutputChunk':
case 'EventConfigChanged': case 'EventConfigChanged':
case 'EventExecutionFinished': case 'EventExecutionFinished':
case 'EventEntityChanged': case 'EventEntityChanged':

View File

@ -8,6 +8,9 @@
"name": "olivetin-webui", "name": "olivetin-webui",
"version": "1.0.0", "version": "1.0.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": {
"@xterm/xterm": "^5.5.0"
},
"devDependencies": { "devDependencies": {
"eslint": "^7.25.0", "eslint": "^7.25.0",
"eslint-config-standard": "^16.0.2", "eslint-config-standard": "^16.0.2",
@ -16,6 +19,7 @@
"eslint-plugin-promise": "^4.3.1", "eslint-plugin-promise": "^4.3.1",
"parcel": "^2.11.0", "parcel": "^2.11.0",
"parcel-resolver-ignore": "^2.2.0", "parcel-resolver-ignore": "^2.2.0",
"process": "^0.11.10",
"stylelint": "^15.6.0", "stylelint": "^15.6.0",
"stylelint-config-standard": "^33.0.0" "stylelint-config-standard": "^33.0.0"
} }
@ -2233,6 +2237,11 @@
"integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
"dev": true "dev": true
}, },
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="
},
"node_modules/abortcontroller-polyfill": { "node_modules/abortcontroller-polyfill": {
"version": "1.7.5", "version": "1.7.5",
"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz", "resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz",
@ -5371,6 +5380,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"dev": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/progress": { "node_modules/progress": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@ -7739,6 +7757,11 @@
"integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
"dev": true "dev": true
}, },
"@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="
},
"abortcontroller-polyfill": { "abortcontroller-polyfill": {
"version": "1.7.5", "version": "1.7.5",
"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz", "resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz",
@ -9933,6 +9956,12 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true "dev": true
}, },
"process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"dev": true
},
"progress": { "progress": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",

View File

@ -12,6 +12,7 @@
"eslint-plugin-promise": "^4.3.1", "eslint-plugin-promise": "^4.3.1",
"parcel": "^2.11.0", "parcel": "^2.11.0",
"parcel-resolver-ignore": "^2.2.0", "parcel-resolver-ignore": "^2.2.0",
"process": "^0.11.10",
"stylelint": "^15.6.0", "stylelint": "^15.6.0",
"stylelint-config-standard": "^33.0.0" "stylelint-config-standard": "^33.0.0"
}, },
@ -25,6 +26,9 @@
"OliveTinLogo-57px.png", "OliveTinLogo-57px.png",
"OliveTinLogo-120px.png", "OliveTinLogo-120px.png",
"OliveTinLogo-180px.png" "OliveTinLogo-180px.png"
], ],
"license": "AGPL-3.0-only" "license": "AGPL-3.0-only",
"dependencies": {
"@xterm/xterm": "^5.5.0"
}
} }

View File

@ -13,15 +13,16 @@ dialog {
box-shadow: 0 0 6px 0 #444; box-shadow: 0 0 6px 0 #444;
max-width: 600px; max-width: 600px;
min-width: 60%; min-width: 60%;
padding: 1em;
text-align: left; text-align: left;
padding: 0;
} }
dialog.big { dialog.big {
max-width: 100%; max-width: 100vw;
width: calc(100vw - 2em); width: 100vw;
height: 100vh; height: 100vh;
border: none; border: none;
margin: 0;
} }
fieldset { fieldset {
@ -393,10 +394,6 @@ form .wrapper span.icon {
vertical-align: middle; vertical-align: middle;
} }
form input[type="submit"]:first-child {
margin-right: 1em;
}
button[name="cancel"]:hover { button[name="cancel"]:hover {
background-color: salmon; background-color: salmon;
color: black; color: black;
@ -421,14 +418,6 @@ div.buttons {
gap: 1em; gap: 1em;
} }
pre {
border: 1px solid gray;
padding: 1em;
min-height: 1em;
overflow: auto;
text-align: left;
}
td.exit-code { td.exit-code {
text-align: center; text-align: center;
} }
@ -475,6 +464,22 @@ div.display {
font-size: small; font-size: small;
} }
#execution-dialog-xterm {
overflow: scroll;
}
.padded-content {
padding: 1em;
}
.xterm {
padding: 1em;
margin-bottom: 1em;
margin-left: 1em;
margin-right: 1em;
width: fit-content;
}
@media screen and (width <= 600px) { @media screen and (width <= 600px) {
fieldset { fieldset {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@ -499,7 +504,15 @@ div.display {
dialog { dialog {
border-left: 0; border-left: 0;
border-right: 0; border-right: 0;
width: 100%; margin-left: 0;
margin-right: 0;
width: 100vw;
max-width: 100vw;
}
.xterm {
margin-left: 0;
margin-right: 0;
} }
} }