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 {
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 {

View File

@ -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.

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -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>

View File

@ -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) {

View File

@ -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)

View File

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

View File

@ -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",

View File

@ -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"
}
}

View File

@ -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;
}
}