feat/uirefresh26 (#1050)

This commit is contained in:
James Read 2026-06-17 23:58:42 +01:00 committed by GitHub
commit de68ba7433
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1723 additions and 327 deletions

View File

@ -25,7 +25,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
- name: Install Antora toolchain
run: npm i antora@3.1.14 asciidoctor-kroki@0.18.1 @asciidoctor/tabs@1.0.0-beta.6

View File

@ -66,6 +66,14 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
- Review the pull request template at `.github/PULL_REQUEST_TEMPLATE.md`.
- When changing behaviour covered by a spec in `specs/`, ensure implementation and tests match the spec.
### Branch Naming
Use conventional-commit-style branch names with a type prefix, optional issue reference, and a short kebab-case description:
- `feat/[#123]-add-justification-prompt`
- `fix/[#456]-websocket-reconnect`
Do **not** use `feat-...`, `feature/...`, or other variants. Omit the `[#<issue>]` segment only when there is no linked issue.
### Troubleshooting
- API tests failing with content-type errors: ensure Connect handler is served under `/api/` and the client targets that base URL.
- Executor panics: check for nil `Binding/Action` and add guards in step functions.

View File

@ -42,6 +42,8 @@ EXPOSE 1337/tcp
COPY config.yaml /config
COPY var/entities/* /config/entities/
COPY examples/backupScript.sh /opt/backupScript.sh
RUN chmod 755 /opt/backupScript.sh
VOLUME /config
ARG TARGETPLATFORM

View File

@ -35,6 +35,8 @@ EXPOSE 1337/tcp
COPY config.yaml /config
COPY var/entities/* /config/entities/
COPY examples/backupScript.sh /opt/backupScript.sh
RUN chmod 755 /opt/backupScript.sh
VOLUME /config
COPY OliveTin /usr/bin/OliveTin

View File

@ -22,6 +22,27 @@ actionGroups:
#
# Docs: https://docs.olivetin.app/action_execution/create_your_first.html
actions:
# Lots of people use OliveTin to build web interfaces for their electronics
# projects. It's best to install OliveTin as a native package (eg, .deb), and
# then you can use either a python script or the `gpio` command.
- title: Toggle GPIO light
shell: gpioset gpiochip1 9=1 || true # The "|| true" is to ignore errors the demo environment doesn't have GPIO access.
icon: light
# Lots of people also use OliveTin to monitor their servers, like checking
# disk space, or checking logs. `onclick: execution-dialog` shows output.
- title: Check disk space
icon: disk
shell: df -h /
onclick: execution-dialog
# This uses `onclick: execution-dialog` to show a dialog with more
# information about the command that was run.
- title: Check shell history
shell: cat ~/.bash_history
icon: logs
onclick: execution-dialog
# Every action can still be run on demand from the web UI or API. The keys
# below are optional *additional* triggers (see each action and
# https://docs.olivetin.app/action_execution/ ).
@ -34,47 +55,19 @@ actions:
- title: Ping the Internet
shell: ping -c 3 1.1.1.1
icon: ping
popupOnStart: execution-dialog-stdout-only
onclick: execution-dialog
# https://docs.olivetin.app/action_execution/onstartup.html
execOnStartup: true
# This uses `popupOnStart: execution-dialog-stdout-only` to simply show just
# the command output.
- title: Check disk space
icon: disk
shell: df -h /media
popupOnStart: execution-dialog-stdout-only
# https://docs.olivetin.app/action_execution/onfilechanged.html
# Create the directory first, e.g. mkdir -p /tmp/olivetin-demo-file-changed
execOnFileChangedInDir:
- /tmp/olivetin-demo-file-changed
# This uses `popupOnStart: execution-dialog` to show a dialog with more
# information about the command that was run.
- title: check dmesg logs
shell: dmesg | tail
icon: logs
popupOnStart: execution-dialog
# https://docs.olivetin.app/action_execution/oncron.html — second example;
# the "date" action uses @hourly elsewhere in this file.
execOnCron:
- "0 3 * * 0"
# This uses `popupOnStart: execution-button` to display a mini button that
# links to the logs.
#
# You can also rate-limit actions too.
- title: date
shell: date
id: date
timeout: 6
icon: clock
popupOnStart: execution-button
- title: Sync Disks
shell: sync
id: syncdisks
icon: disk
onclick: execution-button
maxRate:
- limit: 3
duration: 1m
execOnCron:
- "@hourly"
# You are not limited to operating system commands, and of course you can run
# your own scripts. The backup-jobs action group limits how many backup-related
@ -86,7 +79,7 @@ actions:
groups: [ backup-jobs ]
timeout: 10
icon: backup
popupOnStart: execution-dialog
onclick: execution-dialog
# https://docs.olivetin.app/action_execution/oncalendar.html
execOnCalendarFile: examples/demo-olivetin-calendar.yaml
@ -95,7 +88,7 @@ actions:
groups: [ backup-jobs ]
timeout: 30
icon: backup
popupOnStart: execution-dialog
onclick: execution-dialog
# When you want to prompt users for input, that is when you should use
# `arguments` - this presents a popup dialog and asks for argument values.
@ -106,7 +99,7 @@ actions:
shell: ping {{ host }} -c {{ count }}
icon: ping
timeout: 100
popupOnStart: execution-dialog-stdout-only
onclick: history
# https://docs.olivetin.app/action_execution/onwebhook.html — POST to /webhooks
# with header X-OliveTin-Demo: ping-host (path and payload rules are documented).
execOnWebhook:
@ -148,7 +141,8 @@ actions:
# Docs: https://docs.olivetin.app/args/input_confirmation.html
- title: Delete old backups
icon: ashtonished
shell: rm -rf /opt/oldBackups/
justification: true
shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5
arguments:
- type: html
title: Description
@ -186,7 +180,7 @@ actions:
- title: "Setup easy SSH"
icon: ssh
shell: olivetin-setup-easy-ssh
popupOnStart: execution-dialog
onclick: execution-dialog
# Second webhook example: POST /webhooks?demo=setup-ssh
execOnWebhook:
- matchQuery:
@ -203,13 +197,6 @@ actions:
timeout: 1
shell: ssh -F /config/ssh/easy.cfg root@server1 'service httpd restart'
# Lots of people use OliveTin to build web interfaces for their electronics
# projects. It's best to install OliveTin as a native package (eg, .deb), and
# then you can use either a python script or the `gpio` command.
- title: Toggle GPIO light
shell: gpioset gpiochip1 9=1
icon: light
# There are several built-in shortcuts for the `icon` option, but you
# can also just specify any HTML, this includes any unicode character,
# or a <img = "..." /> link to a custom icon.
@ -274,6 +261,14 @@ actions:
entity: container
triggers: ["Update container entity file"]
- title: Long running action
shell: sleep 300
timeout: 300
icon: logs
onclick: execution-dialog
execOnCron:
- "@hourly"
# Lastly, you can hide actions from the web UI, this is useful for creating
# background helpers that execute only on startup or a cron, for updating
# entity files.

View File

@ -48,7 +48,7 @@
** xref:action_execution/aftercompletion.adoc[Execute after completion]
** xref:action_execution/triggers.adoc[Triggers]
* xref:action_customization/intro.adoc[Action Customization]
** xref:action_customization/popuponstart.adoc[Popup on start]
** xref:action_customization/popuponstart.adoc[On click]
** xref:action_customization/icons.adoc[Icons]
** xref:action_customization/timeouts.adoc[Timeouts]
** xref:action_customization/users.adoc[Users]

View File

@ -27,7 +27,6 @@ Explore specific customization options:
* xref:action_customization/concurrency.adoc[Control concurrency] - Limit simultaneous executions
* xref:action_customization/ratelimiting.adoc[Set rate limits] - Prevent actions from running too frequently
* xref:action_customization/enabledExpression.adoc[Enabled expressions] - Dynamically enable/disable actions based on entity state
* xref:action_customization/popuponstart.adoc[Configure popups] - Control popup behavior when actions start
* xref:action_customization/popuponstart.adoc[Configure on-click behavior] - Control what happens when an action is started
* xref:action_customization/savelogs.adoc[Save action logs] - Configure log retention for actions
* xref:action_customization/ids.adoc[Set action IDs] - Assign IDs for API access

View File

@ -1,10 +1,11 @@
[#popup-on-start]
= Popup on Start (Execution Feedback)
[#onclick]
= On Click (Execution Feedback)
OliveTin now has several options to control "execution feedback" when actions are started. This can be controlled on
a per-action basis, using the `popupOnStart` configuration option.
OliveTin has several options to control what happens when an action button is clicked and the execution starts. This can be controlled on a per-action basis using the `onclick` configuration option.
You can also set the default for OliveTin using the `defaultPopupOnStart` configuration option.
You can also set the default for OliveTin using the `defaultOnClick` configuration option.
NOTE: The older names `popupOnStart` and `defaultPopupOnStart` do exactly the same thing. OliveTin copies legacy values into `onclick` / `defaultOnClick` during config load. New configurations should use `onclick` and `defaultOnClick`.
== Big Flashy Buttons (default)
@ -13,7 +14,7 @@ You can also set the default for OliveTin using the `defaultPopupOnStart` config
----
actions:
- title: Ping the Internet
popupOnStart: default
onclick: default
----
This will also be the option that is used if no other values match.
@ -29,7 +30,7 @@ This can be useful for just displaying the output of a command, without too many
----
actions:
- title: Check disk space
popupOnStart: execution-dialog-stdout-only
onclick: execution-dialog-stdout-only
----
[NOTE]
@ -39,22 +40,22 @@ image::../popupOutputOnly.png[]
== Execution Dialog
The `execution-dialog` option for `popupOnStart` is simialr to the above `execution-dialog-stdout-only`, but it includes the start time, end time, exit code and the duration of time it took for the command to execute.
The `execution-dialog` option is simialr to the above `execution-dialog-stdout-only`, but it includes the start time, end time, exit code and the duration of time it took for the command to execute.
[source,yaml]
.`config.yaml`
----
actions:
- title: Check dmesg logs
popupOnStart: execution-dialog
onclick: execution-dialog
----
.Example of `popupOnStart: execution-dialog`
.Example of `onclick: execution-dialog`
image::../executionDialog.png[]
== Execution Buttons
This mode of `popupOnStart` will create a new button for each individual execution. This can be useful for actions that are executed again and again.
This mode will create a new button for each individual execution. This can be useful for actions that are executed again and again.
The text of the button (eg, "0s" in the screenshot below), is the time it took to execute the action in seconds.
@ -63,7 +64,7 @@ The text of the button (eg, "0s" in the screenshot below), is the time it took t
----
actions:
- title: date
popupOnStart: execution-button
onclick: execution-button
----
image::../executionButtons.png[]
@ -77,7 +78,5 @@ The `history` option opens the action details page for that binding when the exe
----
actions:
- title: Long-running job
popupOnStart: history
onclick: history
----

View File

@ -26,7 +26,7 @@ Setup an action as follows, to use the builtin olivetin-setup-easy-ssh script th
actions:
- title: Setup SSH
shell: olivetin-setup-easy-ssh
popupOnStart: execution-dialog
onclick: execution-dialog
----
[#ssh-easy-step-2]

View File

@ -1,7 +1,7 @@
[#customize-webui]
= Customize the web UI
The OliveTin web UI is reasonably customizable - parts of the page that you don't need can be hidden when they're not needed.
The OliveTin web UI is reasonably customizable - parts of the page that you don't need can be hidden when they're not needed.
== Page Title
@ -43,8 +43,8 @@ image::defaultUiHideNav.png[]
When enabled (the default), each action button can show a small icon indicating what happens when the action is started:
* **Popup dialog** — the action opens a popup (e.g. `popupOnStart: execution-dialog`)
* **Action history** — the action opens the action details page (e.g. `popupOnStart: history`)
* **Popup dialog** — the action opens a popup (e.g. `onclick: execution-dialog`)
* **Action history** — the action opens the action details page (e.g. `onclick: history`)
* **Argument form** — the action opens an argument form on start
* **Run in background** — the action runs without opening a dialog
@ -85,7 +85,7 @@ This is controlled by the **showVersionNumber** policy (in `defaultPolicy` or pe
[#show-new-versions]
== New version available - show/hide
You can disable the "new version" information in the footer - the default for `showNewVersions` is `true`;
You can disable the "new version" information in the footer - the default for `showNewVersions` is `true`;
.`config.yaml`
[source,yaml]
@ -135,7 +135,7 @@ This is considered an advanced feature, and is not recommended unless you like w
You can add custom JavaScript to OliveTin, which will be executed on every page load. This can be useful for adding custom functionality to the web UI.
1. The custom javascript should be in a file called `custom.js` and saved in `custom-webui/`, which should be in the same directory as your `config.yaml`.
2. You can put whatever code you like really in your `custom.js` file.
2. You can put whatever code you like really in your `custom.js` file.
3. Set `enableCustomJs: true` in your `config.yaml` to enable this feature.
4. Restart OliveTin. Note that the custom JavaScript will only be loaded once on startup, so if you are changing the custom JavaScript while OliveTin is running, you will need to restart OliveTin to see the changes.
@ -147,7 +147,7 @@ You can customize OliveTin with themes, but it's also possible to write your on
=== Writing a simple theme with a CSS change
You'll need to create a new theme, and let's assume our theme name is going to be called `uihack`. OliveTin themes are simply a directory of CSS and other assets. OliveTin looks for a directory called `custom-webui/themes/<theme-folder-name>` in the same directory as your `config.yaml` file.
You'll need to create a new theme, and let's assume our theme name is going to be called `uihack`. OliveTin themes are simply a directory of CSS and other assets. OliveTin looks for a directory called `custom-webui/themes/<theme-folder-name>` in the same directory as your `config.yaml` file.
Start by creating a directory called `custom-webui/themes/uihack` relative to the same directory as your `config.yaml` file. In this directory, create a file called `theme.css`.

View File

@ -53,7 +53,8 @@ All configuration options are covered in the solution sections
| `showNavigation` | Show (or hide) the sidebar/topbar section navigation. | `true` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI].
| `showNavigateOnStartIcons` | Show (or hide) the small icons on action buttons that indicate popup/argument/background behavior on start. | `true` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI].
| `sectionNavigationStyle` | The style of the section navigation. `sidebar`, `topbar` | `sidebar` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI].
| `defaultPopupOnStart` | The default popup to show on start. | `none` | Live reloadable | xref:action_customization/popuponstart.adoc[Popup On Start].
| `defaultOnClick` | The default on-click behavior when an action starts. | `nothing` | Live reloadable | xref:action_customization/popuponstart.adoc[On Click].
| `defaultPopupOnStart` | Legacy name for `defaultOnClick`. | - | Live reloadable | xref:action_customization/popuponstart.adoc[On Click].
| `defaultIconForActions` | The default icon string for actions (Unicode aliases such as `smile`, `hugeicons:NeutralIcon`, HTML, Iconify snippets, images, etc.). See xref:action_customization/icons.adoc[Icons]. | `hugeicons:CommandLineIcon` | Requires Restart | -
| `defaultIconForDirectories` | The default icon to use for directories. | `directory` | Requires Restart | -
| `defaultIconForBack` | The default icon to use for back (from directories). | `&laquo;` | Requires Restart | -

View File

@ -15,7 +15,7 @@ image::solution-k8s-hosted.png[]
=== Environment
* A Kubernetes cluster that is up and running.
* Kubernetes permissions to create a helm deployment, a `ClusterRole` and `ClusterRoleBinding`.
* Kubernetes permissions to create a helm deployment, a `ClusterRole` and `ClusterRoleBinding`.
* A configured Ingress Controller, exposing the for web interface
=== System
@ -58,7 +58,7 @@ rules:
--
====
Now that the `ClusterRole` has been created, we need to associate it to a `ServiceAccount` with a `ClusterRoleBinding`.
Now that the `ClusterRole` has been created, we need to associate it to a `ServiceAccount` with a `ClusterRoleBinding`.
Create a cluster role binding;
[tabs]
@ -82,7 +82,7 @@ Add `kubectl` job to OliveTin config with `kubectl edit cm/olivetin-config -n ol
apiVersion: v1
data:
config.yaml: |
defaultPopupOnStart: execution-dialog-output-only
defaultOnClick: execution-dialog-output-only
actions:
- title: get pods
@ -114,4 +114,3 @@ metadata:
----
Don't forget to restart the OliveTin deployment as good measure, because Kubernetes can be slow to update configmaps.

20
examples/backupScript.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
echo "Starting backup script"
# Demo behaviour: 50% finish in 9 seconds (within the 10s action timeout),
# 50% run for 15 seconds (typically times out).
if (( RANDOM % 2 == 0 )); then
maxFiles=9
echo "Demo: backup will finish in 9 seconds"
else
maxFiles=15
echo "Demo: backup will run for 15 seconds (may exceed the action timeout)"
fi
for fileIndex in $(seq 1 "$maxFiles"); do
echo "Backing up file: $fileIndex"
sleep 1
done
echo "All files backed up"

View File

@ -1,6 +1,11 @@
import { buttonResults } from '../resources/vue/stores/buttonResults.js'
import { rateLimits } from '../resources/vue/stores/rateLimits.js'
import { connectionState } from '../resources/vue/stores/connectionState.js'
import {
applyExecutionFinishedBindingState,
applyExecutionStartedBindingState
} from '../resources/vue/stores/bindingExecutionState.js'
import { cloneLogEntry } from '../resources/vue/utils/executionLogEvents.js'
const RECONNECT_DELAYS_MS = [200, 1000, 2000, 4000, 8000, 16000, 32000]
const BANNER_DELAY_MS = 2000
@ -52,8 +57,8 @@ export function connectEventStreamIfNeeded () {
export function initWebsocket () {
if (!listenersInitialized) {
window.addEventListener('EventOutputChunk', onOutputChunk)
window.addEventListener('EventExecutionStarted', onExecutionChanged)
window.addEventListener('EventExecutionFinished', onExecutionChanged)
window.addEventListener('EventExecutionStarted', onExecutionStarted)
window.addEventListener('EventExecutionFinished', onExecutionFinished)
window.addEventListener('pagehide', stopEventStream)
listenersInitialized = true
}
@ -250,20 +255,27 @@ function onOutputChunk (evt) {
}
export function applyExecutionLogEntry (logEntry) {
if (!logEntry?.executionTrackingId) {
const entry = cloneLogEntry(logEntry)
if (!entry?.executionTrackingId) {
return
}
buttonResults[logEntry.executionTrackingId] = logEntry
buttonResults[entry.executionTrackingId] = entry
if (logEntry.datetimeRateLimitExpires && logEntry.bindingId) {
const date = new Date(logEntry.datetimeRateLimitExpires.replace(' ', 'T') + 'Z')
rateLimits[logEntry.bindingId] = date.getTime() / 1000
} else if (logEntry.bindingId) {
rateLimits[logEntry.bindingId] = 0
if (entry.datetimeRateLimitExpires && entry.bindingId) {
const date = new Date(entry.datetimeRateLimitExpires.replace(' ', 'T') + 'Z')
rateLimits[entry.bindingId] = date.getTime() / 1000
} else if (entry.bindingId) {
rateLimits[entry.bindingId] = 0
}
}
function onExecutionChanged (evt) {
function onExecutionStarted (evt) {
applyExecutionLogEntry(evt.payload.logEntry)
applyExecutionStartedBindingState(evt.payload.logEntry)
}
function onExecutionFinished (evt) {
applyExecutionLogEntry(evt.payload.logEntry)
applyExecutionFinishedBindingState(evt.payload.logEntry)
}

View File

@ -90,6 +90,21 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
* @generated from field: repeated olivetin.api.v1.ActionWebhookExecHint exec_on_webhooks = 15;
*/
execOnWebhooks: ActionWebhookExecHint[];
/**
* @generated from field: bool justification = 16;
*/
justification: boolean;
/**
* @generated from field: bool has_running_instance = 17;
*/
hasRunningInstance: boolean;
/**
* @generated from field: bool has_queued_instance = 18;
*/
hasQueuedInstance: boolean;
};
/**
@ -400,6 +415,11 @@ export declare type StartActionRequest = Message<"olivetin.api.v1.StartActionReq
* @generated from field: string unique_tracking_id = 3;
*/
uniqueTrackingId: string;
/**
* @generated from field: string justification = 4;
*/
justification: string;
};
/**
@ -458,6 +478,11 @@ export declare type StartActionAndWaitRequest = Message<"olivetin.api.v1.StartAc
* @generated from field: repeated olivetin.api.v1.StartActionArgument arguments = 2;
*/
arguments: StartActionArgument[];
/**
* @generated from field: string justification = 3;
*/
justification: string;
};
/**
@ -690,6 +715,11 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & {
* @generated from field: string queued_for_group = 22;
*/
queuedForGroup: string;
/**
* @generated from field: string justification = 23;
*/
justification: string;
};
/**

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,12 @@
<template>
<div :id="`actionButton-${bindingId}`" role="none" class="action-button" @contextmenu.prevent="openActionDetails">
<span
v-if="showExecutionIndicator"
class="execution-indicator"
:class="executionIndicatorClass"
:title="executionIndicatorTitle"
aria-hidden="true"
></span>
<button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
:class="combinedClasses" @click="handleClick">
@ -29,9 +36,12 @@
<script setup>
import { buttonResults } from './stores/buttonResults'
import { rateLimits } from './stores/rateLimits'
import { bindingExecutionState, setBindingExecutionState } from './stores/bindingExecutionState'
import { connectionState } from './stores/connectionState'
import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
import { useRouter } from 'vue-router'
import { needsArgumentForm } from './utils/needsArgumentForm.js'
import { shouldSuppressPopupOnStartNavigation } from './utils/popupOnStartNavigation.js'
import { HugeiconsIcon } from '@hugeicons/vue'
import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
@ -93,6 +103,40 @@ const combinedClasses = computed(() => {
return classes
})
const hasRunningInstance = computed(() => {
const id = bindingId.value
return !!(id && bindingExecutionState[id]?.hasRunning)
})
const hasQueuedInstance = computed(() => {
const id = bindingId.value
return !!(id && bindingExecutionState[id]?.hasQueued)
})
const showExecutionIndicator = computed(() => {
return hasRunningInstance.value || hasQueuedInstance.value
})
const executionIndicatorClass = computed(() => {
if (hasRunningInstance.value) {
return 'execution-indicator-running'
}
if (hasQueuedInstance.value) {
return 'execution-indicator-queued'
}
return ''
})
const executionIndicatorTitle = computed(() => {
if (hasRunningInstance.value) {
return 'Running'
}
if (hasQueuedInstance.value) {
return 'Queued'
}
return ''
})
// Timestamps
const updateIterationTimestamp = ref(0)
@ -110,7 +154,7 @@ function constructFromJson(json) {
navigateOnStart.value = 'pop'
} else if (popupOnStart.value === 'history') {
navigateOnStart.value = 'hist'
} else if (props.actionData.arguments.length > 0) {
} else if (needsArgumentForm(props.actionData)) {
navigateOnStart.value = 'arg'
}
@ -127,6 +171,11 @@ function constructFromJson(json) {
// Also initialize the store so the watch picks it up
if (bindingId.value) {
rateLimits[bindingId.value] = rateLimitExpires.value
setBindingExecutionState(
bindingId.value,
!!json.hasRunningInstance,
!!json.hasQueuedInstance
)
}
updateRateLimitStatus()
}
@ -198,7 +247,7 @@ async function handleClick() {
openActionDetails()
return
}
if (props.actionData.arguments && props.actionData.arguments.length > 0) {
if (needsArgumentForm(props.actionData)) {
router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
} else {
await startAction()
@ -300,7 +349,11 @@ function onExecutionQueued(_logEntry) {
}
function onExecutionStarted(logEntry) {
if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
if (
popupOnStart.value &&
popupOnStart.value.includes('execution-dialog') &&
!shouldSuppressPopupOnStartNavigation(router)
) {
router.push(`/logs/${logEntry.executionTrackingId}`)
}
@ -397,6 +450,26 @@ defineExpose({
display: flex;
flex-direction: column;
flex-grow: 1;
position: relative;
}
.execution-indicator {
position: absolute;
top: 0.45em;
left: 0.45em;
width: 0.65em;
height: 0.65em;
border-radius: 50%;
z-index: 1;
pointer-events: none;
}
.execution-indicator-running {
background: #28a745;
}
.execution-indicator-queued {
background: #0d6efd;
}
.action-button button {

View File

@ -1,6 +1,7 @@
<template>
<div :class = "statusClass + ' annotation'">
<span>{{ statusText }}</span><span>{{ exitCodeText }}</span>
<router-link v-if="showQueueLink" to="/logs/queue" class="queue-status-link">{{ statusText }}</router-link>
<span v-else>{{ statusText }}</span><span>{{ exitCodeText }}</span>
</div>
</template>
@ -12,17 +13,23 @@ const props = defineProps({
logEntry: {
type: Object,
required: true
},
linkQueuedStatus: {
type: Boolean,
default: false
}
})
function isWaitingInQueue(logEntry) {
return logEntry &&
!logEntry.executionFinished &&
!logEntry.executionStarted
}
const statusText = computed(() => {
const logEntry = props.logEntry
if (!logEntry) return 'unknown'
if (logEntry.queued && !logEntry.executionFinished) {
return 'Queued'
}
if (logEntry.executionFinished) {
if (logEntry.blocked) {
return 'Blocked'
@ -31,9 +38,13 @@ const statusText = computed(() => {
} else {
return 'Completed'
}
} else {
return 'Still running...'
}
if (isWaitingInQueue(logEntry)) {
return 'Queued'
}
return 'Still running...'
})
const exitCodeText = computed(() => {
@ -51,6 +62,10 @@ const exitCodeText = computed(() => {
return ''
})
const showQueueLink = computed(() => {
return props.linkQueuedStatus && isWaitingInQueue(props.logEntry)
})
const statusClass = computed(() => {
const logEntry = props.logEntry
if (!logEntry) return ''
@ -86,5 +101,14 @@ const statusClass = computed(() => {
color: #ca79ff;
}
.queue-status-link {
color: #0d6efd;
text-decoration: none;
}
.queue-status-link:hover {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,27 @@
<template>
<span class="log-action-title">
<slot>{{ actionTitle }}</slot>
<span v-if="justification" class="log-justification">— {{ justification }}</span>
</span>
</template>
<script setup>
defineProps({
actionTitle: {
type: String,
default: ''
},
justification: {
type: String,
default: ''
}
})
</script>
<style scoped>
.log-justification {
font-size: 0.875rem;
color: #888;
font-weight: normal;
}
</style>

View File

@ -0,0 +1,79 @@
import { reactive } from 'vue'
import { buttonResults } from './buttonResults.js'
const INDICATOR_SHOW_DELAY_MS = 1000
export const bindingExecutionState = reactive({})
const pendingShowTimers = {}
function cancelPendingShowTimer (bindingId) {
const timer = pendingShowTimers[bindingId]
if (timer != null) {
clearTimeout(timer)
delete pendingShowTimers[bindingId]
}
}
function scheduleIndicatorShow (bindingId) {
cancelPendingShowTimer(bindingId)
pendingShowTimers[bindingId] = setTimeout(() => {
delete pendingShowTimers[bindingId]
recomputeBindingExecutionState(bindingId)
}, INDICATOR_SHOW_DELAY_MS)
}
export function setBindingExecutionState (bindingId, hasRunning, hasQueued) {
if (!bindingId) {
return
}
if (!hasRunning && !hasQueued) {
delete bindingExecutionState[bindingId]
return
}
bindingExecutionState[bindingId] = { hasRunning, hasQueued }
}
export function recomputeBindingExecutionState (bindingId) {
if (!bindingId) {
return
}
let hasRunning = false
let hasQueued = false
for (const trackingId in buttonResults) {
const entry = buttonResults[trackingId]
if (!entry || entry.bindingId !== bindingId || entry.executionFinished) {
continue
}
if (entry.executionStarted) {
hasRunning = true
} else {
hasQueued = true
}
}
setBindingExecutionState(bindingId, hasRunning, hasQueued)
}
export function applyExecutionStartedBindingState (logEntry) {
if (!logEntry?.bindingId || logEntry.executionFinished) {
return
}
scheduleIndicatorShow(logEntry.bindingId)
}
export function applyExecutionFinishedBindingState (logEntry) {
if (!logEntry?.bindingId) {
return
}
cancelPendingShowTimer(logEntry.bindingId)
recomputeBindingExecutionState(logEntry.bindingId)
}

View File

@ -0,0 +1,54 @@
export function cloneLogEntry (logEntry) {
if (!logEntry) {
return null
}
return {
...logEntry,
tags: Array.isArray(logEntry.tags) ? [...logEntry.tags] : logEntry.tags
}
}
export function getExecutionLogEntry (evt) {
const logEntry = evt?.payload?.logEntry ?? evt?.detail?.logEntry ?? null
return cloneLogEntry(logEntry)
}
export function updateLogEntryInList (entries, logEntry) {
if (!logEntry?.executionTrackingId || !entries) {
return false
}
const index = entries.findIndex(
item => item.executionTrackingId === logEntry.executionTrackingId
)
if (index < 0) {
return false
}
entries[index] = logEntry
return true
}
export function updateLogEntryInGroups (groups, logEntry) {
const entry = cloneLogEntry(logEntry)
if (!entry?.executionTrackingId || !groups) {
return null
}
for (const group of groups) {
const entries = group.entries || []
const index = entries.findIndex(
item => item.executionTrackingId === entry.executionTrackingId
)
if (index < 0) {
continue
}
const previous = entries[index]
entries[index] = entry
return { group, index, previous }
}
return null
}

View File

@ -0,0 +1,3 @@
export function needsArgumentForm (action) {
return (action?.arguments?.length > 0) || action?.justification
}

View File

@ -0,0 +1,4 @@
export function shouldSuppressPopupOnStartNavigation (router) {
const path = router?.currentRoute?.value?.path ?? window.location.pathname
return path === '/logs' || path === '/logs/queue'
}

View File

@ -53,7 +53,7 @@
</div>
<div v-show="filteredLogs.length > 0">
<table class="logs-table">
<table class="logs-table row-hover">
<thead>
<tr>
<th>Timestamp</th>
@ -82,7 +82,7 @@
</span>
</td>
<td class="exit-code">
<ActionStatusDisplay :logEntry="log" />
<ActionStatusDisplay :logEntry="log" :link-queued-status="true" />
</td>
</tr>
</tbody>
@ -107,6 +107,8 @@ import Section from 'picocrank/vue/components/Section.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import { requestReconnectNow } from '../../../js/websocket.js'
import { needsArgumentForm } from '../utils/needsArgumentForm.js'
import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
const route = useRoute()
const router = useRouter()
@ -266,13 +268,6 @@ function syncDurationTicker() {
}, 1000)
}
onUnmounted(() => {
if (durationTicker != null) {
clearInterval(durationTicker)
durationTicker = null
}
})
function handlePageChange(page) {
currentPage.value = page
fetchActionLogs()
@ -290,11 +285,16 @@ async function startAction() {
return
}
if (needsArgumentForm(action.value)) {
router.push(`/actionBinding/${action.value.bindingId}/argumentForm`)
return
}
try {
requestReconnectNow()
const args = {
"bindingId": action.value.bindingId,
"arguments": []
bindingId: action.value.bindingId,
arguments: []
}
const response = await window.client.startAction(args)
@ -305,9 +305,24 @@ async function startAction() {
}
}
function onExecutionEvent(evt) {
const logEntry = getExecutionLogEntry(evt)
if (!logEntry || logEntry.bindingId !== route.params.actionId) {
return
}
if (!updateLogEntryInList(logs.value, logEntry)) {
fetchActionLogs()
return
}
syncDurationTicker()
}
onMounted(() => {
fetchAction()
fetchActionLogs()
window.addEventListener('EventExecutionStarted', onExecutionEvent)
window.addEventListener('EventExecutionFinished', onExecutionEvent)
})
watch(
@ -319,6 +334,15 @@ watch(
},
{ immediate: false }
)
onUnmounted(() => {
window.removeEventListener('EventExecutionStarted', onExecutionEvent)
window.removeEventListener('EventExecutionFinished', onExecutionEvent)
if (durationTicker != null) {
clearInterval(durationTicker)
durationTicker = null
}
})
</script>
<style scoped>

View File

@ -40,7 +40,13 @@
<span class="argument-description" v-html="arg.description"></span>
</template>
</template>
<div v-else>
<template v-if="justificationRequired">
<label for="justification">Justification:</label>
<input id="justification" name="justification" type="text" v-model="justificationValue" required />
</template>
<div v-if="actionArguments.length === 0 && !justificationRequired">
<p>No arguments required</p>
</div>
@ -76,6 +82,8 @@ const formErrors = ref({})
const actionArguments = ref([])
const popupOnStart = ref('')
const formReady = ref(false)
const justificationRequired = ref(false)
const justificationValue = ref('')
let isComponentMounted = true
// Computed properties
@ -103,6 +111,8 @@ async function setup() {
icon.value = action.icon
popupOnStart.value = action.popupOnStart || ''
actionArguments.value = action.arguments || []
justificationRequired.value = action.justification || false
justificationValue.value = ''
argValues.value = {}
formErrors.value = {}
confirmationChecked.value = false
@ -306,19 +316,41 @@ function updateUrlWithArg(name, value) {
}
}
function shouldSendArgument(arg) {
if (!arg.name) {
return false
}
return arg.type !== 'html'
}
function formatArgumentValueForApi(arg, rawValue) {
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
return rawValue === '1' || rawValue === true || rawValue === 'true' ? '1' : '0'
}
if (rawValue === true) {
return '1'
}
if (rawValue === false) {
return '0'
}
return rawValue ?? ''
}
function getArgumentValues() {
const ret = []
for (const arg of actionArguments.value) {
let value = argValues.value[arg.name] || ''
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
value = value ? '1' : '0'
if (!shouldSendArgument(arg)) {
continue
}
ret.push({
name: arg.name,
value: value
value: formatArgumentValueForApi(arg, argValues.value[arg.name])
})
}
@ -394,6 +426,10 @@ async function startAction(actionArgs) {
uniqueTrackingId: getUniqueId()
}
if (justificationRequired.value) {
startActionArgs.justification = justificationValue.value
}
try {
requestReconnectNow()
const response = await window.client.startAction(startActionArgs)
@ -418,6 +454,13 @@ async function handleSubmit(event) {
}
// Set custom validity for required fields
if (justificationRequired.value && (!justificationValue.value || justificationValue.value.trim() === '')) {
const inputElement = document.getElementById('justification')
if (inputElement) {
inputElement.setCustomValidity('This field is required')
}
}
for (const arg of actionArguments.value) {
const value = argValues.value[arg.name]
const inputElement = document.getElementById(arg.name)

View File

@ -17,12 +17,17 @@
<div v-if="logEntry" class = "flex-row">
<dl class = "fg1">
<dt>Action</dt>
<dd>
<LogActionTitle :action-title="title" :justification="logEntry.justification" />
</dd>
<dt>Duration</dt>
<dd><span v-html="duration"></span></dd>
<dt>Status</dt>
<dd>
<ActionStatusDisplay :log-entry="logEntry" id = "execution-dialog-status" />
<dd class="execution-dialog-status">
<ActionStatusDisplay :log-entry="logEntry" :link-queued-status="true" />
</dd>
</dl>
<ActionIconGlyph class="icon" role="img" :glyph="icon" style="align-self: start" />
@ -64,6 +69,7 @@
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import LogActionTitle from '../components/LogActionTitle.vue'
import Section from 'picocrank/vue/components/Section.vue'
import { OutputTerminal } from '../../../js/OutputTerminal.js'
import { HugeiconsIcon } from '@hugeicons/vue'
@ -71,6 +77,7 @@ import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon } from '@hugeicons/core-fre
import { useRouter } from 'vue-router'
import { buttonResults } from '../stores/buttonResults'
import { requestReconnectNow } from '../../../js/websocket.js'
import { needsArgumentForm } from '../utils/needsArgumentForm.js'
const router = useRouter()
@ -178,10 +185,16 @@ async function rerunAction() {
}
try {
const binding = await window.client.getActionBinding({ bindingId })
if (needsArgumentForm(binding.action)) {
router.push(`/actionBinding/${bindingId}/argumentForm`)
return
}
requestReconnectNow()
const startActionArgs = {
"bindingId": bindingId,
"arguments": []
bindingId: bindingId,
arguments: []
}
const res = await window.client.startAction(startActionArgs)

View File

@ -42,7 +42,7 @@
</div>
<div v-show="logs.length > 0">
<table class="logs-table">
<table class="logs-table row-hover">
<thead>
<tr>
<th>
@ -70,7 +70,7 @@
<td>
<ActionIconGlyph class="icon" :glyph="log.actionIcon" />
<router-link :to="`/logs/${log.executionTrackingId}`">
{{ log.actionTitle }}
<LogActionTitle :action-title="log.actionTitle" :justification="log.justification" />
</router-link>
</td>
<td class="tags">
@ -123,6 +123,8 @@ import Section from 'picocrank/vue/components/Section.vue'
import { useI18n } from 'vue-i18n'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import LogActionTitle from '../components/LogActionTitle.vue'
import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
const route = useRoute()
const router = useRouter()
@ -254,8 +256,30 @@ function handlePageSizeChange(newPageSize) {
fetchLogs()
}
function onExecutionEvent(evt) {
const logEntry = getExecutionLogEntry(evt)
if (!logEntry) {
return
}
if (!updateLogEntryInList(logs.value, logEntry)) {
fetchLogs()
}
}
onMounted(() => {
updateDateFromRoute()
window.addEventListener('EventExecutionStarted', onExecutionEvent)
window.addEventListener('EventExecutionFinished', onExecutionEvent)
})
onUnmounted(() => {
window.removeEventListener('EventExecutionStarted', onExecutionEvent)
window.removeEventListener('EventExecutionFinished', onExecutionEvent)
if (fetchTimer) {
clearTimeout(fetchTimer)
fetchTimer = null
}
})
onUnmounted(() => {

View File

@ -11,86 +11,240 @@
<p class="padding">{{ t('logs.queue-page-description') }}</p>
<div v-if="groups.length > 0" class="queue-groups padding">
<section v-for="group in groups" :key="group.bindingId" class="queue-group">
<header class="queue-group-header">
<ActionIconGlyph class="icon" :glyph="group.actionIcon" />
<div class="queue-group-title">
<h3>{{ group.actionTitle }}</h3>
<p v-if="group.entityPrefix" class="queue-entity">
{{ t('logs.queue-entity') }}: {{ group.entityPrefix }}
</p>
</div>
<span class="queue-group-limit annotation">
{{ t('logs.queue-group-active', { active: group.activeCount, max: group.maxConcurrent }) }}
</span>
</header>
<table class="logs-table">
<thead>
<tr>
<th>{{ t('logs.timestamp') }}</th>
<th>{{ t('logs.metadata') }}</th>
<th>{{ t('logs.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in group.entries" :key="entry.executionTrackingId" class="log-row">
<td class="timestamp">{{ formatTimestamp(entry.datetimeStarted) }}</td>
<td class="tags">
<span class="annotation">
<span class="annotation-key">User:</span>
<span class="annotation-val">{{ entry.user }}</span>
</span>
<span v-if="entry.tags && entry.tags.length > 0" class="tag-list">
<span v-for="tag in entry.tags" :key="tag" class="tag">{{ tag }}</span>
</span>
<span class="annotation">
<span class="annotation-key">ID:</span>
<router-link :to="`/logs/${entry.executionTrackingId}`">
{{ entry.executionTrackingId }}
</router-link>
</span>
</td>
<td class="exit-code">
<span class="annotation">
<span class="queue-position">{{ t('logs.queue-position', { position: index + 1 }) }}</span>
<span :class="queueStatusClass(entry)">{{ queueStatusText(entry) }}</span>
</span>
</td>
</tr>
</tbody>
</table>
</section>
</div>
<div v-else-if="!loading" class="empty-state padding">
<div v-if="groups.length === 0 && !loading" class="empty-state padding">
<p>{{ t('logs.queue-empty') }}</p>
<router-link to="/logs">{{ t('logs.back-to-list') }}</router-link>
</div>
</Section>
<section
v-for="group in groups"
:key="group.bindingId"
class="with-header-and-content queue-group-section"
>
<div class="section-header flex-row">
<div class="fg1 queue-group-heading">
<ActionIconGlyph class="queue-group-icon" :glyph="group.actionIcon" />
<div class="queue-group-title">
<h2 :title="group.entityPrefix ? `${t('logs.queue-entity')}: ${group.entityPrefix}` : ''">
{{ group.actionTitle }}
</h2>
<p v-if="group.entityPrefix" class="queue-entity">
{{ t('logs.queue-entity') }}: {{ group.entityPrefix }}
</p>
</div>
</div>
<div role="toolbar" class="queue-group-toolbar">
<router-link
v-if="group.bindingId"
:to="`/action/${group.bindingId}`"
class="button neutral"
:title="t('logs.queue-action-details')"
>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.31-8.86c-1.77-.45-2.34-.94-2.34-1.67 0-.84.79-1.43 2.1-1.43 1.38 0 1.9.66 1.94 1.64h1.71c-.05-1.34-.87-2.57-2.49-2.97V5H10.9v1.69c-1.51.32-2.72 1.3-2.72 2.81 0 1.79 1.49 2.69 3.66 3.21 1.95.46 2.34 1.22 2.34 1.8 0 .53-.39 1.39-2.1 1.39-1.6 0-2.05-.56-2.13-1.45H8.04c.08 1.5 1.18 2.37 2.82 2.69V19h2.34v-1.63c1.65-.35 2.48-1.24 2.48-2.77-.01-1.88-1.51-2.87-3.7-3.23z"/>
</svg>
{{ t('logs.queue-action-details') }}
</router-link>
<span class="queue-group-limit annotation">
{{ t('logs.queue-group-active', { active: group.activeCount, max: group.maxConcurrent }) }}
</span>
</div>
</div>
<div class="section-content">
<table class="logs-table row-hover">
<thead>
<tr>
<th>{{ t('logs.timestamp') }}</th>
<th>{{ t('logs.metadata') }}</th>
<th>{{ t('logs.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in group.entries" :key="entry.executionTrackingId" class="log-row">
<td class="timestamp">{{ formatTimestamp(entry.datetimeStarted) }}</td>
<td class="tags">
<span class="annotation">
<span class="annotation-key">User:</span>
<span class="annotation-val">{{ entry.user }}</span>
</span>
<span v-if="entry.tags && entry.tags.length > 0" class="tag-list">
<span v-for="tag in entry.tags" :key="tag" class="tag">{{ tag }}</span>
</span>
<span class="annotation">
<span class="annotation-key">ID:</span>
<router-link :to="`/logs/${entry.executionTrackingId}`">
{{ entry.executionTrackingId }}
</router-link>
</span>
</td>
<td class="exit-code">
<span v-if="!entry.executionFinished" class="queue-position">{{ t('logs.queue-position', { position: index + 1 }) }}</span>
<ActionStatusDisplay :logEntry="entry" />
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import Section from 'picocrank/vue/components/Section.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import { useI18n } from 'vue-i18n'
import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
const { t } = useI18n()
const groups = ref([])
const loading = ref(false)
function queueStatusText (entry) {
if (entry.executionStarted) {
return t('logs.queue-running')
function collectCompletedEntries (currentGroups) {
const completed = []
for (const group of currentGroups || []) {
for (const entry of group.entries || []) {
if (entry.executionFinished) {
completed.push(cloneLogEntry(entry))
}
}
}
return t('logs.queue-waiting')
return completed
}
function queueStatusClass (entry) {
return entry.executionStarted ? 'queue-status-running' : 'queue-status-waiting'
function sortGroupEntries (entries) {
entries.sort((left, right) => {
if (left.executionFinished !== right.executionFinished) {
return left.executionFinished ? 1 : -1
}
return (left.datetimeStarted || '').localeCompare(right.datetimeStarted || '')
})
}
function sortGroups (groupList) {
groupList.sort((left, right) => {
const byTitle = (left.actionTitle || '').localeCompare(right.actionTitle || '')
if (byTitle !== 0) {
return byTitle
}
return (left.entityPrefix || '').localeCompare(right.entityPrefix || '')
})
}
function mergeCompletedEntries (apiGroups, completedEntries) {
const merged = (apiGroups || []).map(group => ({
...group,
entries: [...(group.entries || [])]
}))
for (const entry of completedEntries) {
const alreadyPresent = merged.some(group =>
group.entries.some(item => item.executionTrackingId === entry.executionTrackingId)
)
if (alreadyPresent) {
continue
}
let group = merged.find(item => item.bindingId === entry.bindingId)
if (!group) {
group = {
bindingId: entry.bindingId,
actionTitle: entry.actionTitle,
actionIcon: entry.actionIcon,
entityPrefix: '',
maxConcurrent: 0,
activeCount: 0,
entries: []
}
merged.push(group)
}
group.entries.push(entry)
}
for (const group of merged) {
sortGroupEntries(group.entries)
}
sortGroups(merged)
return merged
}
function applyQueueEntryUpdate (logEntry, afterUpdate) {
const result = updateLogEntryInGroups(groups.value, logEntry)
if (!result) {
return false
}
if (afterUpdate) {
afterUpdate(result)
}
sortGroupEntries(result.group.entries)
return true
}
function adjustActiveCountOnStart (group, previous, logEntry) {
const wasActive = !previous.executionFinished
const isActive = !logEntry.executionFinished
if (!wasActive && isActive) {
group.activeCount++
}
}
function insertActiveQueueEntry (logEntry) {
if (!logEntry?.bindingId || !logEntry.executionTrackingId || logEntry.executionFinished) {
fetchQueue()
return
}
let group = groups.value.find(item => item.bindingId === logEntry.bindingId)
if (!group) {
group = {
bindingId: logEntry.bindingId,
actionTitle: logEntry.actionTitle || '',
actionIcon: logEntry.actionIcon || '',
entityPrefix: logEntry.entityPrefix || '',
maxConcurrent: 0,
activeCount: 0,
entries: []
}
groups.value.push(group)
}
group.entries.push(cloneLogEntry(logEntry))
adjustActiveCountOnStart(group, { executionFinished: true }, logEntry)
sortGroupEntries(group.entries)
sortGroups(groups.value)
}
function onExecutionStarted (evt) {
const logEntry = getExecutionLogEntry(evt)
if (!logEntry) {
return
}
if (!applyQueueEntryUpdate(logEntry, ({ group, previous }) => {
adjustActiveCountOnStart(group, previous, logEntry)
})) {
insertActiveQueueEntry(logEntry)
}
}
function onExecutionFinished (evt) {
const logEntry = getExecutionLogEntry(evt)
if (!logEntry) {
return
}
applyQueueEntryUpdate(logEntry, ({ group, previous }) => {
const wasActive = !previous.executionFinished
if (wasActive && logEntry.executionFinished && group.activeCount > 0) {
group.activeCount--
}
})
}
function formatTimestamp (timestamp) {
@ -107,8 +261,9 @@ function formatTimestamp (timestamp) {
async function fetchQueue () {
loading.value = true
try {
const completedEntries = collectCompletedEntries(groups.value)
const response = await window.client.getExecutionQueue({})
groups.value = response.groups || []
groups.value = mergeCompletedEntries(response.groups || [], completedEntries)
} catch (err) {
console.error('Failed to fetch execution queue:', err)
window.showBigError('fetch-queue', 'getting execution queue', err, false)
@ -119,45 +274,37 @@ async function fetchQueue () {
onMounted(() => {
fetchQueue()
window.addEventListener('EventExecutionStarted', fetchQueue)
window.addEventListener('EventExecutionFinished', fetchQueue)
window.addEventListener('EventExecutionStarted', onExecutionStarted)
window.addEventListener('EventExecutionFinished', onExecutionFinished)
})
onUnmounted(() => {
window.removeEventListener('EventExecutionStarted', fetchQueue)
window.removeEventListener('EventExecutionFinished', fetchQueue)
window.removeEventListener('EventExecutionStarted', onExecutionStarted)
window.removeEventListener('EventExecutionFinished', onExecutionFinished)
})
</script>
<style scoped>
.queue-groups {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.queue-group {
border: 1px solid var(--border-color, #ccc);
border-radius: 0.5rem;
overflow: hidden;
}
.queue-group-header {
.queue-group-heading {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--section-background, #f8f9fa);
border-bottom: 1px solid var(--border-color, #ccc);
min-width: 0;
}
.queue-group-title {
flex: 1;
}
.queue-group-title h3 {
.queue-group-title h2 {
margin: 0;
font-size: 1rem;
}
.queue-group-toolbar {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.queue-group-limit {
white-space: nowrap;
}
.queue-entity {
@ -166,12 +313,9 @@ onUnmounted(() => {
color: #666;
}
.queue-group-limit {
white-space: nowrap;
}
.icon {
.queue-group-icon {
font-size: 1.5em;
flex-shrink: 0;
}
.timestamp {
@ -185,16 +329,14 @@ onUnmounted(() => {
font-size: smaller;
}
.exit-code {
display: flex;
align-items: center;
gap: 0.5rem;
}
.queue-position {
margin-right: 0.5rem;
}
.queue-status-running {
color: var(--karma-warning-fg, #856404);
}
.queue-status-waiting {
color: #0d6efd;
white-space: nowrap;
}
.empty-state {

View File

@ -5,6 +5,8 @@ import { Condition } from 'selenium-webdriver'
export const DEFAULT_UI_WAIT_MS = 3000
const executionDialogStatusBy = By.css('.execution-dialog-status')
export async function getActionButtons () {
// Currently, only the active dashboard's contents are rendered,
// so we don't need to scope the selector by dashboard title.
@ -91,13 +93,13 @@ export async function waitForArgumentFormReady(timeoutMs = DEFAULT_UI_WAIT_MS) {
export async function waitForExecutionComplete(timeoutMs = DEFAULT_UI_WAIT_MS) {
await webdriver.wait(new Condition('wait for execution status', async () => {
const statusElements = await webdriver.findElements(By.id('execution-dialog-status'))
const statusElements = await webdriver.findElements(executionDialogStatusBy)
return statusElements.length > 0
}), timeoutMs)
await webdriver.wait(new Condition('wait for execution to finish', async () => {
try {
const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
const statusElement = await webdriver.findElement(executionDialogStatusBy)
const statusText = await statusElement.getText()
return !statusText.includes('Still running')
} catch (e) {
@ -163,7 +165,7 @@ export async function getNavigationLinks() {
export async function requireExecutionDialogStatus (webdriver, expected) {
await webdriver.wait(new Condition('wait for action to be running', async function () {
const dialogStatus = await webdriver.findElement(By.id('execution-dialog-status'))
const dialogStatus = await webdriver.findElement(executionDialogStatusBy)
const actual = await dialogStatus.getText()
if (actual === expected) {

View File

@ -40,7 +40,7 @@ describe('config: entityFilesWithLongIntsUseStandardForm', function () {
await waitForLogsPage()
await waitForExecutionComplete()
const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
const statusElement = await webdriver.findElement(By.css('.execution-dialog-status'))
const statusText = await statusElement.getText()
// The status should indicate success (not "Executing..." or "Failed")

View File

@ -119,6 +119,7 @@
"logs.no-logs-to-display": "There are no logs to display.",
"logs.page-description": "This is a list of logs from actions that have been executed. Use the filter box for search terms and expressions.",
"logs.queue": "Queue",
"logs.queue-action-details": "Action Details",
"logs.queue-empty": "There are no active or waiting executions right now.",
"logs.queue-entity": "Entity",
"logs.queue-group-active": "{active} active (max {max})",

View File

@ -47,6 +47,7 @@ translations:
logs.queue-running: Running
logs.queue-position: "#{position}"
logs.queue-entity: Entity
logs.queue-action-details: Action Details
diagnostics.get-support: Get support
diagnostics.get-support-description: If you are having problems with OliveTin and want to raise a support request, it would be very helpful to include a sosreport from this page.
diagnostics.where-to-find-help: Where to find help

View File

@ -20,6 +20,9 @@ message Action {
repeated string exec_on_file_changed_in_dir = 13;
string exec_on_calendar_file = 14;
repeated ActionWebhookExecHint exec_on_webhooks = 15;
bool justification = 16;
bool has_running_instance = 17;
bool has_queued_instance = 18;
}
message ActionWebhookExecHint {
@ -95,6 +98,7 @@ message StartActionRequest {
repeated StartActionArgument arguments = 2;
string unique_tracking_id = 3;
string justification = 4;
}
message StartActionArgument {
@ -110,6 +114,8 @@ message StartActionAndWaitRequest {
string action_id = 1;
repeated StartActionArgument arguments = 2;
string justification = 3;
}
message StartActionAndWaitResponse {
@ -160,6 +166,7 @@ message LogEntry {
string binding_id = 20; // Binding ID for matching rate limits to action buttons
bool queued = 21;
string queued_for_group = 22;
string justification = 23;
}
message GetLogsResponse {

View File

@ -38,6 +38,9 @@ type Action struct {
ExecOnFileChangedInDir []string `protobuf:"bytes,13,rep,name=exec_on_file_changed_in_dir,json=execOnFileChangedInDir,proto3" json:"exec_on_file_changed_in_dir,omitempty"`
ExecOnCalendarFile string `protobuf:"bytes,14,opt,name=exec_on_calendar_file,json=execOnCalendarFile,proto3" json:"exec_on_calendar_file,omitempty"`
ExecOnWebhooks []*ActionWebhookExecHint `protobuf:"bytes,15,rep,name=exec_on_webhooks,json=execOnWebhooks,proto3" json:"exec_on_webhooks,omitempty"`
Justification bool `protobuf:"varint,16,opt,name=justification,proto3" json:"justification,omitempty"`
HasRunningInstance bool `protobuf:"varint,17,opt,name=has_running_instance,json=hasRunningInstance,proto3" json:"has_running_instance,omitempty"`
HasQueuedInstance bool `protobuf:"varint,18,opt,name=has_queued_instance,json=hasQueuedInstance,proto3" json:"has_queued_instance,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -177,6 +180,27 @@ func (x *Action) GetExecOnWebhooks() []*ActionWebhookExecHint {
return nil
}
func (x *Action) GetJustification() bool {
if x != nil {
return x.Justification
}
return false
}
func (x *Action) GetHasRunningInstance() bool {
if x != nil {
return x.HasRunningInstance
}
return false
}
func (x *Action) GetHasQueuedInstance() bool {
if x != nil {
return x.HasQueuedInstance
}
return false
}
type ActionWebhookExecHint struct {
state protoimpl.MessageState `protogen:"open.v1"`
Template string `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
@ -802,6 +826,7 @@ type StartActionRequest struct {
BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"`
Arguments []*StartActionArgument `protobuf:"bytes,2,rep,name=arguments,proto3" json:"arguments,omitempty"`
UniqueTrackingId string `protobuf:"bytes,3,opt,name=unique_tracking_id,json=uniqueTrackingId,proto3" json:"unique_tracking_id,omitempty"`
Justification string `protobuf:"bytes,4,opt,name=justification,proto3" json:"justification,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -857,6 +882,13 @@ func (x *StartActionRequest) GetUniqueTrackingId() string {
return ""
}
func (x *StartActionRequest) GetJustification() string {
if x != nil {
return x.Justification
}
return ""
}
type StartActionArgument struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
@ -957,6 +989,7 @@ type StartActionAndWaitRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ActionId string `protobuf:"bytes,1,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"`
Arguments []*StartActionArgument `protobuf:"bytes,2,rep,name=arguments,proto3" json:"arguments,omitempty"`
Justification string `protobuf:"bytes,3,opt,name=justification,proto3" json:"justification,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1005,6 +1038,13 @@ func (x *StartActionAndWaitRequest) GetArguments() []*StartActionArgument {
return nil
}
func (x *StartActionAndWaitRequest) GetJustification() string {
if x != nil {
return x.Justification
}
return ""
}
type StartActionAndWaitResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
LogEntry *LogEntry `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"`
@ -1315,6 +1355,7 @@ type LogEntry struct {
BindingId string `protobuf:"bytes,20,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` // Binding ID for matching rate limits to action buttons
Queued bool `protobuf:"varint,21,opt,name=queued,proto3" json:"queued,omitempty"`
QueuedForGroup string `protobuf:"bytes,22,opt,name=queued_for_group,json=queuedForGroup,proto3" json:"queued_for_group,omitempty"`
Justification string `protobuf:"bytes,23,opt,name=justification,proto3" json:"justification,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1489,6 +1530,13 @@ func (x *LogEntry) GetQueuedForGroup() string {
return ""
}
func (x *LogEntry) GetJustification() string {
if x != nil {
return x.Justification
}
return ""
}
type GetLogsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"`
@ -4275,7 +4323,7 @@ var File_olivetin_api_v1_olivetin_proto protoreflect.FileDescriptor
const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\n" +
"\x1eolivetin/api/v1/olivetin.proto\x12\x0folivetin.api.v1\"\x89\x05\n" +
"\x1eolivetin/api/v1/olivetin.proto\x12\x0folivetin.api.v1\"\x91\x06\n" +
"\x06Action\x12\x1d\n" +
"\n" +
"binding_id\x18\x01 \x01(\tR\tbindingId\x12\x14\n" +
@ -4294,7 +4342,10 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\x1bexec_on_file_created_in_dir\x18\f \x03(\tR\x16execOnFileCreatedInDir\x12;\n" +
"\x1bexec_on_file_changed_in_dir\x18\r \x03(\tR\x16execOnFileChangedInDir\x121\n" +
"\x15exec_on_calendar_file\x18\x0e \x01(\tR\x12execOnCalendarFile\x12P\n" +
"\x10exec_on_webhooks\x18\x0f \x03(\v2&.olivetin.api.v1.ActionWebhookExecHintR\x0eexecOnWebhooks\"\x8a\x03\n" +
"\x10exec_on_webhooks\x18\x0f \x03(\v2&.olivetin.api.v1.ActionWebhookExecHintR\x0eexecOnWebhooks\x12$\n" +
"\rjustification\x18\x10 \x01(\bR\rjustification\x120\n" +
"\x14has_running_instance\x18\x11 \x01(\bR\x12hasRunningInstance\x12.\n" +
"\x13has_queued_instance\x18\x12 \x01(\bR\x11hasQueuedInstance\"\x8a\x03\n" +
"\x15ActionWebhookExecHint\x12\x1a\n" +
"\btemplate\x18\x01 \x01(\tR\btemplate\x12\x1d\n" +
"\n" +
@ -4359,20 +4410,22 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\ventity_type\x18\a \x01(\tR\n" +
"entityType\x12\x1d\n" +
"\n" +
"entity_key\x18\b \x01(\tR\tentityKey\"\xa5\x01\n" +
"entity_key\x18\b \x01(\tR\tentityKey\"\xcb\x01\n" +
"\x12StartActionRequest\x12\x1d\n" +
"\n" +
"binding_id\x18\x01 \x01(\tR\tbindingId\x12B\n" +
"\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\x12,\n" +
"\x12unique_tracking_id\x18\x03 \x01(\tR\x10uniqueTrackingId\"?\n" +
"\x12unique_tracking_id\x18\x03 \x01(\tR\x10uniqueTrackingId\x12$\n" +
"\rjustification\x18\x04 \x01(\tR\rjustification\"?\n" +
"\x13StartActionArgument\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value\"I\n" +
"\x13StartActionResponse\x122\n" +
"\x15execution_tracking_id\x18\x02 \x01(\tR\x13executionTrackingId\"|\n" +
"\x15execution_tracking_id\x18\x02 \x01(\tR\x13executionTrackingId\"\xa2\x01\n" +
"\x19StartActionAndWaitRequest\x12\x1b\n" +
"\taction_id\x18\x01 \x01(\tR\bactionId\x12B\n" +
"\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\"T\n" +
"\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\x12$\n" +
"\rjustification\x18\x03 \x01(\tR\rjustification\"T\n" +
"\x1aStartActionAndWaitResponse\x126\n" +
"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"6\n" +
"\x17StartActionByGetRequest\x12\x1b\n" +
@ -4388,7 +4441,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\vdate_filter\x18\x02 \x01(\tR\n" +
"dateFilter\x12\x1b\n" +
"\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x16\n" +
"\x06filter\x18\x04 \x01(\tR\x06filter\"\xcb\x05\n" +
"\x06filter\x18\x04 \x01(\tR\x06filter\"\xf1\x05\n" +
"\bLogEntry\x12)\n" +
"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
@ -4413,7 +4466,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\n" +
"binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" +
"\x06queued\x18\x15 \x01(\bR\x06queued\x12(\n" +
"\x10queued_for_group\x18\x16 \x01(\tR\x0equeuedForGroup\"\xca\x01\n" +
"\x10queued_for_group\x18\x16 \x01(\tR\x0equeuedForGroup\x12$\n" +
"\rjustification\x18\x17 \x01(\tR\rjustification\"\xca\x01\n" +
"\x0fGetLogsResponse\x12-\n" +
"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" +
"\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" +

View File

@ -150,35 +150,30 @@ func (api *oliveTinAPI) killActionByTrackingId(user *authpublic.AuthenticatedUse
}
func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.StartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
args := make(map[string]string)
for _, arg := range req.Msg.Arguments {
args[arg.Name] = arg.Value
}
pair := api.executor.FindBindingByID(req.Msg.BindingId)
if pair == nil || pair.Action == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
pair, err := api.findBindingByIDOrNotFound(req.Msg.BindingId)
if err != nil {
return nil, err
}
authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
if err := validateJustificationRequired(pair.Action, req.Msg.Justification, authenticatedUser); err != nil {
return nil, connectInvalidJustification(err)
}
execReq := executor.ExecutionRequest{
Binding: pair,
TrackingID: req.Msg.UniqueTrackingId,
Arguments: args,
Arguments: startActionArgumentsFromProto(req.Msg.Arguments),
Justification: req.Msg.Justification,
AuthenticatedUser: authenticatedUser,
Cfg: api.cfg,
}
api.executor.ExecRequest(&execReq)
ret := &apiv1.StartActionResponse{
return connect.NewResponse(&apiv1.StartActionResponse{
ExecutionTrackingId: execReq.TrackingID,
}
return connect.NewResponse(ret), nil
}), nil
}
func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1.PasswordHashRequest]) (*connect.Response[apiv1.PasswordHashResponse], error) {
@ -259,11 +254,12 @@ func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[api
return response, nil
}
func (api *oliveTinAPI) startActionAndWaitRun(binding *executor.ActionBinding, args map[string]string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
func (api *oliveTinAPI) startActionAndWaitRun(binding *executor.ActionBinding, args map[string]string, justification string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
execReq := executor.ExecutionRequest{
Binding: binding,
TrackingID: uuid.NewString(),
Arguments: args,
Justification: justification,
AuthenticatedUser: user,
Cfg: api.cfg,
}
@ -280,19 +276,22 @@ func (api *oliveTinAPI) findBindingOrNotFound(actionId string) (*executor.Action
return binding, nil
}
func (api *oliveTinAPI) findBindingByIDOrNotFound(bindingId string) (*executor.ActionBinding, error) {
return api.findBindingOrNotFound(bindingId)
}
func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
if err != nil {
return nil, err
}
args := make(map[string]string)
for _, arg := range req.Msg.Arguments {
args[arg.Name] = arg.Value
}
user := auth.UserFromApiCall(ctx, req, api.cfg)
if err := validateJustificationRequired(binding.Action, req.Msg.Justification, user); err != nil {
return nil, connectInvalidJustification(err)
}
internalLogEntry, ok := api.startActionAndWaitRun(binding, args, user)
internalLogEntry, ok := api.startActionAndWaitRun(binding, startActionArgumentsFromProto(req.Msg.Arguments), req.Msg.Justification, user)
if !ok {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
}
@ -388,6 +387,7 @@ func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry
User: logEntry.Username,
BindingId: logEntry.GetBindingId(),
DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
Justification: logEntry.Justification,
}
if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
@ -531,11 +531,7 @@ func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedU
}
return &apiv1.GetActionBindingResponse{
Action: buildAction(binding, &DashboardRenderRequest{
cfg: api.cfg,
AuthenticatedUser: user,
ex: api.executor,
}),
Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
}, nil
}
@ -576,13 +572,15 @@ func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser)
}
func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
return &DashboardRenderRequest{
rr := &DashboardRenderRequest{
AuthenticatedUser: user,
cfg: api.cfg,
ex: api.executor,
EntityType: entityType,
EntityKey: entityKey,
}
populateActiveBindingStates(rr)
return rr
}
func (api *oliveTinAPI) isDefaultDashboard(title string) bool {
@ -1515,30 +1513,16 @@ func serializeEntityFields(data any) map[string]string {
}
func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
ret := &apiv1.StartActionResponse{
ExecutionTrackingId: req.Msg.ExecutionTrackingId,
execReqLogEntry, err := api.restartActionLogEntry(req.Msg.ExecutionTrackingId)
if err != nil {
return nil, err
}
execReqLogEntry, found := api.executor.GetLog(req.Msg.ExecutionTrackingId)
if !found {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", req.Msg.ExecutionTrackingId))
}
if execReqLogEntry.Binding == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", req.Msg.ExecutionTrackingId))
}
action := execReqLogEntry.Binding.Action
if action == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", req.Msg.ExecutionTrackingId))
if execReqLogEntry.Binding.Action.Justification {
return nil, restartRequiresJustificationError()
}
authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
// TrackingID is deliberately not passed to the executor, so that it generates a new one for the restarted execution.
// This is because the old execution (identified by the old TrackingID) is already used.
execReq := executor.ExecutionRequest{
Binding: execReqLogEntry.Binding,
Arguments: make(map[string]string),
@ -1548,8 +1532,26 @@ func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv
api.executor.ExecRequest(&execReq)
ret.ExecutionTrackingId = execReq.TrackingID
return connect.NewResponse(ret), nil
return connect.NewResponse(&apiv1.StartActionResponse{
ExecutionTrackingId: execReq.TrackingID,
}), nil
}
func (api *oliveTinAPI) restartActionLogEntry(executionTrackingId string) (*executor.InternalLogEntry, error) {
execReqLogEntry, found := api.executor.GetLog(executionTrackingId)
if !found {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", executionTrackingId))
}
if execReqLogEntry.Binding == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", executionTrackingId))
}
if execReqLogEntry.Binding.Action == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", executionTrackingId))
}
return execReqLogEntry, nil
}
func newServer(ex *executor.Executor) *oliveTinAPI {

View File

@ -16,12 +16,56 @@ import (
"github.com/OliveTin/OliveTin/internal/tpl"
)
type bindingActiveState struct {
hasRunning bool
hasQueued bool
}
type DashboardRenderRequest struct {
AuthenticatedUser *authpublic.AuthenticatedUser
cfg *config.Config
ex *executor.Executor
EntityType string
EntityKey string
AuthenticatedUser *authpublic.AuthenticatedUser
cfg *config.Config
ex *executor.Executor
EntityType string
EntityKey string
activeBindingStates map[string]bindingActiveState
}
func activeBindingID(entry *executor.InternalLogEntry) string {
if entry == nil || entry.ExecutionFinished {
return ""
}
return entry.GetBindingId()
}
func applyEntryToBindingState(state bindingActiveState, entry *executor.InternalLogEntry) bindingActiveState {
if entry.ExecutionStarted {
state.hasRunning = true
} else {
state.hasQueued = true
}
return state
}
func buildActiveBindingStates(active []*executor.InternalLogEntry) map[string]bindingActiveState {
states := make(map[string]bindingActiveState)
for _, entry := range active {
bindingID := activeBindingID(entry)
if bindingID == "" {
continue
}
states[bindingID] = applyEntryToBindingState(states[bindingID], entry)
}
return states
}
func populateActiveBindingStates(rr *DashboardRenderRequest) {
if rr == nil || rr.ex == nil || rr.activeBindingStates != nil {
return
}
rr.activeBindingStates = buildActiveBindingStates(rr.ex.GetActiveExecutionsACL(rr.cfg, rr.AuthenticatedUser))
}
func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
@ -135,43 +179,56 @@ func actionFromBinding(actionBinding *executor.ActionBinding) (*executor.ActionB
return actionBinding, actionBinding.Action
}
func applyActiveBindingStateToAction(btn *apiv1.Action, bindingID string, states map[string]bindingActiveState) {
if states == nil {
return
}
state, ok := states[bindingID]
if !ok {
return
}
btn.HasRunningInstance = state.hasRunning
btn.HasQueuedInstance = state.hasQueued
}
func buildActionArguments(action *config.Action, entity *entities.Entity) []*apiv1.ActionArgument {
args := make([]*apiv1.ActionArgument, 0, len(action.Arguments))
for _, cfgArg := range action.Arguments {
args = append(args, &apiv1.ActionArgument{
Name: cfgArg.Name,
Title: cfgArg.Title,
Type: cfgArg.Type,
Description: cfgArg.Description,
DefaultValue: getDefaultArgumentValue(cfgArg, entity),
Choices: buildChoices(cfgArg),
Suggestions: cfgArg.Suggestions,
SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
})
}
return args
}
func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
binding, action := actionFromBinding(actionBinding)
if binding == nil {
return nil
}
aclCanExec := acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action)
enabledExprCanExec := evaluateEnabledExpression(action, binding.Entity)
datetimeRateLimitExpires := formatRateLimitExpiry(rr.ex.GetTimeUntilAvailable(binding))
btn := apiv1.Action{
BindingId: binding.ID,
Title: tpl.ParseTemplateOfActionBeforeExec(action.Title, binding.Entity),
Icon: tpl.ParseTemplateOfActionBeforeExec(action.Icon, binding.Entity),
CanExec: aclCanExec && enabledExprCanExec,
PopupOnStart: action.PopupOnStart,
CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action) && evaluateEnabledExpression(action, binding.Entity),
PopupOnStart: action.OnClick,
Order: int32(binding.ConfigOrder),
Timeout: int32(action.Timeout),
DatetimeRateLimitExpires: datetimeRateLimitExpires,
DatetimeRateLimitExpires: formatRateLimitExpiry(rr.ex.GetTimeUntilAvailable(binding)),
Justification: action.Justification,
}
applyActiveBindingStateToAction(&btn, binding.ID, rr.activeBindingStates)
applyActionExecTriggers(&btn, action)
for _, cfgArg := range action.Arguments {
pbArg := apiv1.ActionArgument{
Name: cfgArg.Name,
Title: cfgArg.Title,
Type: cfgArg.Type,
Description: cfgArg.Description,
DefaultValue: getDefaultArgumentValue(cfgArg, binding.Entity),
Choices: buildChoices(cfgArg),
Suggestions: cfgArg.Suggestions,
SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
}
btn.Arguments = append(btn.Arguments, &pbArg)
}
btn.Arguments = buildActionArguments(action, binding.Entity)
return &btn
}

View File

@ -0,0 +1,92 @@
package api
import (
"testing"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/executor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildActiveBindingStates(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{Title: "backup", Shell: "sleep 1", MaxConcurrent: 1},
{Title: "ping", Shell: "echo ping"},
}
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
backupBinding := ex.FindBindingWithNoEntity(cfg.Actions[0])
pingBinding := ex.FindBindingWithNoEntity(cfg.Actions[1])
require.NotNil(t, backupBinding)
require.NotNil(t, pingBinding)
backupRunning := newAPIQueueLogEntry(backupBinding, true, false)
backupWaiting := newAPIQueueLogEntry(backupBinding, false, false)
pingRunning := newAPIQueueLogEntry(pingBinding, true, false)
states := buildActiveBindingStates([]*executor.InternalLogEntry{
backupRunning,
backupWaiting,
pingRunning,
})
backupState, ok := states[backupBinding.ID]
require.True(t, ok)
assert.True(t, backupState.hasRunning)
assert.True(t, backupState.hasQueued)
pingState, ok := states[pingBinding.ID]
require.True(t, ok)
assert.True(t, pingState.hasRunning)
assert.False(t, pingState.hasQueued)
}
func TestBuildActionIncludesActiveBindingState(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{Title: "backup", Shell: "sleep 1"},
}
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
running := newAPIQueueLogEntry(binding, true, false)
queued := newAPIQueueLogEntry(binding, false, false)
ex.SetLog(running.ExecutionTrackingID, running)
ex.SetLog(queued.ExecutionTrackingID, queued)
rr := &DashboardRenderRequest{
cfg: cfg,
ex: ex,
}
populateActiveBindingStates(rr)
action := buildAction(binding, rr)
require.NotNil(t, action)
assert.True(t, action.HasRunningInstance)
assert.True(t, action.HasQueuedInstance)
}
func TestBuildActiveBindingStatesIgnoresFinished(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{{Title: "backup", Shell: "sleep 1"}}
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
finished := newAPIQueueLogEntry(binding, true, true)
states := buildActiveBindingStates([]*executor.InternalLogEntry{finished})
assert.Empty(t, states)
}

View File

@ -0,0 +1,45 @@
package api
import (
"fmt"
"strings"
"connectrpc.com/connect"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
"github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/executor"
)
func validateJustificationRequired(action *config.Action, justification string, user *authpublic.AuthenticatedUser) error {
if !actionRequiresJustificationConfig(action) || justificationProvided(justification, user) {
return nil
}
return fmt.Errorf("justification is required for this action")
}
func actionRequiresJustificationConfig(action *config.Action) bool {
return action != nil && action.Justification
}
func justificationProvided(justification string, user *authpublic.AuthenticatedUser) bool {
return strings.TrimSpace(justification) != "" || executor.IsSystemExecution(user)
}
func connectInvalidJustification(err error) error {
return connect.NewError(connect.CodeInvalidArgument, err)
}
func startActionArgumentsFromProto(args []*apiv1.StartActionArgument) map[string]string {
result := make(map[string]string, len(args))
for _, arg := range args {
result[arg.Name] = arg.Value
}
return result
}
func restartRequiresJustificationError() error {
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("justification is required for this action; use StartAction with a justification instead"))
}

View File

@ -0,0 +1,89 @@
package api
import (
"context"
"testing"
"time"
"connectrpc.com/connect"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
"github.com/OliveTin/OliveTin/internal/auth"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/executor"
)
func TestStartActionRequiresJustificationForGuest(t *testing.T) {
cfg := config.DefaultConfig()
action := &config.Action{
Title: "Send email",
ID: "send_email",
Justification: true,
Shell: "echo done",
}
cfg.Actions = append(cfg.Actions, action)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(action)
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
_, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
UniqueTrackingId: uuid.NewString(),
}))
require.Error(t, err)
assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
resp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
UniqueTrackingId: uuid.NewString(),
Justification: "New user registration foo@example.com",
}))
require.NoError(t, err)
require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
time.Sleep(200 * time.Millisecond)
entry, ok := ex.GetLog(resp.Msg.ExecutionTrackingId)
require.True(t, ok)
assert.Equal(t, "New user registration foo@example.com", entry.Justification)
}
func TestBuildActionExposesJustificationFlag(t *testing.T) {
cfg := config.DefaultConfig()
action := &config.Action{
Title: "Audited action",
ID: "audited",
Justification: true,
Shell: "echo hi",
}
cfg.Actions = append(cfg.Actions, action)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(action)
require.NotNil(t, binding)
pb := buildAction(binding, &DashboardRenderRequest{
cfg: cfg,
ex: ex,
})
require.NotNil(t, pb)
assert.True(t, pb.Justification)
}
func TestValidateJustificationRequiredAllowsSystemUser(t *testing.T) {
cfg := config.DefaultConfig()
action := &config.Action{Title: "Cron job", Justification: true}
err := validateJustificationRequired(action, "", auth.UserFromSystem(cfg, "cron"))
require.NoError(t, err)
}

View File

@ -30,10 +30,12 @@ type Action struct {
MaxConcurrent int `koanf:"maxConcurrent"`
MaxRate []RateSpec `koanf:"maxRate"`
Arguments []ActionArgument `koanf:"arguments"`
OnClick string `koanf:"onclick"`
PopupOnStart string `koanf:"popupOnStart"`
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
EnabledExpression string `koanf:"enabledExpression"`
Groups []string `koanf:"groups"`
Justification bool `koanf:"justification"`
}
// ActionGroup defines shared limits and metadata for a set of actions.
@ -70,14 +72,15 @@ type RateSpec struct {
// WebhookConfig defines configuration for generic webhook triggers.
type WebhookConfig struct {
Secret string `koanf:"secret"` // Optional: secret for signature verification
AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none"
AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature")
MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers
MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath")
MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters
Extract map[string]string `koanf:"extract"` // Map action argument names to JSONPath expressions
Template string `koanf:"template"` // Optional: template name (e.g., "github-push", "github-pr")
Secret string `koanf:"secret"` // Optional: secret for signature verification
AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none"
AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature")
MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers
MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath")
MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters
Extract map[string]string `koanf:"extract"` // Map action argument names to JSONPath expressions
Template string `koanf:"template"` // Optional: template name (e.g., "github-push", "github-pr")
Justification string `koanf:"justification"` // Optional JSONPath to extract justification from webhook body
}
// Entity represents a "thing" that can have multiple actions associated with it.
@ -175,6 +178,7 @@ type Config struct {
WebUIDir string `koanf:"webUIDir"`
CronSupportForSeconds bool `koanf:"cronSupportForSeconds"`
SectionNavigationStyle string `koanf:"sectionNavigationStyle"`
DefaultOnClick string `koanf:"defaultOnClick"`
DefaultPopupOnStart string `koanf:"defaultPopupOnStart"`
InsecureAllowDumpOAuth2UserData bool `koanf:"insecureAllowDumpOAuth2UserData"`
InsecureAllowDumpVars bool `koanf:"insecureAllowDumpVars"`
@ -290,6 +294,7 @@ func DefaultConfigWithBasePort(basePort int) *Config {
config.WebUIDir = "./webui"
config.CronSupportForSeconds = false
config.SectionNavigationStyle = "sidebar"
config.DefaultOnClick = "nothing"
config.DefaultPopupOnStart = "nothing"
config.InsecureAllowDumpVars = false
config.InsecureAllowDumpSos = false

View File

@ -18,6 +18,7 @@ func (cfg *Config) Sanitize() {
cfg.sanitizeLogHistoryPageSize()
cfg.sanitizeLocalUsers()
cfg.sanitizeSecurityHeaders()
cfg.sanitizeOnClickDefaults()
// log.Infof("cfg %p", cfg)
@ -177,7 +178,9 @@ func (action *Action) sanitize(cfg *Config) {
action.ID = getActionID(action)
action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions)
action.PopupOnStart = sanitizePopupOnStart(action.PopupOnStart, cfg)
migrateActionOnClick(action)
action.OnClick = sanitizeOnClick(action.OnClick, cfg)
action.PopupOnStart = action.OnClick
if action.MaxConcurrent < 1 {
action.MaxConcurrent = 1
@ -359,7 +362,7 @@ func getActionID(action *Action) string {
}
//gocyclo:ignore
func sanitizePopupOnStart(raw string, cfg *Config) string {
func sanitizeOnClick(raw string, cfg *Config) string {
switch raw {
case "execution-dialog":
return raw
@ -372,10 +375,43 @@ func sanitizePopupOnStart(raw string, cfg *Config) string {
case "history":
return raw
default:
return cfg.DefaultPopupOnStart
return cfg.DefaultOnClick
}
}
func migrateActionOnClick(action *Action) {
if action.OnClick == "" && action.PopupOnStart != "" {
action.OnClick = action.PopupOnStart
}
}
func shouldMigrateDefaultOnClickFromPopup(onClick, popupOnStart string) bool {
if popupOnStart == "" {
return false
}
if onClick == "" {
return true
}
return onClick == "nothing" && popupOnStart != "nothing"
}
func (cfg *Config) migrateDefaultOnClickFromLegacyPopup() {
if !shouldMigrateDefaultOnClickFromPopup(cfg.DefaultOnClick, cfg.DefaultPopupOnStart) {
return
}
cfg.DefaultOnClick = cfg.DefaultPopupOnStart
}
func (cfg *Config) sanitizeOnClickDefaults() {
cfg.migrateDefaultOnClickFromLegacyPopup()
if cfg.DefaultOnClick == "" {
cfg.DefaultOnClick = "nothing"
}
cfg.DefaultPopupOnStart = cfg.DefaultOnClick
}
func (arg *ActionArgument) sanitize() {
if arg.Title == "" {
arg.Title = arg.Name

View File

@ -52,10 +52,70 @@ func TestSanitizePopupOnStartHistory(t *testing.T) {
a := c.findAction("With history")
if assert.NotNil(t, a) {
assert.Equal(t, "history", a.PopupOnStart, "history must be preserved, not replaced by defaultPopupOnStart")
assert.Equal(t, "history", a.OnClick, "history must be preserved on onclick")
assert.Equal(t, "history", a.PopupOnStart, "legacy popupOnStart must mirror onclick")
}
}
func TestSanitizeMigratesPopupOnStartToOnClick(t *testing.T) {
c := DefaultConfig()
c.Actions = append(c.Actions, &Action{
Title: "Legacy popup",
PopupOnStart: "execution-dialog",
Shell: "true",
})
c.Sanitize()
a := c.findAction("Legacy popup")
require.NotNil(t, a)
assert.Equal(t, "execution-dialog", a.OnClick)
assert.Equal(t, "execution-dialog", a.PopupOnStart)
}
func TestSanitizeOnClickPreferredOverPopupOnStart(t *testing.T) {
c := DefaultConfig()
c.Actions = append(c.Actions, &Action{
Title: "Preferred onclick",
OnClick: "history",
PopupOnStart: "execution-dialog",
Shell: "true",
})
c.Sanitize()
a := c.findAction("Preferred onclick")
require.NotNil(t, a)
assert.Equal(t, "history", a.OnClick)
assert.Equal(t, "history", a.PopupOnStart)
}
func TestSanitizeMigratesDefaultPopupOnStartToDefaultOnClick(t *testing.T) {
c := DefaultConfig()
c.DefaultPopupOnStart = "execution-dialog"
c.DefaultOnClick = ""
c.Sanitize()
assert.Equal(t, "execution-dialog", c.DefaultOnClick)
assert.Equal(t, "execution-dialog", c.DefaultPopupOnStart)
}
func TestSanitizeMigratesDefaultPopupOnStartWhenDefaultOnClickUnchanged(t *testing.T) {
c := DefaultConfig()
c.DefaultPopupOnStart = "execution-dialog"
c.Actions = append(c.Actions, &Action{
Title: "Uses default onclick",
Shell: "true",
})
c.Sanitize()
assert.Equal(t, "execution-dialog", c.DefaultOnClick)
assert.Equal(t, "execution-dialog", c.DefaultPopupOnStart)
a := c.findAction("Uses default onclick")
require.NotNil(t, a)
assert.Equal(t, "execution-dialog", a.OnClick)
assert.Equal(t, "execution-dialog", a.PopupOnStart)
}
func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
c := DefaultConfig()

View File

@ -87,6 +87,7 @@ type ExecutionRequest struct {
Cfg *config.Config
AuthenticatedUser *authpublic.AuthenticatedUser
TriggerDepth int
Justification string
logEntry *InternalLogEntry
finalParsedCommand string
@ -166,8 +167,9 @@ type InternalLogEntry struct {
that logs are lightweight (so we don't need to have an action associated to
logs, etc. Therefore, we duplicate those values here.
*/
ActionTitle string
ActionIcon string
ActionTitle string
ActionIcon string
Justification string
}
// .Binding can be nil, so we need to handle that.
@ -280,7 +282,7 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
trackingIds := make([]*InternalLogEntry, 0, pageCount)
if totalLogCount > 0 {
for i := endIndex; i <= startIndex; i++ {
for i := startIndex; i >= endIndex; i-- {
trackingIds = append(trackingIds, e.logs[e.logsTrackingIdsByDate[i]])
}
}
@ -376,7 +378,7 @@ func paginateFilteredLogs(filtered []*InternalLogEntry, startOffset int64, pageC
endIndex := max(0, (startIndex-pageCount)+1)
out := make([]*InternalLogEntry, 0, pageCount)
for i := endIndex; i <= startIndex && i < int64(len(filtered)); i++ {
for i := startIndex; i >= endIndex && i < int64(len(filtered)); i-- {
out = append(out, filtered[i])
}
@ -996,6 +998,7 @@ func stepRequestActionPopulateLogEntry(req *ExecutionRequest) {
entry.ActionTitle = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Title, req.Binding.Entity)
entry.ActionIcon = req.Binding.Action.Icon
entry.Tags = req.Tags
entry.Justification = ResolveJustification(req)
if req.Binding.Entity != nil {
entry.EntityPrefix = req.Binding.Entity.UniqueKey
}
@ -1148,9 +1151,18 @@ func prepareCommand(cmd *exec.Cmd, streamer *OutputStreamer, req *ExecutionReque
cmd.Stdout = streamer
cmd.Stderr = streamer
cmd.Env = buildEnv(req.Arguments)
started := false
req.mutateLogEntry(func(entry *InternalLogEntry) {
if entry.ExecutionStarted {
return
}
entry.ExecutionStarted = true
started = true
})
if started {
notifyListenersStarted(req)
}
}
func stepExecAfter(req *ExecutionRequest) bool {
@ -1284,6 +1296,7 @@ func triggerLoop(req *ExecutionRequest) {
Arguments: req.Arguments,
Cfg: req.Cfg,
TriggerDepth: req.TriggerDepth + 1,
Justification: fmt.Sprintf("Triggered by action: %s", req.logEntry.ActionTitle),
}
req.executor.ExecRequest(trigger)

View File

@ -79,6 +79,79 @@ func TestGroupConcurrencyQueuesSecondAction(t *testing.T) {
assert.Contains(t, snapshot.Output, "queued-run")
}
func TestQueuedActionNotifiesWhenExecutionBegins(t *testing.T) {
t.Parallel()
slowAction := &config.Action{
Title: "Hold group",
Shell: "sleep 1",
Groups: []string{"unity"},
}
queuedAction := &config.Action{
Title: "Queued job",
Shell: "echo queued-run",
Groups: []string{"unity"},
}
e, cfg := testGroupExecutor(
[]*config.Action{slowAction, queuedAction},
map[string]*config.ActionGroup{
"unity": {MaxConcurrent: 1},
},
)
notifications := make(chan startedNotification, 8)
e.AddListener(&executionStartedCollector{ch: notifications})
wg1, tracking1 := e.ExecRequest(&ExecutionRequest{
Binding: e.FindBindingWithNoEntity(slowAction),
Cfg: cfg,
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
})
waitUntilExecutionStarted(t, e, tracking1)
wg2, tracking2 := e.ExecRequest(&ExecutionRequest{
Binding: e.FindBindingWithNoEntity(queuedAction),
Cfg: cfg,
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
})
require.Eventually(t, func() bool {
snapshot, ok := e.SnapshotLog(tracking2)
return ok && snapshot.Queued
}, time.Second, 10*time.Millisecond)
wg1.Wait()
wg2.Wait()
sawQueuedStart, sawRunningStart := collectQueuedStartNotifications(notifications, tracking2)
assert.True(t, sawQueuedStart, "queued action should notify when queued")
assert.True(t, sawRunningStart, "queued action should notify again when execution begins")
}
func isQueuedStartNotification(notification startedNotification, trackingID string) bool {
return notification.trackingID == trackingID && notification.queued && !notification.started
}
func isRunningStartNotification(notification startedNotification, trackingID string) bool {
return notification.trackingID == trackingID && !notification.queued && notification.started
}
func collectQueuedStartNotifications(notifications <-chan startedNotification, trackingID string) (sawQueuedStart, sawRunningStart bool) {
for len(notifications) > 0 {
notification := <-notifications
if isQueuedStartNotification(notification, trackingID) {
sawQueuedStart = true
}
if isRunningStartNotification(notification, trackingID) {
sawRunningStart = true
}
}
return sawQueuedStart, sawRunningStart
}
func TestDifferentGroupsRunConcurrently(t *testing.T) {
t.Parallel()
@ -213,6 +286,30 @@ func waitUntilExecutionStarted(t *testing.T, e *Executor, trackingID string) {
}, 2*time.Second, 10*time.Millisecond)
}
type executionStartedCollector struct {
ch chan startedNotification
}
type startedNotification struct {
trackingID string
started bool
queued bool
}
func (c *executionStartedCollector) OnExecutionStarted(entry *InternalLogEntry) {
c.ch <- startedNotification{
trackingID: entry.ExecutionTrackingID,
started: entry.ExecutionStarted,
queued: entry.Queued,
}
}
func (c *executionStartedCollector) OnExecutionFinished(_ *InternalLogEntry) {}
func (c *executionStartedCollector) OnOutputChunk(_ []byte, _ string) {}
func (c *executionStartedCollector) OnActionMapRebuilt() {}
func assertWaitGroupPending(t *testing.T, wg *sync.WaitGroup) {
t.Helper()

View File

@ -0,0 +1,68 @@
package executor
import (
"fmt"
"strings"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
)
const (
justificationCron = "Triggered by cron"
justificationStartup = "Triggered by startup"
justificationFileChange = "Triggered by file change"
justificationCalendar = "Triggered by calendar"
justificationWebhook = "Triggered by webhook"
)
var systemJustificationDefaults = map[string]string{
"cron": justificationCron,
"startup": justificationStartup,
"fileindir": justificationFileChange,
"calendar": justificationCalendar,
"webhook": justificationWebhook,
}
func IsSystemExecution(user *authpublic.AuthenticatedUser) bool {
if user == nil || user.Provider != "system" {
return false
}
return user.Username != "guest"
}
func ResolveJustification(req *ExecutionRequest) string {
provided := strings.TrimSpace(reqJustification(req))
if provided != "" {
return provided
}
if !actionRequiresJustification(req) {
return ""
}
return defaultJustificationForRequest(req)
}
func actionRequiresJustification(req *ExecutionRequest) bool {
return req != nil && req.Binding != nil && req.Binding.Action != nil && req.Binding.Action.Justification
}
func defaultJustificationForRequest(req *ExecutionRequest) string {
if req.TriggerDepth > 0 && req.logEntry != nil {
return fmt.Sprintf("Triggered by action: %s", req.logEntry.ActionTitle)
}
if req.AuthenticatedUser == nil {
return ""
}
return systemJustificationDefaults[req.AuthenticatedUser.Username]
}
func reqJustification(req *ExecutionRequest) string {
if req == nil {
return ""
}
return req.Justification
}

View File

@ -0,0 +1,130 @@
package executor
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/OliveTin/OliveTin/internal/auth"
config "github.com/OliveTin/OliveTin/internal/config"
)
func TestResolveJustificationUsesProvidedValue(t *testing.T) {
cfg := config.DefaultConfig()
action := &config.Action{Title: "Send email", Justification: true, Shell: "echo hi"}
cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg)
ex.RebuildActionMap()
req := &ExecutionRequest{
Binding: ex.FindBindingWithNoEntity(action),
Justification: "New user registration foo@example.com",
AuthenticatedUser: auth.UserGuest(cfg),
Cfg: cfg,
}
req.logEntry = &InternalLogEntry{}
assert.Equal(t, "New user registration foo@example.com", ResolveJustification(req))
}
func TestResolveJustificationCronDefault(t *testing.T) {
cfg := config.DefaultConfig()
action := &config.Action{Title: "Nightly backup", Justification: true, Shell: "echo hi"}
cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg)
ex.RebuildActionMap()
req := &ExecutionRequest{
Binding: ex.FindBindingWithNoEntity(action),
AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
Cfg: cfg,
}
assert.Equal(t, justificationCron, ResolveJustification(req))
}
func TestResolveJustificationStartupDefault(t *testing.T) {
cfg := config.DefaultConfig()
action := &config.Action{Title: "Init", Justification: true, Shell: "echo hi"}
cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg)
ex.RebuildActionMap()
req := &ExecutionRequest{
Binding: ex.FindBindingWithNoEntity(action),
AuthenticatedUser: auth.UserFromSystem(cfg, "startup"),
Cfg: cfg,
}
assert.Equal(t, justificationStartup, ResolveJustification(req))
}
func TestResolveJustificationWebhookDefault(t *testing.T) {
cfg := config.DefaultConfig()
action := &config.Action{Title: "Deploy", Justification: true, Exec: []string{"echo", "deploy"}}
cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg)
ex.RebuildActionMap()
req := &ExecutionRequest{
Binding: ex.FindBindingWithNoEntity(action),
AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
Cfg: cfg,
}
assert.Equal(t, justificationWebhook, ResolveJustification(req))
}
func TestResolveJustificationEmptyWhenNotRequired(t *testing.T) {
cfg := config.DefaultConfig()
action := &config.Action{Title: "Ping", Shell: "echo hi"}
cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg)
ex.RebuildActionMap()
req := &ExecutionRequest{
Binding: ex.FindBindingWithNoEntity(action),
AuthenticatedUser: auth.UserGuest(cfg),
Cfg: cfg,
}
assert.Empty(t, ResolveJustification(req))
}
func TestJustificationNotPassedToShellArgs(t *testing.T) {
cfg := config.DefaultConfig()
action := &config.Action{
Title: "Echo",
Justification: true,
Shell: "echo {{ message }}",
Arguments: []config.ActionArgument{
{Name: "message", Type: "ascii_sentence"},
},
}
cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg)
ex.RebuildActionMap()
req := &ExecutionRequest{
Binding: ex.FindBindingWithNoEntity(action),
Arguments: map[string]string{
"message": "hello",
"justification": "should be stripped",
},
Justification: "audit reason",
AuthenticatedUser: auth.UserGuest(cfg),
Cfg: cfg,
}
req.logEntry = &InternalLogEntry{}
filterToDefinedArgumentsOnly(req)
assert.Equal(t, "hello", req.Arguments["message"])
assert.Empty(t, req.Arguments["justification"])
}
func TestIsSystemExecution(t *testing.T) {
cfg := config.DefaultConfig()
assert.True(t, IsSystemExecution(auth.UserFromSystem(cfg, "cron")))
assert.False(t, IsSystemExecution(auth.UserGuest(cfg)))
}

