feat: circle indicator for running actions, justification support
This commit is contained in:
parent
f3474c6069
commit
de63793b3a
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install Antora toolchain
|
- name: Install Antora toolchain
|
||||||
run: npm i antora@3.1.14 asciidoctor-kroki@0.18.1 @asciidoctor/tabs@1.0.0-beta.6
|
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`.
|
- 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.
|
- 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
|
### Troubleshooting
|
||||||
- API tests failing with content-type errors: ensure Connect handler is served under `/api/` and the client targets that base URL.
|
- 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.
|
- Executor panics: check for nil `Binding/Action` and add guards in step functions.
|
||||||
|
|
|
||||||
87
config.yaml
87
config.yaml
|
|
@ -22,6 +22,27 @@ actionGroups:
|
||||||
#
|
#
|
||||||
# Docs: https://docs.olivetin.app/action_execution/create_your_first.html
|
# Docs: https://docs.olivetin.app/action_execution/create_your_first.html
|
||||||
actions:
|
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
|
# 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
|
# below are optional *additional* triggers (see each action and
|
||||||
# https://docs.olivetin.app/action_execution/ ).
|
# https://docs.olivetin.app/action_execution/ ).
|
||||||
|
|
@ -34,47 +55,19 @@ actions:
|
||||||
- title: Ping the Internet
|
- title: Ping the Internet
|
||||||
shell: ping -c 3 1.1.1.1
|
shell: ping -c 3 1.1.1.1
|
||||||
icon: ping
|
icon: ping
|
||||||
popupOnStart: execution-dialog-stdout-only
|
onclick: execution-dialog
|
||||||
# https://docs.olivetin.app/action_execution/onstartup.html
|
# https://docs.olivetin.app/action_execution/onstartup.html
|
||||||
execOnStartup: true
|
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.
|
# You can also rate-limit actions too.
|
||||||
- title: date
|
- title: Sync Disks
|
||||||
shell: date
|
shell: sync
|
||||||
id: date
|
id: syncdisks
|
||||||
timeout: 6
|
icon: disk
|
||||||
icon: clock
|
onclick: execution-button
|
||||||
popupOnStart: execution-button
|
|
||||||
maxRate:
|
maxRate:
|
||||||
- limit: 3
|
- limit: 3
|
||||||
duration: 1m
|
duration: 1m
|
||||||
execOnCron:
|
|
||||||
- "@hourly"
|
|
||||||
|
|
||||||
# You are not limited to operating system commands, and of course you can run
|
# 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
|
# your own scripts. The backup-jobs action group limits how many backup-related
|
||||||
|
|
@ -86,7 +79,7 @@ actions:
|
||||||
groups: [ backup-jobs ]
|
groups: [ backup-jobs ]
|
||||||
timeout: 10
|
timeout: 10
|
||||||
icon: backup
|
icon: backup
|
||||||
popupOnStart: execution-dialog
|
onclick: execution-dialog
|
||||||
# https://docs.olivetin.app/action_execution/oncalendar.html
|
# https://docs.olivetin.app/action_execution/oncalendar.html
|
||||||
execOnCalendarFile: examples/demo-olivetin-calendar.yaml
|
execOnCalendarFile: examples/demo-olivetin-calendar.yaml
|
||||||
|
|
||||||
|
|
@ -95,7 +88,7 @@ actions:
|
||||||
groups: [ backup-jobs ]
|
groups: [ backup-jobs ]
|
||||||
timeout: 30
|
timeout: 30
|
||||||
icon: backup
|
icon: backup
|
||||||
popupOnStart: execution-dialog
|
onclick: execution-dialog
|
||||||
|
|
||||||
# When you want to prompt users for input, that is when you should use
|
# 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.
|
# `arguments` - this presents a popup dialog and asks for argument values.
|
||||||
|
|
@ -106,7 +99,7 @@ actions:
|
||||||
shell: ping {{ host }} -c {{ count }}
|
shell: ping {{ host }} -c {{ count }}
|
||||||
icon: ping
|
icon: ping
|
||||||
timeout: 100
|
timeout: 100
|
||||||
popupOnStart: execution-dialog-stdout-only
|
onclick: history
|
||||||
# https://docs.olivetin.app/action_execution/onwebhook.html — POST to /webhooks
|
# https://docs.olivetin.app/action_execution/onwebhook.html — POST to /webhooks
|
||||||
# with header X-OliveTin-Demo: ping-host (path and payload rules are documented).
|
# with header X-OliveTin-Demo: ping-host (path and payload rules are documented).
|
||||||
execOnWebhook:
|
execOnWebhook:
|
||||||
|
|
@ -148,7 +141,8 @@ actions:
|
||||||
# Docs: https://docs.olivetin.app/args/input_confirmation.html
|
# Docs: https://docs.olivetin.app/args/input_confirmation.html
|
||||||
- title: Delete old backups
|
- title: Delete old backups
|
||||||
icon: ashtonished
|
icon: ashtonished
|
||||||
shell: rm -rf /opt/oldBackups/
|
justification: true
|
||||||
|
shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5
|
||||||
arguments:
|
arguments:
|
||||||
- type: html
|
- type: html
|
||||||
title: Description
|
title: Description
|
||||||
|
|
@ -186,7 +180,7 @@ actions:
|
||||||
- title: "Setup easy SSH"
|
- title: "Setup easy SSH"
|
||||||
icon: ssh
|
icon: ssh
|
||||||
shell: olivetin-setup-easy-ssh
|
shell: olivetin-setup-easy-ssh
|
||||||
popupOnStart: execution-dialog
|
onclick: execution-dialog
|
||||||
# Second webhook example: POST /webhooks?demo=setup-ssh
|
# Second webhook example: POST /webhooks?demo=setup-ssh
|
||||||
execOnWebhook:
|
execOnWebhook:
|
||||||
- matchQuery:
|
- matchQuery:
|
||||||
|
|
@ -203,13 +197,6 @@ actions:
|
||||||
timeout: 1
|
timeout: 1
|
||||||
shell: ssh -F /config/ssh/easy.cfg root@server1 'service httpd restart'
|
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
|
# There are several built-in shortcuts for the `icon` option, but you
|
||||||
# can also just specify any HTML, this includes any unicode character,
|
# can also just specify any HTML, this includes any unicode character,
|
||||||
# or a <img = "..." /> link to a custom icon.
|
# or a <img = "..." /> link to a custom icon.
|
||||||
|
|
@ -274,6 +261,14 @@ actions:
|
||||||
entity: container
|
entity: container
|
||||||
triggers: ["Update container entity file"]
|
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
|
# 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
|
# background helpers that execute only on startup or a cron, for updating
|
||||||
# entity files.
|
# entity files.
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
** xref:action_execution/aftercompletion.adoc[Execute after completion]
|
** xref:action_execution/aftercompletion.adoc[Execute after completion]
|
||||||
** xref:action_execution/triggers.adoc[Triggers]
|
** xref:action_execution/triggers.adoc[Triggers]
|
||||||
* xref:action_customization/intro.adoc[Action Customization]
|
* 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/icons.adoc[Icons]
|
||||||
** xref:action_customization/timeouts.adoc[Timeouts]
|
** xref:action_customization/timeouts.adoc[Timeouts]
|
||||||
** xref:action_customization/users.adoc[Users]
|
** 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/concurrency.adoc[Control concurrency] - Limit simultaneous executions
|
||||||
* xref:action_customization/ratelimiting.adoc[Set rate limits] - Prevent actions from running too frequently
|
* 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/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/savelogs.adoc[Save action logs] - Configure log retention for actions
|
||||||
* xref:action_customization/ids.adoc[Set action IDs] - Assign IDs for API access
|
* xref:action_customization/ids.adoc[Set action IDs] - Assign IDs for API access
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
[#popup-on-start]
|
[#onclick]
|
||||||
= Popup on Start (Execution Feedback)
|
= On Click (Execution Feedback)
|
||||||
|
|
||||||
OliveTin now has several options to control "execution feedback" when actions are started. This can be controlled on
|
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.
|
||||||
a per-action basis, using the `popupOnStart` 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)
|
== Big Flashy Buttons (default)
|
||||||
|
|
||||||
|
|
@ -13,7 +14,7 @@ You can also set the default for OliveTin using the `defaultPopupOnStart` config
|
||||||
----
|
----
|
||||||
actions:
|
actions:
|
||||||
- title: Ping the Internet
|
- title: Ping the Internet
|
||||||
popupOnStart: default
|
onclick: default
|
||||||
----
|
----
|
||||||
|
|
||||||
This will also be the option that is used if no other values match.
|
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:
|
actions:
|
||||||
- title: Check disk space
|
- title: Check disk space
|
||||||
popupOnStart: execution-dialog-stdout-only
|
onclick: execution-dialog-stdout-only
|
||||||
----
|
----
|
||||||
|
|
||||||
[NOTE]
|
[NOTE]
|
||||||
|
|
@ -39,22 +40,22 @@ image::../popupOutputOnly.png[]
|
||||||
|
|
||||||
== Execution Dialog
|
== 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]
|
[source,yaml]
|
||||||
.`config.yaml`
|
.`config.yaml`
|
||||||
----
|
----
|
||||||
actions:
|
actions:
|
||||||
- title: Check dmesg logs
|
- title: Check dmesg logs
|
||||||
popupOnStart: execution-dialog
|
onclick: execution-dialog
|
||||||
----
|
----
|
||||||
|
|
||||||
.Example of `popupOnStart: execution-dialog`
|
.Example of `onclick: execution-dialog`
|
||||||
image::../executionDialog.png[]
|
image::../executionDialog.png[]
|
||||||
|
|
||||||
== Execution Buttons
|
== 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.
|
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:
|
actions:
|
||||||
- title: date
|
- title: date
|
||||||
popupOnStart: execution-button
|
onclick: execution-button
|
||||||
----
|
----
|
||||||
|
|
||||||
image::../executionButtons.png[]
|
image::../executionButtons.png[]
|
||||||
|
|
@ -77,7 +78,5 @@ The `history` option opens the action details page for that binding when the exe
|
||||||
----
|
----
|
||||||
actions:
|
actions:
|
||||||
- title: Long-running job
|
- 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:
|
actions:
|
||||||
- title: Setup SSH
|
- title: Setup SSH
|
||||||
shell: olivetin-setup-easy-ssh
|
shell: olivetin-setup-easy-ssh
|
||||||
popupOnStart: execution-dialog
|
onclick: execution-dialog
|
||||||
----
|
----
|
||||||
|
|
||||||
[#ssh-easy-step-2]
|
[#ssh-easy-step-2]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
[#customize-webui]
|
[#customize-webui]
|
||||||
= Customize the web UI
|
= Customize the web UI
|
||||||
|
|
||||||
The OliveTin web UI is reasonably customizable - parts of the page that you don't need can be hidden when they're not needed.
|
The OliveTin web UI is reasonably customizable - parts of the page that you don't need can be hidden when they're not needed.
|
||||||
|
|
||||||
== Page Title
|
== Page Title
|
||||||
|
|
||||||
|
|
@ -43,8 +43,8 @@ image::defaultUiHideNav.png[]
|
||||||
|
|
||||||
When enabled (the default), each action button can show a small icon indicating what happens when the action is started:
|
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`)
|
* **Popup dialog** — the action opens a popup (e.g. `onclick: execution-dialog`)
|
||||||
* **Action history** — the action opens the action details page (e.g. `popupOnStart: history`)
|
* **Action history** — the action opens the action details page (e.g. `onclick: history`)
|
||||||
* **Argument form** — the action opens an argument form on start
|
* **Argument form** — the action opens an argument form on start
|
||||||
* **Run in background** — the action runs without opening a dialog
|
* **Run in background** — the action runs without opening a dialog
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@ This is controlled by the **showVersionNumber** policy (in `defaultPolicy` or pe
|
||||||
[#show-new-versions]
|
[#show-new-versions]
|
||||||
== New version available - show/hide
|
== New version available - show/hide
|
||||||
|
|
||||||
You can disable the "new version" information in the footer - the default for `showNewVersions` is `true`;
|
You can disable the "new version" information in the footer - the default for `showNewVersions` is `true`;
|
||||||
|
|
||||||
.`config.yaml`
|
.`config.yaml`
|
||||||
[source,yaml]
|
[source,yaml]
|
||||||
|
|
@ -135,7 +135,7 @@ This is considered an advanced feature, and is not recommended unless you like w
|
||||||
You can add custom JavaScript to OliveTin, which will be executed on every page load. This can be useful for adding custom functionality to the web UI.
|
You can add custom JavaScript to OliveTin, which will be executed on every page load. This can be useful for adding custom functionality to the web UI.
|
||||||
|
|
||||||
1. The custom javascript should be in a file called `custom.js` and saved in `custom-webui/`, which should be in the same directory as your `config.yaml`.
|
1. The custom javascript should be in a file called `custom.js` and saved in `custom-webui/`, which should be in the same directory as your `config.yaml`.
|
||||||
2. You can put whatever code you like really in your `custom.js` file.
|
2. You can put whatever code you like really in your `custom.js` file.
|
||||||
3. Set `enableCustomJs: true` in your `config.yaml` to enable this feature.
|
3. Set `enableCustomJs: true` in your `config.yaml` to enable this feature.
|
||||||
4. Restart OliveTin. Note that the custom JavaScript will only be loaded once on startup, so if you are changing the custom JavaScript while OliveTin is running, you will need to restart OliveTin to see the changes.
|
4. Restart OliveTin. Note that the custom JavaScript will only be loaded once on startup, so if you are changing the custom JavaScript while OliveTin is running, you will need to restart OliveTin to see the changes.
|
||||||
|
|
||||||
|
|
@ -147,7 +147,7 @@ You can customize OliveTin with themes, but it's also possible to write your on
|
||||||
|
|
||||||
=== Writing a simple theme with a CSS change
|
=== Writing a simple theme with a CSS change
|
||||||
|
|
||||||
You'll need to create a new theme, and let's assume our theme name is going to be called `uihack`. OliveTin themes are simply a directory of CSS and other assets. OliveTin looks for a directory called `custom-webui/themes/<theme-folder-name>` in the same directory as your `config.yaml` file.
|
You'll need to create a new theme, and let's assume our theme name is going to be called `uihack`. OliveTin themes are simply a directory of CSS and other assets. OliveTin looks for a directory called `custom-webui/themes/<theme-folder-name>` in the same directory as your `config.yaml` file.
|
||||||
|
|
||||||
Start by creating a directory called `custom-webui/themes/uihack` relative to the same directory as your `config.yaml` file. In this directory, create a file called `theme.css`.
|
Start by creating a directory called `custom-webui/themes/uihack` relative to the same directory as your `config.yaml` file. In this directory, create a file called `theme.css`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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].
|
| `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].
|
| `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].
|
| `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 | -
|
| `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 | -
|
| `defaultIconForDirectories` | The default icon to use for directories. | `directory` | Requires Restart | -
|
||||||
| `defaultIconForBack` | The default icon to use for back (from directories). | `«` | Requires Restart | -
|
| `defaultIconForBack` | The default icon to use for back (from directories). | `«` | Requires Restart | -
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ image::solution-k8s-hosted.png[]
|
||||||
=== Environment
|
=== Environment
|
||||||
|
|
||||||
* A Kubernetes cluster that is up and running.
|
* A Kubernetes cluster that is up and running.
|
||||||
* Kubernetes permissions to create a helm deployment, a `ClusterRole` and `ClusterRoleBinding`.
|
* Kubernetes permissions to create a helm deployment, a `ClusterRole` and `ClusterRoleBinding`.
|
||||||
* A configured Ingress Controller, exposing the for web interface
|
* A configured Ingress Controller, exposing the for web interface
|
||||||
|
|
||||||
=== System
|
=== System
|
||||||
|
|
@ -58,7 +58,7 @@ rules:
|
||||||
--
|
--
|
||||||
====
|
====
|
||||||
|
|
||||||
Now that the `ClusterRole` has been created, we need to associate it to a `ServiceAccount` with a `ClusterRoleBinding`.
|
Now that the `ClusterRole` has been created, we need to associate it to a `ServiceAccount` with a `ClusterRoleBinding`.
|
||||||
Create a cluster role binding;
|
Create a cluster role binding;
|
||||||
|
|
||||||
[tabs]
|
[tabs]
|
||||||
|
|
@ -82,7 +82,7 @@ Add `kubectl` job to OliveTin config with `kubectl edit cm/olivetin-config -n ol
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
data:
|
data:
|
||||||
config.yaml: |
|
config.yaml: |
|
||||||
defaultPopupOnStart: execution-dialog-output-only
|
defaultOnClick: execution-dialog-output-only
|
||||||
|
|
||||||
actions:
|
actions:
|
||||||
- title: get pods
|
- 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.
|
Don't forget to restart the OliveTin deployment as good measure, because Kubernetes can be slow to update configmaps.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { buttonResults } from '../resources/vue/stores/buttonResults.js'
|
import { buttonResults } from '../resources/vue/stores/buttonResults.js'
|
||||||
import { rateLimits } from '../resources/vue/stores/rateLimits.js'
|
import { rateLimits } from '../resources/vue/stores/rateLimits.js'
|
||||||
import { connectionState } from '../resources/vue/stores/connectionState.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 RECONNECT_DELAYS_MS = [200, 1000, 2000, 4000, 8000, 16000, 32000]
|
||||||
const BANNER_DELAY_MS = 2000
|
const BANNER_DELAY_MS = 2000
|
||||||
|
|
@ -52,8 +57,8 @@ export function connectEventStreamIfNeeded () {
|
||||||
export function initWebsocket () {
|
export function initWebsocket () {
|
||||||
if (!listenersInitialized) {
|
if (!listenersInitialized) {
|
||||||
window.addEventListener('EventOutputChunk', onOutputChunk)
|
window.addEventListener('EventOutputChunk', onOutputChunk)
|
||||||
window.addEventListener('EventExecutionStarted', onExecutionChanged)
|
window.addEventListener('EventExecutionStarted', onExecutionStarted)
|
||||||
window.addEventListener('EventExecutionFinished', onExecutionChanged)
|
window.addEventListener('EventExecutionFinished', onExecutionFinished)
|
||||||
window.addEventListener('pagehide', stopEventStream)
|
window.addEventListener('pagehide', stopEventStream)
|
||||||
listenersInitialized = true
|
listenersInitialized = true
|
||||||
}
|
}
|
||||||
|
|
@ -250,20 +255,27 @@ function onOutputChunk (evt) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyExecutionLogEntry (logEntry) {
|
export function applyExecutionLogEntry (logEntry) {
|
||||||
if (!logEntry?.executionTrackingId) {
|
const entry = cloneLogEntry(logEntry)
|
||||||
|
if (!entry?.executionTrackingId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
buttonResults[logEntry.executionTrackingId] = logEntry
|
buttonResults[entry.executionTrackingId] = entry
|
||||||
|
|
||||||
if (logEntry.datetimeRateLimitExpires && logEntry.bindingId) {
|
if (entry.datetimeRateLimitExpires && entry.bindingId) {
|
||||||
const date = new Date(logEntry.datetimeRateLimitExpires.replace(' ', 'T') + 'Z')
|
const date = new Date(entry.datetimeRateLimitExpires.replace(' ', 'T') + 'Z')
|
||||||
rateLimits[logEntry.bindingId] = date.getTime() / 1000
|
rateLimits[entry.bindingId] = date.getTime() / 1000
|
||||||
} else if (logEntry.bindingId) {
|
} else if (entry.bindingId) {
|
||||||
rateLimits[logEntry.bindingId] = 0
|
rateLimits[entry.bindingId] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onExecutionChanged (evt) {
|
function onExecutionStarted (evt) {
|
||||||
applyExecutionLogEntry(evt.payload.logEntry)
|
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;
|
* @generated from field: repeated olivetin.api.v1.ActionWebhookExecHint exec_on_webhooks = 15;
|
||||||
*/
|
*/
|
||||||
execOnWebhooks: ActionWebhookExecHint[];
|
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;
|
* @generated from field: string unique_tracking_id = 3;
|
||||||
*/
|
*/
|
||||||
uniqueTrackingId: string;
|
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;
|
* @generated from field: repeated olivetin.api.v1.StartActionArgument arguments = 2;
|
||||||
*/
|
*/
|
||||||
arguments: StartActionArgument[];
|
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;
|
* @generated from field: string queued_for_group = 22;
|
||||||
*/
|
*/
|
||||||
queuedForGroup: string;
|
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>
|
<template>
|
||||||
<div :id="`actionButton-${bindingId}`" role="none" class="action-button" @contextmenu.prevent="openActionDetails">
|
<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"
|
<button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
|
||||||
:class="combinedClasses" @click="handleClick">
|
:class="combinedClasses" @click="handleClick">
|
||||||
|
|
||||||
|
|
@ -29,9 +36,12 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { buttonResults } from './stores/buttonResults'
|
import { buttonResults } from './stores/buttonResults'
|
||||||
import { rateLimits } from './stores/rateLimits'
|
import { rateLimits } from './stores/rateLimits'
|
||||||
|
import { bindingExecutionState, setBindingExecutionState } from './stores/bindingExecutionState'
|
||||||
import { connectionState } from './stores/connectionState'
|
import { connectionState } from './stores/connectionState'
|
||||||
import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
|
import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { needsArgumentForm } from './utils/needsArgumentForm.js'
|
||||||
|
import { shouldSuppressPopupOnStartNavigation } from './utils/popupOnStartNavigation.js'
|
||||||
import { HugeiconsIcon } from '@hugeicons/vue'
|
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||||
import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
|
import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
|
|
@ -93,6 +103,40 @@ const combinedClasses = computed(() => {
|
||||||
return classes
|
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
|
// Timestamps
|
||||||
const updateIterationTimestamp = ref(0)
|
const updateIterationTimestamp = ref(0)
|
||||||
|
|
||||||
|
|
@ -110,7 +154,7 @@ function constructFromJson(json) {
|
||||||
navigateOnStart.value = 'pop'
|
navigateOnStart.value = 'pop'
|
||||||
} else if (popupOnStart.value === 'history') {
|
} else if (popupOnStart.value === 'history') {
|
||||||
navigateOnStart.value = 'hist'
|
navigateOnStart.value = 'hist'
|
||||||
} else if (props.actionData.arguments.length > 0) {
|
} else if (needsArgumentForm(props.actionData)) {
|
||||||
navigateOnStart.value = 'arg'
|
navigateOnStart.value = 'arg'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,6 +171,11 @@ function constructFromJson(json) {
|
||||||
// Also initialize the store so the watch picks it up
|
// Also initialize the store so the watch picks it up
|
||||||
if (bindingId.value) {
|
if (bindingId.value) {
|
||||||
rateLimits[bindingId.value] = rateLimitExpires.value
|
rateLimits[bindingId.value] = rateLimitExpires.value
|
||||||
|
setBindingExecutionState(
|
||||||
|
bindingId.value,
|
||||||
|
!!json.hasRunningInstance,
|
||||||
|
!!json.hasQueuedInstance
|
||||||
|
)
|
||||||
}
|
}
|
||||||
updateRateLimitStatus()
|
updateRateLimitStatus()
|
||||||
}
|
}
|
||||||
|
|
@ -198,7 +247,7 @@ async function handleClick() {
|
||||||
openActionDetails()
|
openActionDetails()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (props.actionData.arguments && props.actionData.arguments.length > 0) {
|
if (needsArgumentForm(props.actionData)) {
|
||||||
router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
|
router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
|
||||||
} else {
|
} else {
|
||||||
await startAction()
|
await startAction()
|
||||||
|
|
@ -300,7 +349,11 @@ function onExecutionQueued(_logEntry) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onExecutionStarted(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}`)
|
router.push(`/logs/${logEntry.executionTrackingId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,6 +450,26 @@ defineExpose({
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
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 {
|
.action-button button {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div :class = "statusClass + ' annotation'">
|
<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>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -12,17 +13,23 @@ const props = defineProps({
|
||||||
logEntry: {
|
logEntry: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
linkQueuedStatus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function isWaitingInQueue(logEntry) {
|
||||||
|
return logEntry &&
|
||||||
|
!logEntry.executionFinished &&
|
||||||
|
!logEntry.executionStarted
|
||||||
|
}
|
||||||
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
const logEntry = props.logEntry
|
const logEntry = props.logEntry
|
||||||
if (!logEntry) return 'unknown'
|
if (!logEntry) return 'unknown'
|
||||||
|
|
||||||
if (logEntry.queued && !logEntry.executionFinished) {
|
|
||||||
return 'Queued'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logEntry.executionFinished) {
|
if (logEntry.executionFinished) {
|
||||||
if (logEntry.blocked) {
|
if (logEntry.blocked) {
|
||||||
return 'Blocked'
|
return 'Blocked'
|
||||||
|
|
@ -31,9 +38,13 @@ const statusText = computed(() => {
|
||||||
} else {
|
} else {
|
||||||
return 'Completed'
|
return 'Completed'
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return 'Still running...'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWaitingInQueue(logEntry)) {
|
||||||
|
return 'Queued'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Still running...'
|
||||||
})
|
})
|
||||||
|
|
||||||
const exitCodeText = computed(() => {
|
const exitCodeText = computed(() => {
|
||||||
|
|
@ -51,6 +62,10 @@ const exitCodeText = computed(() => {
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showQueueLink = computed(() => {
|
||||||
|
return props.linkQueuedStatus && isWaitingInQueue(props.logEntry)
|
||||||
|
})
|
||||||
|
|
||||||
const statusClass = computed(() => {
|
const statusClass = computed(() => {
|
||||||
const logEntry = props.logEntry
|
const logEntry = props.logEntry
|
||||||
if (!logEntry) return ''
|
if (!logEntry) return ''
|
||||||
|
|
@ -86,5 +101,14 @@ const statusClass = computed(() => {
|
||||||
color: #ca79ff;
|
color: #ca79ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-status-link {
|
||||||
|
color: #0d6efd;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-status-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</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>
|
||||||
|
|
||||||
<div v-show="filteredLogs.length > 0">
|
<div v-show="filteredLogs.length > 0">
|
||||||
<table class="logs-table">
|
<table class="logs-table row-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="exit-code">
|
<td class="exit-code">
|
||||||
<ActionStatusDisplay :logEntry="log" />
|
<ActionStatusDisplay :logEntry="log" :link-queued-status="true" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -107,6 +107,8 @@ import Section from 'picocrank/vue/components/Section.vue'
|
||||||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||||
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
||||||
import { requestReconnectNow } from '../../../js/websocket.js'
|
import { requestReconnectNow } from '../../../js/websocket.js'
|
||||||
|
import { needsArgumentForm } from '../utils/needsArgumentForm.js'
|
||||||
|
import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -266,13 +268,6 @@ function syncDurationTicker() {
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (durationTicker != null) {
|
|
||||||
clearInterval(durationTicker)
|
|
||||||
durationTicker = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handlePageChange(page) {
|
function handlePageChange(page) {
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
fetchActionLogs()
|
fetchActionLogs()
|
||||||
|
|
@ -290,11 +285,16 @@ async function startAction() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (needsArgumentForm(action.value)) {
|
||||||
|
router.push(`/actionBinding/${action.value.bindingId}/argumentForm`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
requestReconnectNow()
|
requestReconnectNow()
|
||||||
const args = {
|
const args = {
|
||||||
"bindingId": action.value.bindingId,
|
bindingId: action.value.bindingId,
|
||||||
"arguments": []
|
arguments: []
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await window.client.startAction(args)
|
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(() => {
|
onMounted(() => {
|
||||||
fetchAction()
|
fetchAction()
|
||||||
fetchActionLogs()
|
fetchActionLogs()
|
||||||
|
window.addEventListener('EventExecutionStarted', onExecutionEvent)
|
||||||
|
window.addEventListener('EventExecutionFinished', onExecutionEvent)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -319,6 +334,15 @@ watch(
|
||||||
},
|
},
|
||||||
{ immediate: false }
|
{ immediate: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('EventExecutionStarted', onExecutionEvent)
|
||||||
|
window.removeEventListener('EventExecutionFinished', onExecutionEvent)
|
||||||
|
if (durationTicker != null) {
|
||||||
|
clearInterval(durationTicker)
|
||||||
|
durationTicker = null
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,13 @@
|
||||||
<span class="argument-description" v-html="arg.description"></span>
|
<span class="argument-description" v-html="arg.description"></span>
|
||||||
</template>
|
</template>
|
||||||
</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>
|
<p>No arguments required</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -76,6 +82,8 @@ const formErrors = ref({})
|
||||||
const actionArguments = ref([])
|
const actionArguments = ref([])
|
||||||
const popupOnStart = ref('')
|
const popupOnStart = ref('')
|
||||||
const formReady = ref(false)
|
const formReady = ref(false)
|
||||||
|
const justificationRequired = ref(false)
|
||||||
|
const justificationValue = ref('')
|
||||||
let isComponentMounted = true
|
let isComponentMounted = true
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
|
|
@ -103,6 +111,8 @@ async function setup() {
|
||||||
icon.value = action.icon
|
icon.value = action.icon
|
||||||
popupOnStart.value = action.popupOnStart || ''
|
popupOnStart.value = action.popupOnStart || ''
|
||||||
actionArguments.value = action.arguments || []
|
actionArguments.value = action.arguments || []
|
||||||
|
justificationRequired.value = action.justification || false
|
||||||
|
justificationValue.value = ''
|
||||||
argValues.value = {}
|
argValues.value = {}
|
||||||
formErrors.value = {}
|
formErrors.value = {}
|
||||||
confirmationChecked.value = false
|
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() {
|
function getArgumentValues() {
|
||||||
const ret = []
|
const ret = []
|
||||||
|
|
||||||
for (const arg of actionArguments.value) {
|
for (const arg of actionArguments.value) {
|
||||||
let value = argValues.value[arg.name] || ''
|
if (!shouldSendArgument(arg)) {
|
||||||
|
continue
|
||||||
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
|
|
||||||
value = value ? '1' : '0'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ret.push({
|
ret.push({
|
||||||
name: arg.name,
|
name: arg.name,
|
||||||
value: value
|
value: formatArgumentValueForApi(arg, argValues.value[arg.name])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -394,6 +426,10 @@ async function startAction(actionArgs) {
|
||||||
uniqueTrackingId: getUniqueId()
|
uniqueTrackingId: getUniqueId()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (justificationRequired.value) {
|
||||||
|
startActionArgs.justification = justificationValue.value
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
requestReconnectNow()
|
requestReconnectNow()
|
||||||
const response = await window.client.startAction(startActionArgs)
|
const response = await window.client.startAction(startActionArgs)
|
||||||
|
|
@ -418,6 +454,13 @@ async function handleSubmit(event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set custom validity for required fields
|
// 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) {
|
for (const arg of actionArguments.value) {
|
||||||
const value = argValues.value[arg.name]
|
const value = argValues.value[arg.name]
|
||||||
const inputElement = document.getElementById(arg.name)
|
const inputElement = document.getElementById(arg.name)
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,17 @@
|
||||||
|
|
||||||
<div v-if="logEntry" class = "flex-row">
|
<div v-if="logEntry" class = "flex-row">
|
||||||
<dl class = "fg1">
|
<dl class = "fg1">
|
||||||
|
<dt>Action</dt>
|
||||||
|
<dd>
|
||||||
|
<LogActionTitle :action-title="title" :justification="logEntry.justification" />
|
||||||
|
</dd>
|
||||||
|
|
||||||
<dt>Duration</dt>
|
<dt>Duration</dt>
|
||||||
<dd><span v-html="duration"></span></dd>
|
<dd><span v-html="duration"></span></dd>
|
||||||
|
|
||||||
<dt>Status</dt>
|
<dt>Status</dt>
|
||||||
<dd>
|
<dd class="execution-dialog-status">
|
||||||
<ActionStatusDisplay :log-entry="logEntry" id = "execution-dialog-status" />
|
<ActionStatusDisplay :log-entry="logEntry" :link-queued-status="true" />
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<ActionIconGlyph class="icon" role="img" :glyph="icon" style="align-self: start" />
|
<ActionIconGlyph class="icon" role="img" :glyph="icon" style="align-self: start" />
|
||||||
|
|
@ -64,6 +69,7 @@
|
||||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||||
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
||||||
|
import LogActionTitle from '../components/LogActionTitle.vue'
|
||||||
import Section from 'picocrank/vue/components/Section.vue'
|
import Section from 'picocrank/vue/components/Section.vue'
|
||||||
import { OutputTerminal } from '../../../js/OutputTerminal.js'
|
import { OutputTerminal } from '../../../js/OutputTerminal.js'
|
||||||
import { HugeiconsIcon } from '@hugeicons/vue'
|
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||||
|
|
@ -71,6 +77,7 @@ import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon } from '@hugeicons/core-fre
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { buttonResults } from '../stores/buttonResults'
|
import { buttonResults } from '../stores/buttonResults'
|
||||||
import { requestReconnectNow } from '../../../js/websocket.js'
|
import { requestReconnectNow } from '../../../js/websocket.js'
|
||||||
|
import { needsArgumentForm } from '../utils/needsArgumentForm.js'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
@ -178,10 +185,16 @@ async function rerunAction() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const binding = await window.client.getActionBinding({ bindingId })
|
||||||
|
if (needsArgumentForm(binding.action)) {
|
||||||
|
router.push(`/actionBinding/${bindingId}/argumentForm`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
requestReconnectNow()
|
requestReconnectNow()
|
||||||
const startActionArgs = {
|
const startActionArgs = {
|
||||||
"bindingId": bindingId,
|
bindingId: bindingId,
|
||||||
"arguments": []
|
arguments: []
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await window.client.startAction(startActionArgs)
|
const res = await window.client.startAction(startActionArgs)
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="logs.length > 0">
|
<div v-show="logs.length > 0">
|
||||||
<table class="logs-table">
|
<table class="logs-table row-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
<td>
|
<td>
|
||||||
<ActionIconGlyph class="icon" :glyph="log.actionIcon" />
|
<ActionIconGlyph class="icon" :glyph="log.actionIcon" />
|
||||||
<router-link :to="`/logs/${log.executionTrackingId}`">
|
<router-link :to="`/logs/${log.executionTrackingId}`">
|
||||||
{{ log.actionTitle }}
|
<LogActionTitle :action-title="log.actionTitle" :justification="log.justification" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td class="tags">
|
<td class="tags">
|
||||||
|
|
@ -123,6 +123,8 @@ import Section from 'picocrank/vue/components/Section.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
||||||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||||
|
import LogActionTitle from '../components/LogActionTitle.vue'
|
||||||
|
import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -254,8 +256,30 @@ function handlePageSizeChange(newPageSize) {
|
||||||
fetchLogs()
|
fetchLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onExecutionEvent(evt) {
|
||||||
|
const logEntry = getExecutionLogEntry(evt)
|
||||||
|
if (!logEntry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateLogEntryInList(logs.value, logEntry)) {
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateDateFromRoute()
|
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(() => {
|
onUnmounted(() => {
|
||||||
|
|
|
||||||
|
|
@ -11,86 +11,240 @@
|
||||||
|
|
||||||
<p class="padding">{{ t('logs.queue-page-description') }}</p>
|
<p class="padding">{{ t('logs.queue-page-description') }}</p>
|
||||||
|
|
||||||
<div v-if="groups.length > 0" class="queue-groups padding">
|
<div v-if="groups.length === 0 && !loading" class="empty-state padding">
|
||||||
<section v-for="group in groups" :key="group.bindingId" class="queue-group">
|
|
||||||
<header class="queue-group-header">
|
|
||||||
<ActionIconGlyph class="icon" :glyph="group.actionIcon" />
|
|
||||||
<div class="queue-group-title">
|
|
||||||
<h3>{{ group.actionTitle }}</h3>
|
|
||||||
<p v-if="group.entityPrefix" class="queue-entity">
|
|
||||||
{{ t('logs.queue-entity') }}: {{ group.entityPrefix }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="queue-group-limit annotation">
|
|
||||||
{{ t('logs.queue-group-active', { active: group.activeCount, max: group.maxConcurrent }) }}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<table class="logs-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{ t('logs.timestamp') }}</th>
|
|
||||||
<th>{{ t('logs.metadata') }}</th>
|
|
||||||
<th>{{ t('logs.status') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(entry, index) in group.entries" :key="entry.executionTrackingId" class="log-row">
|
|
||||||
<td class="timestamp">{{ formatTimestamp(entry.datetimeStarted) }}</td>
|
|
||||||
<td class="tags">
|
|
||||||
<span class="annotation">
|
|
||||||
<span class="annotation-key">User:</span>
|
|
||||||
<span class="annotation-val">{{ entry.user }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="entry.tags && entry.tags.length > 0" class="tag-list">
|
|
||||||
<span v-for="tag in entry.tags" :key="tag" class="tag">{{ tag }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="annotation">
|
|
||||||
<span class="annotation-key">ID:</span>
|
|
||||||
<router-link :to="`/logs/${entry.executionTrackingId}`">
|
|
||||||
{{ entry.executionTrackingId }}
|
|
||||||
</router-link>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="exit-code">
|
|
||||||
<span class="annotation">
|
|
||||||
<span class="queue-position">{{ t('logs.queue-position', { position: index + 1 }) }}</span>
|
|
||||||
<span :class="queueStatusClass(entry)">{{ queueStatusText(entry) }}</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="!loading" class="empty-state padding">
|
|
||||||
<p>{{ t('logs.queue-empty') }}</p>
|
<p>{{ t('logs.queue-empty') }}</p>
|
||||||
<router-link to="/logs">{{ t('logs.back-to-list') }}</router-link>
|
<router-link to="/logs">{{ t('logs.back-to-list') }}</router-link>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.bindingId"
|
||||||
|
class="with-header-and-content queue-group-section"
|
||||||
|
>
|
||||||
|
<div class="section-header flex-row">
|
||||||
|
<div class="fg1 queue-group-heading">
|
||||||
|
<ActionIconGlyph class="queue-group-icon" :glyph="group.actionIcon" />
|
||||||
|
<div class="queue-group-title">
|
||||||
|
<h2 :title="group.entityPrefix ? `${t('logs.queue-entity')}: ${group.entityPrefix}` : ''">
|
||||||
|
{{ group.actionTitle }}
|
||||||
|
</h2>
|
||||||
|
<p v-if="group.entityPrefix" class="queue-entity">
|
||||||
|
{{ t('logs.queue-entity') }}: {{ group.entityPrefix }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div role="toolbar" class="queue-group-toolbar">
|
||||||
|
<router-link
|
||||||
|
v-if="group.bindingId"
|
||||||
|
:to="`/action/${group.bindingId}`"
|
||||||
|
class="button neutral"
|
||||||
|
:title="t('logs.queue-action-details')"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.31-8.86c-1.77-.45-2.34-.94-2.34-1.67 0-.84.79-1.43 2.1-1.43 1.38 0 1.9.66 1.94 1.64h1.71c-.05-1.34-.87-2.57-2.49-2.97V5H10.9v1.69c-1.51.32-2.72 1.3-2.72 2.81 0 1.79 1.49 2.69 3.66 3.21 1.95.46 2.34 1.22 2.34 1.8 0 .53-.39 1.39-2.1 1.39-1.6 0-2.05-.56-2.13-1.45H8.04c.08 1.5 1.18 2.37 2.82 2.69V19h2.34v-1.63c1.65-.35 2.48-1.24 2.48-2.77-.01-1.88-1.51-2.87-3.7-3.23z"/>
|
||||||
|
</svg>
|
||||||
|
{{ t('logs.queue-action-details') }}
|
||||||
|
</router-link>
|
||||||
|
<span class="queue-group-limit annotation">
|
||||||
|
{{ t('logs.queue-group-active', { active: group.activeCount, max: group.maxConcurrent }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-content">
|
||||||
|
<table class="logs-table row-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('logs.timestamp') }}</th>
|
||||||
|
<th>{{ t('logs.metadata') }}</th>
|
||||||
|
<th>{{ t('logs.status') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(entry, index) in group.entries" :key="entry.executionTrackingId" class="log-row">
|
||||||
|
<td class="timestamp">{{ formatTimestamp(entry.datetimeStarted) }}</td>
|
||||||
|
<td class="tags">
|
||||||
|
<span class="annotation">
|
||||||
|
<span class="annotation-key">User:</span>
|
||||||
|
<span class="annotation-val">{{ entry.user }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="entry.tags && entry.tags.length > 0" class="tag-list">
|
||||||
|
<span v-for="tag in entry.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="annotation">
|
||||||
|
<span class="annotation-key">ID:</span>
|
||||||
|
<router-link :to="`/logs/${entry.executionTrackingId}`">
|
||||||
|
{{ entry.executionTrackingId }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="exit-code">
|
||||||
|
<span v-if="!entry.executionFinished" class="queue-position">{{ t('logs.queue-position', { position: index + 1 }) }}</span>
|
||||||
|
<ActionStatusDisplay :logEntry="entry" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import Section from 'picocrank/vue/components/Section.vue'
|
import Section from 'picocrank/vue/components/Section.vue'
|
||||||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||||
|
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const groups = ref([])
|
const groups = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
function queueStatusText (entry) {
|
function collectCompletedEntries (currentGroups) {
|
||||||
if (entry.executionStarted) {
|
const completed = []
|
||||||
return t('logs.queue-running')
|
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) {
|
function sortGroupEntries (entries) {
|
||||||
return entry.executionStarted ? 'queue-status-running' : 'queue-status-waiting'
|
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) {
|
function formatTimestamp (timestamp) {
|
||||||
|
|
@ -107,8 +261,9 @@ function formatTimestamp (timestamp) {
|
||||||
async function fetchQueue () {
|
async function fetchQueue () {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
const completedEntries = collectCompletedEntries(groups.value)
|
||||||
const response = await window.client.getExecutionQueue({})
|
const response = await window.client.getExecutionQueue({})
|
||||||
groups.value = response.groups || []
|
groups.value = mergeCompletedEntries(response.groups || [], completedEntries)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch execution queue:', err)
|
console.error('Failed to fetch execution queue:', err)
|
||||||
window.showBigError('fetch-queue', 'getting execution queue', err, false)
|
window.showBigError('fetch-queue', 'getting execution queue', err, false)
|
||||||
|
|
@ -119,45 +274,37 @@ async function fetchQueue () {
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchQueue()
|
fetchQueue()
|
||||||
window.addEventListener('EventExecutionStarted', fetchQueue)
|
window.addEventListener('EventExecutionStarted', onExecutionStarted)
|
||||||
window.addEventListener('EventExecutionFinished', fetchQueue)
|
window.addEventListener('EventExecutionFinished', onExecutionFinished)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('EventExecutionStarted', fetchQueue)
|
window.removeEventListener('EventExecutionStarted', onExecutionStarted)
|
||||||
window.removeEventListener('EventExecutionFinished', fetchQueue)
|
window.removeEventListener('EventExecutionFinished', onExecutionFinished)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.queue-groups {
|
.queue-group-heading {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem 1rem;
|
min-width: 0;
|
||||||
background: var(--section-background, #f8f9fa);
|
|
||||||
border-bottom: 1px solid var(--border-color, #ccc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-group-title {
|
.queue-group-title h2 {
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-group-title h3 {
|
|
||||||
margin: 0;
|
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 {
|
.queue-entity {
|
||||||
|
|
@ -166,12 +313,9 @@ onUnmounted(() => {
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-group-limit {
|
.queue-group-icon {
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp {
|
.timestamp {
|
||||||
|
|
@ -185,16 +329,14 @@ onUnmounted(() => {
|
||||||
font-size: smaller;
|
font-size: smaller;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exit-code {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-position {
|
.queue-position {
|
||||||
margin-right: 0.5rem;
|
white-space: nowrap;
|
||||||
}
|
|
||||||
|
|
||||||
.queue-status-running {
|
|
||||||
color: var(--karma-warning-fg, #856404);
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-status-waiting {
|
|
||||||
color: #0d6efd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { Condition } from 'selenium-webdriver'
|
||||||
|
|
||||||
export const DEFAULT_UI_WAIT_MS = 3000
|
export const DEFAULT_UI_WAIT_MS = 3000
|
||||||
|
|
||||||
|
const executionDialogStatusBy = By.css('.execution-dialog-status')
|
||||||
|
|
||||||
export async function getActionButtons () {
|
export async function getActionButtons () {
|
||||||
// Currently, only the active dashboard's contents are rendered,
|
// Currently, only the active dashboard's contents are rendered,
|
||||||
// so we don't need to scope the selector by dashboard title.
|
// 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) {
|
export async function waitForExecutionComplete(timeoutMs = DEFAULT_UI_WAIT_MS) {
|
||||||
await webdriver.wait(new Condition('wait for execution status', async () => {
|
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
|
return statusElements.length > 0
|
||||||
}), timeoutMs)
|
}), timeoutMs)
|
||||||
|
|
||||||
await webdriver.wait(new Condition('wait for execution to finish', async () => {
|
await webdriver.wait(new Condition('wait for execution to finish', async () => {
|
||||||
try {
|
try {
|
||||||
const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
|
const statusElement = await webdriver.findElement(executionDialogStatusBy)
|
||||||
const statusText = await statusElement.getText()
|
const statusText = await statusElement.getText()
|
||||||
return !statusText.includes('Still running')
|
return !statusText.includes('Still running')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -163,7 +165,7 @@ export async function getNavigationLinks() {
|
||||||
|
|
||||||
export async function requireExecutionDialogStatus (webdriver, expected) {
|
export async function requireExecutionDialogStatus (webdriver, expected) {
|
||||||
await webdriver.wait(new Condition('wait for action to be running', async function () {
|
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()
|
const actual = await dialogStatus.getText()
|
||||||
|
|
||||||
if (actual === expected) {
|
if (actual === expected) {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ describe('config: entityFilesWithLongIntsUseStandardForm', function () {
|
||||||
await waitForLogsPage()
|
await waitForLogsPage()
|
||||||
await waitForExecutionComplete()
|
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()
|
const statusText = await statusElement.getText()
|
||||||
|
|
||||||
// The status should indicate success (not "Executing..." or "Failed")
|
// The status should indicate success (not "Executing..." or "Failed")
|
||||||
|
|
|
||||||
|
|
@ -350,4 +350,4 @@
|
||||||
"welcome": "欢迎使用 OliveTin"
|
"welcome": "欢迎使用 OliveTin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ message Action {
|
||||||
repeated string exec_on_file_changed_in_dir = 13;
|
repeated string exec_on_file_changed_in_dir = 13;
|
||||||
string exec_on_calendar_file = 14;
|
string exec_on_calendar_file = 14;
|
||||||
repeated ActionWebhookExecHint exec_on_webhooks = 15;
|
repeated ActionWebhookExecHint exec_on_webhooks = 15;
|
||||||
|
bool justification = 16;
|
||||||
|
bool has_running_instance = 17;
|
||||||
|
bool has_queued_instance = 18;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ActionWebhookExecHint {
|
message ActionWebhookExecHint {
|
||||||
|
|
@ -95,6 +98,7 @@ message StartActionRequest {
|
||||||
repeated StartActionArgument arguments = 2;
|
repeated StartActionArgument arguments = 2;
|
||||||
|
|
||||||
string unique_tracking_id = 3;
|
string unique_tracking_id = 3;
|
||||||
|
string justification = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message StartActionArgument {
|
message StartActionArgument {
|
||||||
|
|
@ -110,6 +114,8 @@ message StartActionAndWaitRequest {
|
||||||
string action_id = 1;
|
string action_id = 1;
|
||||||
|
|
||||||
repeated StartActionArgument arguments = 2;
|
repeated StartActionArgument arguments = 2;
|
||||||
|
|
||||||
|
string justification = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message StartActionAndWaitResponse {
|
message StartActionAndWaitResponse {
|
||||||
|
|
@ -160,6 +166,7 @@ message LogEntry {
|
||||||
string binding_id = 20; // Binding ID for matching rate limits to action buttons
|
string binding_id = 20; // Binding ID for matching rate limits to action buttons
|
||||||
bool queued = 21;
|
bool queued = 21;
|
||||||
string queued_for_group = 22;
|
string queued_for_group = 22;
|
||||||
|
string justification = 23;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetLogsResponse {
|
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"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
@ -177,6 +180,27 @@ func (x *Action) GetExecOnWebhooks() []*ActionWebhookExecHint {
|
||||||
return nil
|
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 {
|
type ActionWebhookExecHint struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Template string `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
|
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"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
@ -857,6 +882,13 @@ func (x *StartActionRequest) GetUniqueTrackingId() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *StartActionRequest) GetJustification() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Justification
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type StartActionArgument struct {
|
type StartActionArgument struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
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"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
ActionId string `protobuf:"bytes,1,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
@ -1005,6 +1038,13 @@ func (x *StartActionAndWaitRequest) GetArguments() []*StartActionArgument {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *StartActionAndWaitRequest) GetJustification() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Justification
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type StartActionAndWaitResponse struct {
|
type StartActionAndWaitResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
LogEntry *LogEntry `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"`
|
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
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
@ -1489,6 +1530,13 @@ func (x *LogEntry) GetQueuedForGroup() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *LogEntry) GetJustification() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Justification
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type GetLogsResponse struct {
|
type GetLogsResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"`
|
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 = "" +
|
const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\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" +
|
"\x06Action\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"binding_id\x18\x01 \x01(\tR\tbindingId\x12\x14\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_created_in_dir\x18\f \x03(\tR\x16execOnFileCreatedInDir\x12;\n" +
|
||||||
"\x1bexec_on_file_changed_in_dir\x18\r \x03(\tR\x16execOnFileChangedInDir\x121\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" +
|
"\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" +
|
"\x15ActionWebhookExecHint\x12\x1a\n" +
|
||||||
"\btemplate\x18\x01 \x01(\tR\btemplate\x12\x1d\n" +
|
"\btemplate\x18\x01 \x01(\tR\btemplate\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
|
|
@ -4359,20 +4410,22 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"\ventity_type\x18\a \x01(\tR\n" +
|
"\ventity_type\x18\a \x01(\tR\n" +
|
||||||
"entityType\x12\x1d\n" +
|
"entityType\x12\x1d\n" +
|
||||||
"\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" +
|
"\x12StartActionRequest\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"binding_id\x18\x01 \x01(\tR\tbindingId\x12B\n" +
|
"binding_id\x18\x01 \x01(\tR\tbindingId\x12B\n" +
|
||||||
"\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\x12,\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" +
|
"\x13StartActionArgument\x12\x12\n" +
|
||||||
"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
|
"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
|
||||||
"\x05value\x18\x02 \x01(\tR\x05value\"I\n" +
|
"\x05value\x18\x02 \x01(\tR\x05value\"I\n" +
|
||||||
"\x13StartActionResponse\x122\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" +
|
"\x19StartActionAndWaitRequest\x12\x1b\n" +
|
||||||
"\taction_id\x18\x01 \x01(\tR\bactionId\x12B\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" +
|
"\x1aStartActionAndWaitResponse\x126\n" +
|
||||||
"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"6\n" +
|
"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"6\n" +
|
||||||
"\x17StartActionByGetRequest\x12\x1b\n" +
|
"\x17StartActionByGetRequest\x12\x1b\n" +
|
||||||
|
|
@ -4388,7 +4441,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"\vdate_filter\x18\x02 \x01(\tR\n" +
|
"\vdate_filter\x18\x02 \x01(\tR\n" +
|
||||||
"dateFilter\x12\x1b\n" +
|
"dateFilter\x12\x1b\n" +
|
||||||
"\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x16\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" +
|
"\bLogEntry\x12)\n" +
|
||||||
"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
|
"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
|
||||||
"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
|
"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
|
||||||
|
|
@ -4413,7 +4466,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" +
|
"binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" +
|
||||||
"\x06queued\x18\x15 \x01(\bR\x06queued\x12(\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" +
|
"\x0fGetLogsResponse\x12-\n" +
|
||||||
"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\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" +
|
"\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) {
|
func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.StartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
|
||||||
args := make(map[string]string)
|
pair, err := api.findBindingByIDOrNotFound(req.Msg.BindingId)
|
||||||
|
if err != nil {
|
||||||
for _, arg := range req.Msg.Arguments {
|
return nil, err
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
|
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{
|
execReq := executor.ExecutionRequest{
|
||||||
Binding: pair,
|
Binding: pair,
|
||||||
TrackingID: req.Msg.UniqueTrackingId,
|
TrackingID: req.Msg.UniqueTrackingId,
|
||||||
Arguments: args,
|
Arguments: startActionArgumentsFromProto(req.Msg.Arguments),
|
||||||
|
Justification: req.Msg.Justification,
|
||||||
AuthenticatedUser: authenticatedUser,
|
AuthenticatedUser: authenticatedUser,
|
||||||
Cfg: api.cfg,
|
Cfg: api.cfg,
|
||||||
}
|
}
|
||||||
|
|
||||||
api.executor.ExecRequest(&execReq)
|
api.executor.ExecRequest(&execReq)
|
||||||
|
|
||||||
ret := &apiv1.StartActionResponse{
|
return connect.NewResponse(&apiv1.StartActionResponse{
|
||||||
ExecutionTrackingId: execReq.TrackingID,
|
ExecutionTrackingId: execReq.TrackingID,
|
||||||
}
|
}), nil
|
||||||
|
|
||||||
return connect.NewResponse(ret), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1.PasswordHashRequest]) (*connect.Response[apiv1.PasswordHashResponse], error) {
|
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
|
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{
|
execReq := executor.ExecutionRequest{
|
||||||
Binding: binding,
|
Binding: binding,
|
||||||
TrackingID: uuid.NewString(),
|
TrackingID: uuid.NewString(),
|
||||||
Arguments: args,
|
Arguments: args,
|
||||||
|
Justification: justification,
|
||||||
AuthenticatedUser: user,
|
AuthenticatedUser: user,
|
||||||
Cfg: api.cfg,
|
Cfg: api.cfg,
|
||||||
}
|
}
|
||||||
|
|
@ -280,19 +276,22 @@ func (api *oliveTinAPI) findBindingOrNotFound(actionId string) (*executor.Action
|
||||||
return binding, nil
|
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) {
|
func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
|
||||||
binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
|
binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
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 {
|
if !ok {
|
||||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
|
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,
|
User: logEntry.Username,
|
||||||
BindingId: logEntry.GetBindingId(),
|
BindingId: logEntry.GetBindingId(),
|
||||||
DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
|
DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
|
||||||
|
Justification: logEntry.Justification,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
|
if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
|
||||||
|
|
@ -531,11 +531,7 @@ func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedU
|
||||||
}
|
}
|
||||||
|
|
||||||
return &apiv1.GetActionBindingResponse{
|
return &apiv1.GetActionBindingResponse{
|
||||||
Action: buildAction(binding, &DashboardRenderRequest{
|
Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
|
||||||
cfg: api.cfg,
|
|
||||||
AuthenticatedUser: user,
|
|
||||||
ex: api.executor,
|
|
||||||
}),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -576,13 +572,15 @@ func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
|
func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
|
||||||
return &DashboardRenderRequest{
|
rr := &DashboardRenderRequest{
|
||||||
AuthenticatedUser: user,
|
AuthenticatedUser: user,
|
||||||
cfg: api.cfg,
|
cfg: api.cfg,
|
||||||
ex: api.executor,
|
ex: api.executor,
|
||||||
EntityType: entityType,
|
EntityType: entityType,
|
||||||
EntityKey: entityKey,
|
EntityKey: entityKey,
|
||||||
}
|
}
|
||||||
|
populateActiveBindingStates(rr)
|
||||||
|
return rr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *oliveTinAPI) isDefaultDashboard(title string) bool {
|
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) {
|
func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
|
||||||
ret := &apiv1.StartActionResponse{
|
execReqLogEntry, err := api.restartActionLogEntry(req.Msg.ExecutionTrackingId)
|
||||||
ExecutionTrackingId: req.Msg.ExecutionTrackingId,
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
execReqLogEntry, found := api.executor.GetLog(req.Msg.ExecutionTrackingId)
|
if execReqLogEntry.Binding.Action.Justification {
|
||||||
|
return nil, restartRequiresJustificationError()
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
|
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{
|
execReq := executor.ExecutionRequest{
|
||||||
Binding: execReqLogEntry.Binding,
|
Binding: execReqLogEntry.Binding,
|
||||||
Arguments: make(map[string]string),
|
Arguments: make(map[string]string),
|
||||||
|
|
@ -1548,8 +1532,26 @@ func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv
|
||||||
|
|
||||||
api.executor.ExecRequest(&execReq)
|
api.executor.ExecRequest(&execReq)
|
||||||
|
|
||||||
ret.ExecutionTrackingId = execReq.TrackingID
|
return connect.NewResponse(&apiv1.StartActionResponse{
|
||||||
return connect.NewResponse(ret), nil
|
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 {
|
func newServer(ex *executor.Executor) *oliveTinAPI {
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,56 @@ import (
|
||||||
"github.com/OliveTin/OliveTin/internal/tpl"
|
"github.com/OliveTin/OliveTin/internal/tpl"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type bindingActiveState struct {
|
||||||
|
hasRunning bool
|
||||||
|
hasQueued bool
|
||||||
|
}
|
||||||
|
|
||||||
type DashboardRenderRequest struct {
|
type DashboardRenderRequest struct {
|
||||||
AuthenticatedUser *authpublic.AuthenticatedUser
|
AuthenticatedUser *authpublic.AuthenticatedUser
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
ex *executor.Executor
|
ex *executor.Executor
|
||||||
EntityType string
|
EntityType string
|
||||||
EntityKey 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 {
|
func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
|
||||||
|
|
@ -135,43 +179,56 @@ func actionFromBinding(actionBinding *executor.ActionBinding) (*executor.ActionB
|
||||||
return actionBinding, actionBinding.Action
|
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 {
|
func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
|
||||||
binding, action := actionFromBinding(actionBinding)
|
binding, action := actionFromBinding(actionBinding)
|
||||||
if binding == nil {
|
if binding == nil {
|
||||||
return 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{
|
btn := apiv1.Action{
|
||||||
BindingId: binding.ID,
|
BindingId: binding.ID,
|
||||||
Title: tpl.ParseTemplateOfActionBeforeExec(action.Title, binding.Entity),
|
Title: tpl.ParseTemplateOfActionBeforeExec(action.Title, binding.Entity),
|
||||||
Icon: tpl.ParseTemplateOfActionBeforeExec(action.Icon, binding.Entity),
|
Icon: tpl.ParseTemplateOfActionBeforeExec(action.Icon, binding.Entity),
|
||||||
CanExec: aclCanExec && enabledExprCanExec,
|
CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action) && evaluateEnabledExpression(action, binding.Entity),
|
||||||
PopupOnStart: action.PopupOnStart,
|
PopupOnStart: action.OnClick,
|
||||||
Order: int32(binding.ConfigOrder),
|
Order: int32(binding.ConfigOrder),
|
||||||
Timeout: int32(action.Timeout),
|
Timeout: int32(action.Timeout),
|
||||||
DatetimeRateLimitExpires: datetimeRateLimitExpires,
|
DatetimeRateLimitExpires: formatRateLimitExpiry(rr.ex.GetTimeUntilAvailable(binding)),
|
||||||
|
Justification: action.Justification,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyActiveBindingStateToAction(&btn, binding.ID, rr.activeBindingStates)
|
||||||
applyActionExecTriggers(&btn, action)
|
applyActionExecTriggers(&btn, action)
|
||||||
|
btn.Arguments = buildActionArguments(action, binding.Entity)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &btn
|
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"`
|
MaxConcurrent int `koanf:"maxConcurrent"`
|
||||||
MaxRate []RateSpec `koanf:"maxRate"`
|
MaxRate []RateSpec `koanf:"maxRate"`
|
||||||
Arguments []ActionArgument `koanf:"arguments"`
|
Arguments []ActionArgument `koanf:"arguments"`
|
||||||
|
OnClick string `koanf:"onclick"`
|
||||||
PopupOnStart string `koanf:"popupOnStart"`
|
PopupOnStart string `koanf:"popupOnStart"`
|
||||||
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
|
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
|
||||||
EnabledExpression string `koanf:"enabledExpression"`
|
EnabledExpression string `koanf:"enabledExpression"`
|
||||||
Groups []string `koanf:"groups"`
|
Groups []string `koanf:"groups"`
|
||||||
|
Justification bool `koanf:"justification"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionGroup defines shared limits and metadata for a set of actions.
|
// ActionGroup defines shared limits and metadata for a set of actions.
|
||||||
|
|
@ -70,14 +72,15 @@ type RateSpec struct {
|
||||||
|
|
||||||
// WebhookConfig defines configuration for generic webhook triggers.
|
// WebhookConfig defines configuration for generic webhook triggers.
|
||||||
type WebhookConfig struct {
|
type WebhookConfig struct {
|
||||||
Secret string `koanf:"secret"` // Optional: secret for signature verification
|
Secret string `koanf:"secret"` // Optional: secret for signature verification
|
||||||
AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none"
|
AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none"
|
||||||
AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature")
|
AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature")
|
||||||
MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers
|
MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers
|
||||||
MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath")
|
MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath")
|
||||||
MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters
|
MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters
|
||||||
Extract map[string]string `koanf:"extract"` // Map action argument names to JSONPath expressions
|
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")
|
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.
|
// Entity represents a "thing" that can have multiple actions associated with it.
|
||||||
|
|
@ -175,6 +178,7 @@ type Config struct {
|
||||||
WebUIDir string `koanf:"webUIDir"`
|
WebUIDir string `koanf:"webUIDir"`
|
||||||
CronSupportForSeconds bool `koanf:"cronSupportForSeconds"`
|
CronSupportForSeconds bool `koanf:"cronSupportForSeconds"`
|
||||||
SectionNavigationStyle string `koanf:"sectionNavigationStyle"`
|
SectionNavigationStyle string `koanf:"sectionNavigationStyle"`
|
||||||
|
DefaultOnClick string `koanf:"defaultOnClick"`
|
||||||
DefaultPopupOnStart string `koanf:"defaultPopupOnStart"`
|
DefaultPopupOnStart string `koanf:"defaultPopupOnStart"`
|
||||||
InsecureAllowDumpOAuth2UserData bool `koanf:"insecureAllowDumpOAuth2UserData"`
|
InsecureAllowDumpOAuth2UserData bool `koanf:"insecureAllowDumpOAuth2UserData"`
|
||||||
InsecureAllowDumpVars bool `koanf:"insecureAllowDumpVars"`
|
InsecureAllowDumpVars bool `koanf:"insecureAllowDumpVars"`
|
||||||
|
|
@ -290,6 +294,7 @@ func DefaultConfigWithBasePort(basePort int) *Config {
|
||||||
config.WebUIDir = "./webui"
|
config.WebUIDir = "./webui"
|
||||||
config.CronSupportForSeconds = false
|
config.CronSupportForSeconds = false
|
||||||
config.SectionNavigationStyle = "sidebar"
|
config.SectionNavigationStyle = "sidebar"
|
||||||
|
config.DefaultOnClick = "nothing"
|
||||||
config.DefaultPopupOnStart = "nothing"
|
config.DefaultPopupOnStart = "nothing"
|
||||||
config.InsecureAllowDumpVars = false
|
config.InsecureAllowDumpVars = false
|
||||||
config.InsecureAllowDumpSos = false
|
config.InsecureAllowDumpSos = false
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ func (cfg *Config) Sanitize() {
|
||||||
cfg.sanitizeLogHistoryPageSize()
|
cfg.sanitizeLogHistoryPageSize()
|
||||||
cfg.sanitizeLocalUsers()
|
cfg.sanitizeLocalUsers()
|
||||||
cfg.sanitizeSecurityHeaders()
|
cfg.sanitizeSecurityHeaders()
|
||||||
|
cfg.sanitizeOnClickDefaults()
|
||||||
|
|
||||||
// log.Infof("cfg %p", cfg)
|
// log.Infof("cfg %p", cfg)
|
||||||
|
|
||||||
|
|
@ -177,7 +178,9 @@ func (action *Action) sanitize(cfg *Config) {
|
||||||
|
|
||||||
action.ID = getActionID(action)
|
action.ID = getActionID(action)
|
||||||
action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions)
|
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 {
|
if action.MaxConcurrent < 1 {
|
||||||
action.MaxConcurrent = 1
|
action.MaxConcurrent = 1
|
||||||
|
|
@ -359,7 +362,7 @@ func getActionID(action *Action) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
//gocyclo:ignore
|
//gocyclo:ignore
|
||||||
func sanitizePopupOnStart(raw string, cfg *Config) string {
|
func sanitizeOnClick(raw string, cfg *Config) string {
|
||||||
switch raw {
|
switch raw {
|
||||||
case "execution-dialog":
|
case "execution-dialog":
|
||||||
return raw
|
return raw
|
||||||
|
|
@ -372,10 +375,43 @@ func sanitizePopupOnStart(raw string, cfg *Config) string {
|
||||||
case "history":
|
case "history":
|
||||||
return raw
|
return raw
|
||||||
default:
|
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() {
|
func (arg *ActionArgument) sanitize() {
|
||||||
if arg.Title == "" {
|
if arg.Title == "" {
|
||||||
arg.Title = arg.Name
|
arg.Title = arg.Name
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,70 @@ func TestSanitizePopupOnStartHistory(t *testing.T) {
|
||||||
|
|
||||||
a := c.findAction("With history")
|
a := c.findAction("With history")
|
||||||
if assert.NotNil(t, a) {
|
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) {
|
func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
|
||||||
c := DefaultConfig()
|
c := DefaultConfig()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ type ExecutionRequest struct {
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
AuthenticatedUser *authpublic.AuthenticatedUser
|
AuthenticatedUser *authpublic.AuthenticatedUser
|
||||||
TriggerDepth int
|
TriggerDepth int
|
||||||
|
Justification string
|
||||||
|
|
||||||
logEntry *InternalLogEntry
|
logEntry *InternalLogEntry
|
||||||
finalParsedCommand string
|
finalParsedCommand string
|
||||||
|
|
@ -166,8 +167,9 @@ type InternalLogEntry struct {
|
||||||
that logs are lightweight (so we don't need to have an action associated to
|
that logs are lightweight (so we don't need to have an action associated to
|
||||||
logs, etc. Therefore, we duplicate those values here.
|
logs, etc. Therefore, we duplicate those values here.
|
||||||
*/
|
*/
|
||||||
ActionTitle string
|
ActionTitle string
|
||||||
ActionIcon string
|
ActionIcon string
|
||||||
|
Justification string
|
||||||
}
|
}
|
||||||
|
|
||||||
// .Binding can be nil, so we need to handle that.
|
// .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)
|
trackingIds := make([]*InternalLogEntry, 0, pageCount)
|
||||||
|
|
||||||
if totalLogCount > 0 {
|
if totalLogCount > 0 {
|
||||||
for i := endIndex; i <= startIndex; i++ {
|
for i := startIndex; i >= endIndex; i-- {
|
||||||
trackingIds = append(trackingIds, e.logs[e.logsTrackingIdsByDate[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)
|
endIndex := max(0, (startIndex-pageCount)+1)
|
||||||
|
|
||||||
out := make([]*InternalLogEntry, 0, pageCount)
|
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])
|
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.ActionTitle = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Title, req.Binding.Entity)
|
||||||
entry.ActionIcon = req.Binding.Action.Icon
|
entry.ActionIcon = req.Binding.Action.Icon
|
||||||
entry.Tags = req.Tags
|
entry.Tags = req.Tags
|
||||||
|
entry.Justification = ResolveJustification(req)
|
||||||
if req.Binding.Entity != nil {
|
if req.Binding.Entity != nil {
|
||||||
entry.EntityPrefix = req.Binding.Entity.UniqueKey
|
entry.EntityPrefix = req.Binding.Entity.UniqueKey
|
||||||
}
|
}
|
||||||
|
|
@ -1148,9 +1151,18 @@ func prepareCommand(cmd *exec.Cmd, streamer *OutputStreamer, req *ExecutionReque
|
||||||
cmd.Stdout = streamer
|
cmd.Stdout = streamer
|
||||||
cmd.Stderr = streamer
|
cmd.Stderr = streamer
|
||||||
cmd.Env = buildEnv(req.Arguments)
|
cmd.Env = buildEnv(req.Arguments)
|
||||||
|
|
||||||
|
started := false
|
||||||
req.mutateLogEntry(func(entry *InternalLogEntry) {
|
req.mutateLogEntry(func(entry *InternalLogEntry) {
|
||||||
|
if entry.ExecutionStarted {
|
||||||
|
return
|
||||||
|
}
|
||||||
entry.ExecutionStarted = true
|
entry.ExecutionStarted = true
|
||||||
|
started = true
|
||||||
})
|
})
|
||||||
|
if started {
|
||||||
|
notifyListenersStarted(req)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stepExecAfter(req *ExecutionRequest) bool {
|
func stepExecAfter(req *ExecutionRequest) bool {
|
||||||
|
|
@ -1284,6 +1296,7 @@ func triggerLoop(req *ExecutionRequest) {
|
||||||
Arguments: req.Arguments,
|
Arguments: req.Arguments,
|
||||||
Cfg: req.Cfg,
|
Cfg: req.Cfg,
|
||||||
TriggerDepth: req.TriggerDepth + 1,
|
TriggerDepth: req.TriggerDepth + 1,
|
||||||
|
Justification: fmt.Sprintf("Triggered by action: %s", req.logEntry.ActionTitle),
|
||||||
}
|
}
|
||||||
|
|
||||||
req.executor.ExecRequest(trigger)
|
req.executor.ExecRequest(trigger)
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,79 @@ func TestGroupConcurrencyQueuesSecondAction(t *testing.T) {
|
||||||
assert.Contains(t, snapshot.Output, "queued-run")
|
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) {
|
func TestDifferentGroupsRunConcurrently(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|
@ -213,6 +286,30 @@ func waitUntilExecutionStarted(t *testing.T, e *Executor, trackingID string) {
|
||||||
}, 2*time.Second, 10*time.Millisecond)
|
}, 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) {
|
func assertWaitGroupPending(t *testing.T, wg *sync.WaitGroup) {
|
||||||
t.Helper()
|
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
|
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
|
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)
|
binding := h.executor.FindBindingWithNoEntity(action)
|
||||||
if binding == nil {
|
if binding == nil {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
|
|
@ -156,6 +165,7 @@ func (h *WebhookHandler) executeAction(action *config.Action, args map[string]st
|
||||||
Cfg: h.cfg,
|
Cfg: h.cfg,
|
||||||
Tags: []string{"webhook"},
|
Tags: []string{"webhook"},
|
||||||
Arguments: definedArgs,
|
Arguments: definedArgs,
|
||||||
|
Justification: justification,
|
||||||
AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"),
|
AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,19 @@ func (m *WebhookMatcher) compareValues(actual, expected string) bool {
|
||||||
return actual == expected
|
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) {
|
func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) {
|
||||||
matcher, err := NewJSONMatcher(m.bodyBytes)
|
matcher, err := NewJSONMatcher(m.bodyBytes)
|
||||||
if err != nil {
|
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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue