feat/uirefresh26 (#1050)
This commit is contained in:
commit
de68ba7433
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
87
config.yaml
87
config.yaml
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
----
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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). | `«` | Requires Restart | -
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export function needsArgumentForm (action) {
|
||||
return (action?.arguments?.length > 0) || action?.justification
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export function shouldSuppressPopupOnStartNavigation (router) {
|
||||
const path = router?.currentRoute?.value?.path ?? window.location.pathname
|
||||
return path === '/logs' || path === '/logs/queue'
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -11,22 +11,49 @@
|
|||
|
||||
<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 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">
|
||||
<h3>{{ group.actionTitle }}</h3>
|
||||
<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>
|
||||
</header>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="logs-table">
|
||||
<div class="section-content">
|
||||
<table class="logs-table row-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('logs.timestamp') }}</th>
|
||||
|
|
@ -53,44 +80,171 @@
|
|||
</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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading" class="empty-state padding">
|
||||
<p>{{ t('logs.queue-empty') }}</p>
|
||||
<router-link to="/logs">{{ t('logs.back-to-list') }}</router-link>
|
||||
</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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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})",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" +
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -78,6 +80,7 @@ type WebhookConfig struct {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ type ExecutionRequest struct {
|
|||
Cfg *config.Config
|
||||
AuthenticatedUser *authpublic.AuthenticatedUser
|
||||
TriggerDepth int
|
||||
Justification string
|
||||
|
||||
logEntry *InternalLogEntry
|
||||
finalParsedCommand string
|
||||
|
|
@ -168,6 +169,7 @@ type InternalLogEntry struct {
|
|||
*/
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue