From c12431d8a3f17925a8119051122c1ab75ec7981d Mon Sep 17 00:00:00 2001 From: James Read Date: Thu, 8 Feb 2024 22:54:08 +0100 Subject: [PATCH] feature: popupOnStart allows for many feedback options when actions are started (#227) --- config.yaml | 100 ++++++++++++++++++---------- internal/config/config.go | 10 +-- internal/config/emoji.go | 2 + internal/config/sanitize.go | 18 ++++- webui/index.html | 59 +++++++++------- webui/js/ActionButton.js | 65 +++++++++++++----- webui/js/ExecutionButton.js | 61 +++-------------- webui/js/ExecutionDialog.js | 92 ++++++++++++++++++++++--- webui/js/ExecutionFeedbackButton.js | 29 ++++++++ webui/js/marshaller.js | 59 ++++++++++++++-- webui/js/websocket.js | 22 ++---- webui/main.js | 3 +- webui/style.css | 68 ++++++++++++++----- 13 files changed, 401 insertions(+), 187 deletions(-) create mode 100644 webui/js/ExecutionFeedbackButton.js diff --git a/config.yaml b/config.yaml index 865e3e5..03c2eaa 100644 --- a/config.yaml +++ b/config.yaml @@ -8,36 +8,38 @@ listenAddressSingleHTTPFrontend: 0.0.0.0:1337 # Choose from INFO (default), WARN and DEBUG logLevel: "INFO" -entities: - - name: container - file: /etc/OliveTin/entities/containers.yaml - icon: container - -helpers: - - title: refresh container file - shell: "docker ps --format '{{ .state }}' > /etc/OliveTin/entities/containers.yaml" - execOnStartup: true - execOnCron: - - "*/1 * * * * *" - # Actions (buttons) to show up on the WebUI: actions: - - title: date - shell: date - icon: clock - popupOnStart: true - - - title: start - shell: docker start {{ container.name }} - icon: start + - title: Restart media container + shell: docker restart mediacontainer + icon: box entity: container + - title: Check disk space + icon: disk + shell: df -h /media + popupOnStart: execution-dialog-stdout-only + + - title: check dmesg logs + shell: dmesg | tail + icon: logs + popupOnStart: execution-dialog + + # This will run a simple script that you create. - title: Run backup script shell: /opt/backupScript.sh shellAfterCompleted: "apprise -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ stdout }} '" maxConcurrent: 1 + timeout: 10 icon: backup + popupOnStart: execution-dialog + + - title: date + shell: date + timeout: 6 + icon: clock + popupOnStart: execution-button # This will send 1 ping (-c 1) # Docs: https://docs.olivetin.app/action-ping.html @@ -58,13 +60,6 @@ actions: default: 1 description: How many times to do you want to ping? - # Restart lightdm on host "server1" - # Docs: https://docs.olivetin.app/action-ping.html - - title: restart httpd - titleAlias: restart_httpd - icon: restart - shell: ssh root@server1 'service httpd restart' - # OliveTin can run long-running jobs like Ansible playbooks. # # For such jobs, you will need to install ansible-playbook on the host where @@ -76,6 +71,14 @@ actions: shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml timeout: 120 + # Restart httpd on host "server1" + # Docs: https://docs.olivetin.app/action-ping.html + - title: restart httpd on server1 + titleAlias: restart_httpd + icon: restart + timeout: 1 + shell: ssh root@server1 'service httpd restart' + # OliveTin can control containers - docker is just a command line app. # # However, if you are running in a container you will need to do some setup, @@ -93,19 +96,42 @@ actions: - value: traefik - value: grafana - - title: Slow Script - shell: sleep 3 - timeout: 5 - icon: "🥱" - - - title: Broken Script (timeout) - shell: sleep 5 - timeout: 5 - icon: "😪" - - title: Delete old backups icon: ashtonished shell: rm -rf /opt/oldBackups/ arguments: - type: confirmation title: Are you sure?! + + - title: Server1 Power Off + shell: echo "Power Off Server 1" + + - title: Server2 Wake On LAN + shell: echo "Sending Wake on LAN to Server 2" + + - title: Server2 Power Off + shell: echo "Power Off Server 2" + + - title: Ping All Servers + shell: echo "Ping all servers" + icon: ping + +dashboards: + - title: My Servers + contents: + - title: Server Power Controls + type: fieldset + contents: + - title: Server 1 + contents: + - link: Server1 Power Off + + - title: Server 2 + contents: + - link: Server2 Wake On LAN + - link: Server2 Power Off + + - title: Server Utilities + type: fieldset + contents: + - link: Ping All Servers diff --git a/internal/config/config.go b/internal/config/config.go index d9f4cd4..bde362c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,10 +51,10 @@ type HelperAction struct { // Entity represents a "thing" that can have multiple actions associated with it. // for example, a media player with a start and stop action. type EntityFile struct { - File string - Name string - Icon string - CSS map[string]string + File string + Name string + Icon string + CSS map[string]string } // PermissionsList defines what users can do with an action. @@ -103,6 +103,7 @@ type Config struct { HelperActions []HelperAction `mapstructure:"helperActions"` CronSupportForSeconds bool SectionNavigationStyle string + DefaultPopupOnStart string } type DashboardItem struct { @@ -134,6 +135,7 @@ func DefaultConfig() *Config { config.WebUIDir = "./webui" config.CronSupportForSeconds = false config.SectionNavigationStyle = "sidebar" + config.DefaultPopupOnStart = "nothing" return &config } diff --git a/internal/config/emoji.go b/internal/config/emoji.go index 6a354a9..b311035 100644 --- a/internal/config/emoji.go +++ b/internal/config/emoji.go @@ -11,6 +11,8 @@ var emojis = map[string]string{ "box": "📦", "ashtonished": "😲", "clock": "🕒", + "disk": "💽", + "logs": "🔍", } func lookupHTMLIcon(keyToLookup string) string { diff --git a/internal/config/sanitize.go b/internal/config/sanitize.go index 8e127ad..b010962 100644 --- a/internal/config/sanitize.go +++ b/internal/config/sanitize.go @@ -12,7 +12,7 @@ func (cfg *Config) Sanitize() { // log.Infof("cfg %p", cfg) for idx := range cfg.Actions { - cfg.Actions[idx].sanitize() + cfg.Actions[idx].sanitize(cfg) } } @@ -23,12 +23,13 @@ func (cfg *Config) sanitizeLogLevel() { } } -func (action *Action) sanitize() { +func (action *Action) sanitize(cfg *Config) { if action.Timeout < 3 { action.Timeout = 3 } action.Icon = lookupHTMLIcon(action.Icon) + action.PopupOnStart = sanitizePopupOnStart(action.PopupOnStart, cfg) if action.MaxConcurrent < 1 { action.MaxConcurrent = 1 @@ -39,6 +40,19 @@ func (action *Action) sanitize() { } } +func sanitizePopupOnStart(raw string, cfg *Config) string { + switch raw { + case "execution-dialog": + return raw + case "execution-dialog-stdout-only": + return raw + case "execution-button": + return raw + default: + return cfg.DefaultPopupOnStart + } +} + func (arg *ActionArgument) sanitize() { if arg.Title == "" { arg.Title = arg.Name diff --git a/webui/index.html b/webui/index.html index 88cec57..e877f54 100644 --- a/webui/index.html +++ b/webui/index.html @@ -83,38 +83,47 @@
-

Log: +

?

+ + +
+
+ Started: unknown +
+
+

+ Finished: unknown +

+

+ Exit Code: unknown +

+

+ Status: unknown +

-

- Started: unknown. - Finished: unknown -

-

- Exit Code: unknown -

-

- Status: unknown -

+
+
+ Standard Output +
+						?
+					
+
-
- Output -
-					?
-				
-
- -
- Errors -
-					?
-				
-
+
+ Standard Error +
+						?
+					
+
+
- +
+ +
diff --git a/webui/js/ActionButton.js b/webui/js/ActionButton.js index 6da3aaa..eaa70e8 100644 --- a/webui/js/ActionButton.js +++ b/webui/js/ActionButton.js @@ -1,7 +1,24 @@ -import './ArgumentForm.js' import './ExecutionButton.js' +import './ArgumentForm.js' +import { ExecutionFeedbackButton } from './ExecutionFeedbackButton.js' + +class ActionButton extends ExecutionFeedbackButton { + constructDomFromTemplate () { + const tpl = document.getElementById('tplActionButton') + const content = tpl.content.cloneNode(true) + + /* + * FIXME: Should probably be using a shadowdom here, but seem to + * get an error when combined with custom elements. + */ + + this.appendChild(content) + + this.btn = this.querySelector('button') + this.domTitle = this.btn.querySelector('.title') + this.domIcon = this.btn.querySelector('.icon') + } -class ActionButton extends window.HTMLElement { constructFromJson (json) { this.updateIterationTimestamp = 0 @@ -35,6 +52,8 @@ class ActionButton extends window.HTMLElement { } } + this.popupOnStart = json.popupOnStart + this.updateFromJson(json) this.domTitle.innerText = this.btn.title @@ -57,6 +76,14 @@ class ActionButton extends window.HTMLElement { } } + onExecStatusChanged () { + this.btn.disabled = false + + setTimeout(() => { + this.updateDom(null, this.btn.title) + }, 2000) + } + getUniqueId () { if (window.isSecureContext) { return window.crypto.randomUUID() @@ -82,9 +109,7 @@ class ActionButton extends window.HTMLElement { uuid: this.getUniqueId() } - const btnExecution = document.createElement('execution-button') - btnExecution.constructFromJson(startActionArgs.uuid) - this.querySelector('.action-button-footer').prepend(btnExecution) + this.onActionStarted(startActionArgs.uuid) window.fetch(this.actionCallUrl, { method: 'POST', @@ -102,24 +127,30 @@ class ActionButton extends window.HTMLElement { ).then((json) => { // The button used to wait for the action to finish, but now it is fire & forget }).catch(err => { - btnExecution.onActionError(err) + throw err // We used to flash buttons red, but now hand to the global error handler }) } - constructDomFromTemplate () { - const tpl = document.getElementById('tplActionButton') - const content = tpl.content.cloneNode(true) + onActionStarted (executionUuid) { + if (this.popupOnStart === 'execution-button') { + const btnExecution = document.createElement('execution-button') + btnExecution.constructFromJson(executionUuid) + this.querySelector('.action-button-footer').prepend(btnExecution) - /* - * FIXME: Should probably be using a shadowdom here, but seem to - * get an error when combined with custom elements. - */ + return + } - this.appendChild(content) + if (this.popupOnStart.includes('execution-dialog')) { + window.executionDialog.reset() - this.btn = this.querySelector('button') - this.domTitle = this.btn.querySelector('.title') - this.domIcon = this.btn.querySelector('.icon') + if (this.popupOnStart === 'execution-dialog-stdout-only') { + window.executionDialog.hideEverythingApartFromOutput() + } + + window.executionDialog.show(this) + } + + this.btn.disabled = true } } diff --git a/webui/js/ExecutionButton.js b/webui/js/ExecutionButton.js index 0a5f27f..8acf40e 100644 --- a/webui/js/ExecutionButton.js +++ b/webui/js/ExecutionButton.js @@ -1,6 +1,6 @@ -import { ExecutionDialog } from './ExecutionDialog.js' +import { ExecutionFeedbackButton } from './ExecutionFeedbackButton.js' -class ExecutionButton extends window.HTMLElement { +class ExecutionButton extends ExecutionFeedbackButton { constructFromJson (json) { this.executionUuid = json this.ellapsed = 0 @@ -15,60 +15,19 @@ class ExecutionButton extends window.HTMLElement { this.btn.onclick = () => { this.show() } + + this.domTitle = this.btn } show () { - if (typeof (window.executionDialog) === 'undefined') { - window.executionDialog = new ExecutionDialog() - } - - window.executionDialog.show(this.executionUuid) + window.executionDialog.reset() + window.executionDialog.show() + window.executionDialog.fetchExecutionResult(this.executionUuid) } - onFinished (LogEntry) { - if (LogEntry.timedOut) { - this.onActionResult('action-timeout', 'Timed out') - } else if (LogEntry.blocked) { - this.onActionResult('action-blocked', 'Blocked!') - } else if (LogEntry.exitCode !== 0) { - this.onActionResult('action-nonzero-exit', 'Exit code ' + LogEntry.exitCode) - } else { - console.log(LogEntry) - this.ellapsed = Math.ceil(new Date(LogEntry.datetimeFinished) - new Date(LogEntry.datetimeStarted)) / 1000 - this.onActionResult('action-success', 'Success!') - } - } - - onActionResult (cssClass, temporaryStatusMessage) { - this.temporaryStatusMessage = '[' + temporaryStatusMessage + ']' - this.updateDom() - this.btn.classList.add(cssClass) - } - - onActionError (err) { - console.error('callback error', err) - this.isWaiting = false - this.updateDom() - this.btn.classList.add('action-failed') - } - - updateDom () { - if (this.temporaryStatusMessage != null) { - this.btn.innerText = this.temporaryStatusMessage - this.btn.classList.add('temporary-status-message') - this.isWaiting = false - - setTimeout(() => { - this.temporaryStatusMessage = null - this.btn.classList.remove('temporary-status-message') - this.updateDom() - }, 2000) - } else if (this.isWaiting) { - this.btn.innerText = 'Waiting...' - } else { - this.btn.innerText = this.ellapsed + 's' - this.btn.title = this.ellapsed + ' seconds' - } + onExecStatusChanged () { + this.domTitle.innerText = this.ellapsed + 's' + this.btn.title = this.ellapsed + ' seconds' } } diff --git a/webui/js/ExecutionDialog.js b/webui/js/ExecutionDialog.js index 028069e..c7fe560 100644 --- a/webui/js/ExecutionDialog.js +++ b/webui/js/ExecutionDialog.js @@ -2,21 +2,51 @@ // the element out of index.html and just re-uses that - as only // one dialog can be shown at a time. export class ExecutionDialog { - show (json) { - this.executionUuid = json - + constructor () { this.dlg = document.querySelector('dialog#execution-results-popup') 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.toggleSize() + } + this.domDatetimeStarted = document.getElementById('execution-dialog-datetime-started') this.domDatetimeFinished = document.getElementById('execution-dialog-datetime-finished') this.domExitCode = document.getElementById('execution-dialog-exit-code') this.domStatus = document.getElementById('execution-dialog-status') - this.domTitle.innerText = 'Loading...' + this.domExecutionBasics = document.getElementById('execution-dialog-basics') + this.domExecutionDetails = document.getElementById('execution-dialog-details') + this.domExecutionOutput = document.getElementById('execution-dialog-output') + } + + toggleSize () { + if (this.dlg.classList.contains('big')) { + this.dlg.classList.remove('big') + this.domStdout.parentElement.open = false + } else { + this.dlg.classList.add('big') + this.domStdout.parentElement.open = true + } + } + + reset () { + this.executionSeconds = 0 + + 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.domIcon.innerText = '' + this.domTitle.innerText = 'Waiting for result... ' this.domExitCode.innerText = '?' this.domStatus.className = '' this.domDatetimeStarted.innerText = '' @@ -24,12 +54,44 @@ export class ExecutionDialog { this.domStdout.innerText = '' this.domStderr.innerText = '' - this.dlg.showModal() + this.hideDetailsOnResult = false + this.domExecutionBasics.hidden = false - this.fetchExecutionResult() + this.domExecutionDetails.hidden = true + this.domStdout.parentElement.open = false + + this.domExecutionOutput.hidden = true } - fetchExecutionResult () { + show (actionButton) { + if (typeof actionButton !== 'undefined' && actionButton != null) { + this.domIcon.innerText = actionButton.domIcon.innerText + } + + clearInterval(window.executionDialogTicker) + this.executionSeconds = 0 + this.executionTick() + window.executionDialogTicker = setInterval(() => { + this.executionTick() + }, 1000) + + this.dlg.showModal() + } + + executionTick () { + this.executionSeconds++ + + this.domDatetimeStarted.innerText = this.executionSeconds + ' seconds ago' + } + + hideEverythingApartFromOutput () { + this.hideDetailsOnResult = true + this.domExecutionBasics.hidden = true + } + + fetchExecutionResult (uuid) { + this.executionUuid = uuid + const executionStatusArgs = { executionUuid: this.executionUuid } @@ -48,13 +110,23 @@ export class ExecutionDialog { } } ).then((json) => { - this.renderResult(json) + this.renderExecutionResult(json) }).catch(err => { this.renderError(err) }) } - renderResult (res) { + renderExecutionResult (res) { + clearInterval(window.executionDialogTicker) + + this.domExecutionOutput.hidden = false + + if (!this.hideDetailsOnResult) { + this.domExecutionDetails.hidden = false + } else { + this.domStdout.parentElement.open = true + } + this.executionUuid = res.logEntry.executionUuid if (res.logEntry.executionFinished) { @@ -86,7 +158,7 @@ export class ExecutionDialog { this.domStdout.innerText = res.logEntry.stdout this.domStdout.innerText = res.logEntry.stdout - if (res.logEntry.stderr === '') { + if (res.logEntry.stderr === '(empty)') { this.domStderr.parentElement.style.display = 'none' this.domStderr.innerText = res.logEntry.stderr } else { diff --git a/webui/js/ExecutionFeedbackButton.js b/webui/js/ExecutionFeedbackButton.js new file mode 100644 index 0000000..50e30fb --- /dev/null +++ b/webui/js/ExecutionFeedbackButton.js @@ -0,0 +1,29 @@ +export class ExecutionFeedbackButton extends window.HTMLElement { + onExecutionFinished (LogEntry) { + if (LogEntry.timedOut) { + this.renderExecutionResult('action-timeout', 'Timed out') + } else if (LogEntry.blocked) { + this.renderExecutionResult('action-blocked', 'Blocked!') + } else if (LogEntry.exitCode !== 0) { + this.renderExecutionResult('action-nonzero-exit', 'Exit code ' + LogEntry.exitCode) + } else { + this.ellapsed = Math.ceil(new Date(LogEntry.datetimeFinished) - new Date(LogEntry.datetimeStarted)) / 1000 + this.renderExecutionResult('action-success', 'Success!') + } + } + + renderExecutionResult (resultCssClass, temporaryStatusMessage) { + this.updateDom(resultCssClass, '[' + temporaryStatusMessage + ']') + this.onExecStatusChanged() + } + + updateDom (resultCssClass, title) { + if (resultCssClass == null) { + this.btn.className = '' + } else { + this.btn.classList.add(resultCssClass) + } + + this.domTitle.innerText = title + } +} diff --git a/webui/js/marshaller.js b/webui/js/marshaller.js index a43db09..f97c190 100644 --- a/webui/js/marshaller.js +++ b/webui/js/marshaller.js @@ -1,12 +1,22 @@ import './ActionButton.js' // To define action-button +import { ExecutionDialog } from './ExecutionDialog.js' + +/** + * This is a weird function that just sets some globals. + */ +export function initMarshaller () { + window.changeDirectory = changeDirectory + window.showSection = showSection + + window.executionDialog = new ExecutionDialog() + + window.addEventListener('ExecutionFinished', onExecutionFinished) +} export function marshalDashboardComponentsJsonToHtml (json) { marshalActionsJsonToHtml(json) marshalDashboardStructureToHtml(json) - window.changeDirectory = changeDirectory - window.showSection = showSection - changeDirectory(null) } @@ -37,6 +47,43 @@ function marshalActionsJsonToHtml (json) { } } +function onExecutionFinished (evt) { + const logEntry = evt.payload + + const actionButton = window.actionButtons[logEntry.actionTitle] + + switch (actionButton.popupOnStart) { + case 'execution-button': + document.querySelector('execution-button#execution-' + logEntry.uuid).onExecutionFinished(logEntry) + break + case 'execution-dialog-stdout-only': + case 'execution-dialog': + actionButton.onExecutionFinished(logEntry) + + // We don't need to fetch the logEntry for the dialog because we already + // have it, so we open the dialog and it will get updated below. + + window.executionDialog.show() + window.executionDialog.executionUuid = logEntry.uuid + + break + default: + actionButton.onExecutionFinished(logEntry) + break + } + + marshalLogsJsonToHtml({ + logs: [logEntry] + }) + + // If the current execution dialog is open, update that too + if (window.executionDialog.dlg.open && window.executionDialog.executionUuid === logEntry.uuid) { + window.executionDialog.renderExecutionResult({ + logEntry: logEntry + }) + } +} + function showSection (title) { for (const section of document.querySelectorAll('section')) { if (section.title === title) { @@ -117,10 +164,10 @@ function marshalDashboardStructureToHtml (json) { document.getElementById('navigation-links').appendChild(navigationLi) } - if (json.dashboards.length === 0) { - showSection('Actions') - } else { + if (document.getElementById('root-group').children.length === 0 && json.dashboards.length > 0) { showSection(json.dashboards[0].title) + } else { + showSection('Actions') } const rootGroup = document.querySelector('#root-group') diff --git a/webui/js/websocket.js b/webui/js/websocket.js index b9ce468..bb81002 100644 --- a/webui/js/websocket.js +++ b/webui/js/websocket.js @@ -1,5 +1,3 @@ -import { marshalLogsJsonToHtml } from './marshaller.js' - window.ws = null export function checkWebsocketConnection () { @@ -38,30 +36,18 @@ function websocketOnMessage (msg) { // FIXME check msg status is OK const j = JSON.parse(msg.data) + const e = new Event(j.type) + e.payload = j.payload + switch (j.type) { case 'ExecutionFinished': - updatePageAfterFinished(j.payload) + window.dispatchEvent(e) break default: window.showBigError('Unknown message type from server: ' + j.type) } } -function updatePageAfterFinished (logEntry) { - document.querySelector('execution-button#execution-' + logEntry.uuid).onFinished(logEntry) - - marshalLogsJsonToHtml({ - logs: [logEntry] - }) - - // If the current execution dialog is open, update that too - if (window.executionDialog != null && window.executionDialog.dlg.open && window.executionDialog.executionUuid === logEntry.uuid) { - window.executionDialog.renderResult({ - logEntry: logEntry - }) - } -} - function websocketOnError (err) { window.websocketAvailable = false window.refreshLoop() diff --git a/webui/main.js b/webui/main.js index fc7e344..cb366f2 100644 --- a/webui/main.js +++ b/webui/main.js @@ -1,6 +1,6 @@ 'use strict' -import { setupSectionNavigation, marshalDashboardComponentsJsonToHtml, marshalLogsJsonToHtml } from './js/marshaller.js' +import { initMarshaller, setupSectionNavigation, marshalDashboardComponentsJsonToHtml, marshalLogsJsonToHtml } from './js/marshaller.js' import { checkWebsocketConnection } from './js/websocket.js' function searchLogs (e) { @@ -123,6 +123,7 @@ function processWebuiSettingsJson (settings) { } function main () { + initMarshaller() setupLogSearchBox() setupSectionNavigation('sidebar') diff --git a/webui/style.css b/webui/style.css index ecb0699..fa08327 100644 --- a/webui/style.css +++ b/webui/style.css @@ -10,13 +10,21 @@ body { dialog { box-shadow: 0 0 6px 0 #444; max-width: 600px; + min-width: 60%; padding: 1em; text-align: left; } +dialog.big { + max-width: 100%; + width: calc(100vw - 2em); + height: 100vh; + border: none; +} + fieldset { display: grid; - grid-template-columns: repeat(auto-fit, 160px); + grid-template-columns: repeat(auto-fit, 180px); grid-template-rows: auto auto auto auto; grid-gap: 1em; padding: 0; @@ -29,10 +37,9 @@ fieldset { display: inline; } -h1 { - display: inline; - font-size: small; - padding-left: .5em; +footer, +footer a { + color: black; } #sidebar-toggler-button { @@ -55,13 +62,11 @@ h1 { cursor: pointer; } -footer, -footer a { - color: black; +nav { + background-color: white; } nav.sidebar { - background-color: white; position: absolute; width: 180px; height: 100vh; @@ -87,6 +92,16 @@ input:checked ~ nav.sidebar { left: -250px; } +h1 { + display: inline; + font-size: small; + padding-left: .5em; +} + +h1 a { + color: black; +} + nav ul { margin: 0; padding: 0; @@ -106,6 +121,10 @@ nav ul li a { user-select: none; } +h1 a:visited { + color: black; +} + nav ul li a:hover { color: black; background-color: #efefef; @@ -120,7 +139,6 @@ nav.topbar ul li { display: inline-block; } - table { background-color: white; border-collapse: collapse; @@ -156,6 +174,11 @@ span.icon { font-size: 3em; } +.action-header { + display: flex; + align-items: center; +} + .action-header span.icon, tr.log-row span.icon { display: inline-block; @@ -189,6 +212,7 @@ h2 { display: inline-block; font-size: 1em; margin-top: 0; + flex-grow: 1; } div.entity h2 { @@ -404,6 +428,7 @@ pre { border: 1px solid gray; padding: 1em; min-height: 1em; + overflow: auto; } td.exit-code { @@ -442,7 +467,7 @@ div.toolbar * { @media screen and (width <= 600px) { fieldset { - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } label { @@ -460,6 +485,12 @@ div.toolbar * { margin-left: 0; margin-top: .6em; } + + dialog { + border-left: 0; + border-right: 0; + width: 100%; + } } @media (prefers-color-scheme: dark) { @@ -492,16 +523,21 @@ div.toolbar * { color: white; } - nav { - background-color: #111; - color: white; - } - footer, footer a { color: gray; } + + h1 a, h1 a:visited { + color: white; + } + + nav { + background-color: #111; + color: white; + } + nav ul li a:hover { background-color: #666; color: white;