feature: popupOnStart allows for many feedback options when actions are started (#227)

This commit is contained in:
James Read 2024-02-08 22:54:08 +01:00 committed by GitHub
parent dc0cf33d37
commit c12431d8a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 401 additions and 187 deletions

View File

@ -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: "&#x1F971"
- title: Broken Script (timeout)
shell: sleep 5
timeout: 5
icon: "&#x1F62A"
- 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

View File

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

View File

@ -11,6 +11,8 @@ var emojis = map[string]string{
"box": "📦",
"ashtonished": "😲",
"clock": "🕒",
"disk": "💽",
"logs": "🔍",
}
func lookupHTMLIcon(keyToLookup string) string {

View File

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

View File

@ -83,38 +83,47 @@
<div class = "action-header">
<span id = "execution-dialog-icon" class = "icon" role = "img"></span>
<h2>Log:
<h2>
<span id = "execution-dialog-title">?</span>
</h2>
<button id = "execution-dialog-toggle-size">&#128470;</button>
</div>
<div id = "execution-dialog-basics">
<strong>Started: </strong><span id = "execution-dialog-datetime-started">unknown</span>
</div>
<div id = "execution-dialog-details">
<p>
<strong>Finished: </strong><span id = "execution-dialog-datetime-finished">unknown</span>
</p>
<p>
<strong>Exit Code: </strong><span id = "execution-dialog-exit-code">unknown</span>
</p>
<p>
<strong>Status: </strong><span id = "execution-dialog-status">unknown</span>
</p>
</div>
<p>
<strong>Started: </strong><span id = "execution-dialog-datetime-started">unknown</span>.
<strong>Finished: </strong><span id = "execution-dialog-datetime-finished">unknown</span>
</p>
<p>
<strong>Exit Code: </strong><span id = "execution-dialog-exit-code">unknown</span>
</p>
<p>
<strong>Status: </strong><span id = "execution-dialog-status">unknown</span>
</p>
<div id = "execution-dialog-output">
<details>
<summary>Standard Output</summary>
<pre id = "execution-dialog-stdout">
?
</pre>
</details>
<details>
<summary>Output</summary>
<pre id = "execution-dialog-stdout">
?
</pre>
</details>
<details>
<summary>Errors</summary>
<pre id = "execution-dialog-stderr">
?
</pre>
</details>
<details>
<summary>Standard Error</summary>
<pre id = "execution-dialog-stderr">
?
</pre>
</details>
</div>
<form method = "dialog">
<button name = "Cancel">Close</button>
<div class = "buttons">
<button name = "Cancel">Close</button>
</div>
</form>
</dialog>

View File

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

View File

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

View File

@ -2,21 +2,51 @@
// the <dialog /> 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 {

View File

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

View File

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

View File

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

View File

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

View File

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