View File

@ -137,11 +137,20 @@ func (h *WebhookHandler) processWebhook(actionConfig ActionWebhookConfig, r *htt
return false
}
h.executeAction(actionConfig.Action, args)
justification, err := matcher.ExtractJustification()
if err != nil {
log.WithFields(log.Fields{
"actionTitle": actionConfig.Action.Title,
"error": err,
}).Warnf("Failed to extract webhook justification")
return false
}
h.executeAction(actionConfig.Action, args, justification)
return true
}
func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string) {
func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string, justification string) {
binding := h.executor.FindBindingWithNoEntity(action)
if binding == nil {
log.WithFields(log.Fields{
@ -156,6 +165,7 @@ func (h *WebhookHandler) executeAction(action *config.Action, args map[string]st
Cfg: h.cfg,
Tags: []string{"webhook"},
Arguments: definedArgs,
Justification: justification,
AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"),
}

View File

@ -138,6 +138,19 @@ func (m *WebhookMatcher) compareValues(actual, expected string) bool {
return actual == expected
}
func (m *WebhookMatcher) ExtractJustification() (string, error) {
if m.config.Justification == "" {
return "", nil
}
matcher, err := NewJSONMatcher(m.bodyBytes)
if err != nil {
return "", err
}
return matcher.ExtractValue(m.config.Justification)
}
func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) {
matcher, err := NewJSONMatcher(m.bodyBytes)
if err != nil {

View File

@ -0,0 +1,36 @@
package webhooks
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
config "github.com/OliveTin/OliveTin/internal/config"
)
func TestExtractJustificationFromWebhookBody(t *testing.T) {
body := []byte(`{"message":"deploy production","repo":"my-app"}`)
req, err := http.NewRequest(http.MethodPost, "/webhooks/deploy", nil)
require.NoError(t, err)
matcher := NewWebhookMatcher(config.WebhookConfig{
Justification: "$.message",
}, req, body)
value, err := matcher.ExtractJustification()
require.NoError(t, err)
assert.Equal(t, "deploy production", value)
}
func TestExtractJustificationEmptyWhenNotConfigured(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "/webhooks/deploy", nil)
require.NoError(t, err)
matcher := NewWebhookMatcher(config.WebhookConfig{}, req, []byte(`{}`))
value, err := matcher.ExtractJustification()
require.NoError(t, err)
assert.Empty(t, value)
}

View File

@ -147,6 +147,7 @@ actions:
# Using a path under the user's home is more natural on macOS.
- title: Delete old backups
icon: ashtonished
justification: true
shell: rm -rf "$HOME/Backups/old/"
arguments:
- name: confirm