Explorar o código

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
James Read %!s(int64=2) %!d(string=hai) anos
pai
achega
ac0f3ab6f8

+ 7 - 2
OliveTin.proto

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

+ 5 - 3
config.yaml

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

+ 39 - 31
internal/executor/executor.go

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

+ 1 - 2
internal/grpcapi/grpcApi.go

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

+ 12 - 2
internal/websocket/websocket.go

@@ -68,6 +68,17 @@ func checkOriginPermissive(r *http.Request) bool {
 	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) {
 	evt := &pb.EventExecutionFinished{
 		LogEntry: &pb.LogEntry{
@@ -76,8 +87,7 @@ func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.Interna
 			ActionId:            logEntry.ActionId,
 			DatetimeStarted:     logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
 			DatetimeFinished:    logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
-			Stdout:              logEntry.Stdout,
-			Stderr:              logEntry.Stderr,
+			Output:              logEntry.Output,
 			TimedOut:            logEntry.TimedOut,
 			Blocked:             logEntry.Blocked,
 			ExitCode:            logEntry.ExitCode,

+ 13 - 25
webui.dev/index.html

@@ -2,13 +2,16 @@
 
 <html lang = "en">
 	<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." />
 
 		<title>OliveTin</title>
 
 		<link rel = "stylesheet" type = "text/css" href = "style.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 = "apple-touch-icon" sizes="57x57" href="OliveTinLogo-57px.png" />
@@ -84,12 +87,12 @@
 			</p>
 		</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 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>
 
 				<h2>
@@ -98,10 +101,10 @@
 
 				<button id = "execution-dialog-toggle-size" title = "Toggle dialog size">&#128470;</button>
 			</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>
 			</div>
-			<div id = "execution-dialog-details">
+			<div id = "execution-dialog-details" class = "padded-content">
 				<p>
 					<strong>Finished: </strong><span id = "execution-dialog-datetime-finished">unknown</span>
 				</p>
@@ -114,18 +117,13 @@
 			</div>
 
 			<div id = "execution-dialog-output">
-				<details>
-					<summary>Standard Output</summary>
-					<pre id = "execution-dialog-stdout">?</pre>
-				</details>
-
-				<details>
-					<summary>Standard Error</summary>
-					<pre id = "execution-dialog-stderr">?</pre>
+				<details id = "execution-dialog-output-details">
+					<summary class = "padded-content">Output</summary>
+					<div id = "execution-dialog-xterm" />
 				</details>
 			</div>
 
-			<div class = "buttons">
+			<div class = "buttons padded-content">
 				<button name = "kill" title = "Kill" id = "execution-dialog-kill-action">Kill</button>
 
 				<form method = "dialog">
@@ -136,7 +134,7 @@
 
 		<template id = "tplArgumentForm">
 			<dialog title = "Arguments" id = "argument-popup">
-				<form class = "action-arguments">
+				<form class = "action-arguments padded-content">
 					<div class = "wrapper">
 						<div class = "action-header">
 							<span class = "icon" role = "img"></span>
@@ -170,16 +168,6 @@
 					<span role = "img" class = "icon"></span>
 					<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>
 				</td>
 				<td class = "exit-code">?</td>

+ 25 - 27
webui.dev/js/ExecutionDialog.js

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

+ 17 - 11
webui.dev/js/marshaller.js

@@ -1,5 +1,6 @@
 import './ActionButton.js' // To define action-button
 import { ExecutionDialog } from './ExecutionDialog.js'
+import { Terminal } from '@xterm/xterm'
 
 /**
  * This is a weird function that just sets some globals.
@@ -8,6 +9,10 @@ export function initMarshaller () {
   window.changeDirectory = changeDirectory
   window.showSection = showSection
 
+  window.terminal = new Terminal({
+    convertEol: true
+  })
+
   window.executionDialog = new ExecutionDialog()
 
   window.logEntries = {}
@@ -17,6 +22,7 @@ export function initMarshaller () {
   window.currentSection = ''
 
   window.addEventListener('EventExecutionFinished', onExecutionFinished)
+  window.addEventListener('EventOutputChunk', onOutputChunk)
 }
 
 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) {
   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.
 
       window.executionDialog.show()
-      window.executionDialog.executionUuid = logEntry.uuid
+      window.executionDialog.executionTrackingId = logEntry.uuid
 
       break
     default:
@@ -476,14 +492,6 @@ export function marshalLogsJsonToHtml (json) {
     const tpl = document.getElementById('tplLogRow')
     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
 
     if (logEntry.exitCode === 0) {
@@ -497,8 +505,6 @@ export function marshalLogsJsonToHtml (json) {
     row.querySelector('.timestamp').innerText = logEntry.datetimeStarted
     row.querySelector('.content').innerText = logEntry.actionTitle
     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.setAttribute('title', logEntry.actionTitle)
 

+ 1 - 0
webui.dev/js/websocket.js

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

+ 29 - 0
webui.dev/package-lock.json

@@ -8,6 +8,9 @@
 			"name": "olivetin-webui",
 			"version": "1.0.0",
 			"license": "AGPL-3.0-only",
+			"dependencies": {
+				"@xterm/xterm": "^5.5.0"
+			},
 			"devDependencies": {
 				"eslint": "^7.25.0",
 				"eslint-config-standard": "^16.0.2",
@@ -16,6 +19,7 @@
 				"eslint-plugin-promise": "^4.3.1",
 				"parcel": "^2.11.0",
 				"parcel-resolver-ignore": "^2.2.0",
+				"process": "^0.11.10",
 				"stylelint": "^15.6.0",
 				"stylelint-config-standard": "^33.0.0"
 			}
@@ -2233,6 +2237,11 @@
 			"integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
 			"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": {
 			"version": "1.7.5",
 			"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz",
@@ -5371,6 +5380,15 @@
 				"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": {
 			"version": "2.0.3",
 			"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -7739,6 +7757,11 @@
 			"integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
 			"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": {
 			"version": "1.7.5",
 			"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz",
@@ -9933,6 +9956,12 @@
 			"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
 			"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": {
 			"version": "2.0.3",
 			"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",

+ 6 - 2
webui.dev/package.json

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

+ 29 - 16
webui.dev/style.css

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