feat: action group sizing

This commit is contained in:
jamesread 2026-06-18 23:33:27 +01:00
parent a3413c0bc3
commit 633d9ecd82
40 changed files with 2040 additions and 673 deletions

View File

@ -9,14 +9,6 @@ listenAddressSingleHTTPFrontend: 0.0.0.0:1337
# Docs: https://docs.olivetin.app/advanced_configuration/logs.html
logLevel: "INFO"
# Action groups share a concurrency limit across multiple actions. When the
# limit is reached, additional requests are queued and run in order.
# Docs: https://docs.olivetin.app/action_customization/concurrency.html#action-groups
actionGroups:
backup-jobs:
maxConcurrent: 1
icon: backup
# Actions are commands that are executed by OliveTin, and normally show up as
# buttons on the WebUI.
#
@ -266,6 +258,7 @@ actions:
timeout: 300
icon: logs
onclick: execution-dialog
groups: [ con2queue10 ]
execOnCron:
- "@hourly"
@ -310,6 +303,17 @@ entities:
- file: entities/containers.json
name: container
# Action groups share a concurrency limit across multiple actions. When the
# limit is reached, additional requests are queued and run in order.
# Docs: https://docs.olivetin.app/action_customization/concurrency.html#action-groups
actionGroups:
backup-jobs:
maxConcurrent: 1
icon: backup
con2queue10:
maxConcurrent: 2
queueSize: 10
# Dashboards are a way of taking actions from the default "actions" view, and
# organizing them into groups - either into folders, or fieldsets.
#

View File

@ -37,7 +37,7 @@
* Action Execution
** xref:action_execution/create_your_first.adoc[Create your first action]
** xref:action_execution/shellvsexec.adoc[Shell vs Exec]
** xref:action_execution/ondemand.adoc[Execute on demand]
** xref:action_execution/ondemand.adoc[Execute on click]
** xref:action_execution/oncron.adoc[Execute on schedule (cron)]
** xref:action_execution/onstartup.adoc[Execute on startup]
** xref:action_execution/onwebhook.adoc[Execute on webhook]

View File

@ -40,6 +40,7 @@ Use `actionGroups` to define a shared limit, and assign actions to a group with
actionGroups:
unity:
maxConcurrent: 1
queueSize: 5
actions:
- title: Unity Android Build
@ -51,8 +52,21 @@ actions:
groups: [ unity ]
----
When the group limit is reached, additional requests are queued automatically and run in order when a slot becomes free. Queued executions appear in the logs with a queued status.
=== maxConcurrent vs queueSize
Per-action `maxConcurrent` still applies separately. If the same action binding is started twice while one is already running, the second request is blocked immediately (not queued).
Action groups define two related limits:
* `maxConcurrent` -- how many executions in the group may *run at the same time*. When every slot is in use, new requests wait for a free slot instead of running immediately.
* `queueSize` -- how many executions may *wait in the queue* for a slot. If the group is full and the queue already holds this many waiting executions, additional requests are blocked (the same "blocked" status as per-action concurrency).
For example, with `maxConcurrent: 1` and `queueSize: 5`, OliveTin can have one execution running and up to five more waiting. A seventh request while those six are still outstanding is blocked.
`queueSize` defaults to `5` when omitted.
When the group limit is reached but the queue is not full, additional requests are queued automatically and run in order when a slot becomes free. Queued executions appear in the logs with a queued status.
Per-action `maxConcurrent` still applies to actions that are not in a group. For actions in a group, concurrency is governed by the group `maxConcurrent` and `queueSize` settings instead. A second request for the same action may run concurrently when the group has spare capacity, or join the queue when the group is full.
Actions that are not in a group are never queued. They only use their own `maxConcurrent` limit (default `1`).
The queue is held in memory. If OliveTin restarts while actions are queued, those queued requests are not preserved.

View File

@ -1,5 +1,4 @@
[#exec-on-demand]
= Execute on demand
[#exec-on-click]
= Execute on click
This is the default, meaning that the action shows up on a dashboard, and at the moment cannot be changed.

View File

@ -11,19 +11,19 @@
"dependencies": {
"@connectrpc/connect": "^2.1.2",
"@connectrpc/connect-web": "^2.1.2",
"@hugeicons/core-free-icons": "^4.2.0",
"@hugeicons/core-free-icons": "^4.2.1",
"@hugeicons/vue": "^1.0.6",
"@vitejs/plugin-vue": "^6.0.7",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"iconify-icon": "^3.0.2",
"picocrank": "^1.16.0",
"picocrank": "^1.17.0",
"standard": "^17.1.2",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.0.16",
"vue": "^3.5.38",
"vue-i18n": "^11.4.5",
"vue-i18n": "^11.4.6",
"vue-router": "^5.1.0"
},
"devDependencies": {
@ -941,9 +941,9 @@
}
},
"node_modules/@hugeicons/core-free-icons": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.0.tgz",
"integrity": "sha512-V1G/Ph9TbmEow+pKnupZRWQjdORR/TGGr3JVRZOWkomdJ/5N6GgLuKPgBDs7G0kZ0//9LL34AGOUzWe3K+umNA==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.1.tgz",
"integrity": "sha512-75jYZKYyA9VwS35YRmmGUGzFedbY+Fl0Vxx5FzXR2CGDlIhNRumFeVqaaKoClf2MeYEJwPAVMEL9RwCYtOgnSw==",
"license": "MIT"
},
"node_modules/@hugeicons/vue": {
@ -997,14 +997,14 @@
"license": "MIT"
},
"node_modules/@intlify/core-base": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.5.tgz",
"integrity": "sha512-lja3F/iKVIvTa48mIwmrIeDcQUFZ0F0drvFvT8AwINOvbwnAzl/S/p8p2DxILZpWEUHRi1qewfWNIkMvhD3kKA==",
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.6.tgz",
"integrity": "sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.4.5",
"@intlify/message-compiler": "11.4.5",
"@intlify/shared": "11.4.5"
"@intlify/devtools-types": "11.4.6",
"@intlify/message-compiler": "11.4.6",
"@intlify/shared": "11.4.6"
},
"engines": {
"node": ">= 22"
@ -1014,13 +1014,13 @@
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.5.tgz",
"integrity": "sha512-W5vydP9Yq3t82IyWqCM6aR0BTWCZrN5RAwjZEPpH8I2OQWp2RLy03Evh2ANZlSMhcvGAoyDg25k0so85Kwncpw==",
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.6.tgz",
"integrity": "sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.5",
"@intlify/shared": "11.4.5"
"@intlify/core-base": "11.4.6",
"@intlify/shared": "11.4.6"
},
"engines": {
"node": ">= 22"
@ -1030,12 +1030,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.5.tgz",
"integrity": "sha512-IEOZiHtbQopyPc/Dz2M869lOlZYX1SdcniNJwphATDYHhovvIneEKf1EFF37DE7NAABZtza1FNtnwwqZWInfpw==",
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.6.tgz",
"integrity": "sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.4.5",
"@intlify/shared": "11.4.6",
"source-map-js": "^1.0.2"
},
"engines": {
@ -1046,9 +1046,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.5.tgz",
"integrity": "sha512-g/i5mtdUa9ia/8BaJ4w6ZRHgAXYQd9XyCaQPRMvsd8d5qmZwkjoTmHrNsI28Q/7I8h+2ijUkI4uEnnMCziKupQ==",
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.6.tgz",
"integrity": "sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==",
"license": "MIT",
"engines": {
"node": ">= 22"
@ -5186,9 +5186,9 @@
"license": "ISC"
},
"node_modules/picocrank": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.16.0.tgz",
"integrity": "sha512-FHZ11Y0Jwqw89LQ0wdmglzUIk5W9vhG7CyrbW86qTbnke6hTyO721aixu0QkZtsw8GYSKJqO6QPLoOOxi0ZUMQ==",
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.17.0.tgz",
"integrity": "sha512-EIUSI26elt6ulloN74CT2sX5tBxS2wjdV4A+eXHYNtHbH0hM2iKkYzpKKWOEr+yUTfrvjah6VuqieedFw/9R0w==",
"license": "ISC",
"dependencies": {
"@hugeicons/core-free-icons": "^4.1.1",
@ -6900,14 +6900,14 @@
}
},
"node_modules/vue-i18n": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.5.tgz",
"integrity": "sha512-rm8YJ6RpjOrkcgS2GLrZwLvs/VbhxbTSuEspbyXDo233+fPK0OMFNLOj3fdQYVKdOgcpSfLW91JhbqgpkkcBWA==",
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
"integrity": "sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.5",
"@intlify/devtools-types": "11.4.5",
"@intlify/shared": "11.4.5",
"@intlify/core-base": "11.4.6",
"@intlify/devtools-types": "11.4.6",
"@intlify/shared": "11.4.6",
"@vue/devtools-api": "^6.5.0"
},
"engines": {

View File

@ -24,19 +24,19 @@
"dependencies": {
"@connectrpc/connect": "^2.1.2",
"@connectrpc/connect-web": "^2.1.2",
"@hugeicons/core-free-icons": "^4.2.0",
"@hugeicons/core-free-icons": "^4.2.1",
"@hugeicons/vue": "^1.0.6",
"@vitejs/plugin-vue": "^6.0.7",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"iconify-icon": "^3.0.2",
"picocrank": "^1.16.0",
"picocrank": "^1.17.0",
"standard": "^17.1.2",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.0.16",
"vue": "^3.5.38",
"vue-i18n": "^11.4.5",
"vue-i18n": "^11.4.6",
"vue-router": "^5.1.0"
},
"engines": {

View File

@ -105,6 +105,11 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
* @generated from field: bool has_queued_instance = 18;
*/
hasQueuedInstance: boolean;
/**
* @generated from field: repeated olivetin.api.v1.ActionGroupMembership groups = 19;
*/
groups: ActionGroupMembership[];
};
/**
@ -113,6 +118,32 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
*/
export declare const ActionSchema: GenMessage<Action>;
/**
* @generated from message olivetin.api.v1.ActionGroupMembership
*/
export declare type ActionGroupMembership = Message<"olivetin.api.v1.ActionGroupMembership"> & {
/**
* @generated from field: string name = 1;
*/
name: string;
/**
* @generated from field: int32 max_concurrent = 2;
*/
maxConcurrent: number;
/**
* @generated from field: int32 queue_size = 3;
*/
queueSize: number;
};
/**
* Describes the message olivetin.api.v1.ActionGroupMembership.
* Use `create(ActionGroupMembershipSchema)` to create a new message.
*/
export declare const ActionGroupMembershipSchema: GenMessage<ActionGroupMembership>;
/**
* @generated from message olivetin.api.v1.ActionWebhookExecHint
*/
@ -912,6 +943,11 @@ export declare type ExecutionQueueGroup = Message<"olivetin.api.v1.ExecutionQueu
* @generated from field: int32 queued_count = 6;
*/
queuedCount: number;
/**
* @generated from field: int32 queue_size = 7;
*/
queueSize: number;
};
/**
@ -1046,6 +1082,37 @@ export declare type ExecutionStatusRequest = Message<"olivetin.api.v1.ExecutionS
*/
export declare const ExecutionStatusRequestSchema: GenMessage<ExecutionStatusRequest>;
/**
* @generated from message olivetin.api.v1.DashboardNavigationTarget
*/
export declare type DashboardNavigationTarget = Message<"olivetin.api.v1.DashboardNavigationTarget"> & {
/**
* @generated from field: string title = 1;
*/
title: string;
/**
* @generated from field: string entity_type = 2;
*/
entityType: string;
/**
* @generated from field: string entity_key = 3;
*/
entityKey: string;
/**
* @generated from field: string path = 4;
*/
path: string;
};
/**
* Describes the message olivetin.api.v1.DashboardNavigationTarget.
* Use `create(DashboardNavigationTargetSchema)` to create a new message.
*/
export declare const DashboardNavigationTargetSchema: GenMessage<DashboardNavigationTarget>;
/**
* @generated from message olivetin.api.v1.ExecutionStatusResponse
*/
@ -1054,6 +1121,11 @@ export declare type ExecutionStatusResponse = Message<"olivetin.api.v1.Execution
* @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
*/
logEntry?: LogEntry | undefined;
/**
* @generated from field: repeated olivetin.api.v1.DashboardNavigationTarget back_to_dashboards = 2;
*/
backToDashboards: DashboardNavigationTarget[];
};
/**
@ -1800,6 +1872,11 @@ export declare type GetActionBindingResponse = Message<"olivetin.api.v1.GetActio
* @generated from field: olivetin.api.v1.Action action = 1;
*/
action?: Action | undefined;
/**
* @generated from field: repeated olivetin.api.v1.DashboardNavigationTarget back_to_dashboards = 2;
*/
backToDashboards: DashboardNavigationTarget[];
};
/**

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,34 @@
<template>
<span v-if="showLimits" class="action-group-limit">
{{ t('logs.action-group-limits', { concurrent: maxConcurrent, queueSize }) }}
</span>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
maxConcurrent: {
type: Number,
default: 0
},
queueSize: {
type: Number,
default: 0
}
})
const { t } = useI18n()
const showLimits = computed(() => props.maxConcurrent > 0 && props.queueSize > 0)
</script>
<style scoped>
.action-group-limit {
white-space: nowrap;
font-weight: 500;
font-size: smaller;
color: var(--text-secondary, #666);
}
</style>

View File

@ -165,23 +165,19 @@ const router = createRouter({
})
// Navigation guard to update page title
router.beforeEach((to, from, next) => {
router.beforeEach((to) => {
if (to.meta && to.meta.title) {
const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
document.title = to.meta.title + " - " + pageTitle
}
next()
})
// Navigation guard for authentication (if needed)
router.beforeEach((to, from, next) => {
// Check if user is authenticated for protected routes
router.beforeEach((to) => {
const isAuthenticated = window.isAuthenticated || true // Default to true for now
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else {
next()
return '/login'
}
})

View File

@ -0,0 +1,32 @@
function nonEmptyList (list) {
return Array.isArray(list) && list.length > 0
}
export function countExecutionConditions (action) {
if (!action) {
return 0
}
let count = 1
if (action.execOnStartup) {
count++
}
if (nonEmptyList(action.execOnCron)) {
count++
}
if (nonEmptyList(action.execOnFileCreatedInDir)) {
count++
}
if (nonEmptyList(action.execOnFileChangedInDir)) {
count++
}
if (action.execOnCalendarFile) {
count++
}
if (nonEmptyList(action.execOnWebhooks)) {
count++
}
return count
}

View File

@ -1,12 +1,27 @@
<template>
<Section :title="'Action Details: ' + actionTitle" :padding="false">
<Section :padding="false">
<template #title>
<span class="section-title-with-icon">
Action Details:
<ActionIconGlyph v-if="action" class="action-title-icon" :glyph="action.icon" />
{{ actionTitle }}
</span>
</template>
<template #toolbar>
<div class="action-details-toolbar">
<button v-if="action" @click="startAction" title="Start this action" class="button neutral">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 6v12l8-6z" />
</svg>
Start
<button
v-for="dashboard in backToDashboards"
:key="dashboard.path"
@click="goToDashboard(dashboard.path)"
:title="'Back to ' + dashboard.title"
class="button neutral"
>
<HugeiconsIcon :icon="DashboardSquare01Icon" />
{{ dashboard.title }}
</button>
<button v-if="action" @click="startAction" title="Run this action" class="button neutral">
<HugeiconsIcon :icon="WorkoutRunIcon" />
Run
</button>
<router-link
v-if="action"
@ -14,7 +29,7 @@
class="button neutral"
title="View configured automatic triggers and on-demand execution"
>
Execution conditions
Execution conditions ({{ executionConditionCount }})
</router-link>
</div>
</template>
@ -22,18 +37,30 @@
<div class = "flex-row padding" v-if="action">
<div class = "fg1">
<dl>
<dt>Title</dt>
<dd>{{ action.title }}</dd>
<dt>Timeout</dt>
<dd>{{ action.timeout }} seconds</dd>
<template v-if="actionGroups.length > 0">
<dt>
<router-link :to="{ name: 'LogsQueue' }" class="action-groups-link">Action groups</router-link>
</dt>
<dd>
<ul class="action-group-list">
<li v-for="group in actionGroups" :key="group.name" class="action-group-row">
<router-link :to="{ name: 'LogsQueue' }" class="action-groups-link action-group-name">{{ group.name }}</router-link><template v-if="group.maxConcurrent > 0 && group.queueSize > 0"> - </template><ActionGroupLimitsLabel
:max-concurrent="group.maxConcurrent"
:queue-size="group.queueSize"
/>
</li>
</ul>
</dd>
</template>
</dl>
<p class = "fg1">
Execution history for this action. You can filter by execution tracking ID.
</p>
</div>
<div style = "align-self: start; text-align: right;">
<ActionIconGlyph class="icon" :glyph="action.icon" />
<div class="filter-container">
<label class="input-with-icons">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
@ -106,14 +133,19 @@ import Pagination from 'picocrank/vue/components/Pagination.vue'
import Section from 'picocrank/vue/components/Section.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
import { HugeiconsIcon } from '@hugeicons/vue'
import { DashboardSquare01Icon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
import { requestReconnectNow } from '../../../js/websocket.js'
import { needsArgumentForm } from '../utils/needsArgumentForm.js'
import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
import { countExecutionConditions } from '../utils/executionConditionCount.js'
const route = useRoute()
const router = useRouter()
const logs = ref([])
const action = ref(null)
const backToDashboards = ref([])
const actionTitle = ref('Action Details')
const searchText = ref('')
const pageSize = ref(10)
@ -134,6 +166,10 @@ const filteredLogs = computed(() => {
)
})
const executionConditionCount = computed(() => countExecutionConditions(action.value))
const actionGroups = computed(() => action.value?.groups ?? [])
async function fetchActionLogs() {
loading.value = true
try {
@ -171,6 +207,7 @@ async function fetchAction() {
}
const response = await window.client.getActionBinding(args)
action.value = response.action
backToDashboards.value = (response.backToDashboards || []).slice(0, 3)
actionTitle.value = action.value?.title || 'Unknown Action'
} catch (err) {
console.error('Failed to fetch action:', err)
@ -178,8 +215,13 @@ async function fetchAction() {
}
}
function goToDashboard(path) {
router.push(path)
}
function resetState() {
action.value = null
backToDashboards.value = []
actionTitle.value = 'Action Details'
logs.value = []
totalCount.value = 0
@ -346,7 +388,13 @@ onUnmounted(() => {
</script>
<style scoped>
.icon {
.section-title-with-icon {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.action-title-icon {
font-size: 1.5rem;
}
@ -472,4 +520,28 @@ onUnmounted(() => {
gap: 0.5rem;
align-items: center;
}
.action-group-list {
margin: 0;
padding-left: 0;
list-style: none;
}
.action-group-row {
padding: 0.25rem 0;
}
.action-group-name {
font-family: monospace;
}
.action-groups-link {
color: inherit;
text-decoration: none;
}
.action-groups-link:hover {
text-decoration: underline;
color: var(--link-color, #007bff);
}
</style>

View File

@ -13,7 +13,7 @@
</p>
<h3 class="exec-type-heading">
On demand
On click
<a class="doc-link" :href="execConditionDocs.onDemand" target="_blank" rel="noopener noreferrer">Documentation</a>
</h3>
<p>

View File

@ -1,27 +1,43 @@
<template>
<Section :title="'Execution Results: ' + title" id = "execution-results-popup">
<Section id="execution-results-popup">
<template #title>
<span class="section-title-with-icon">
Execution Results:
<router-link
v-if="actionId"
:to="`/action/${actionId}`"
class="action-details-title-link"
:title="titleTooltip"
>
<ActionIconGlyph class="action-title-icon" :glyph="icon" />
<LogActionTitle v-if="logEntry" :action-title="title" :justification="logEntry.justification" />
<span v-else>{{ title }}</span>
</router-link>
<template v-else>
<LogActionTitle v-if="logEntry" :action-title="title" :justification="logEntry.justification" />
<span v-else>{{ title }}</span>
</template>
</span>
</template>
<template #toolbar>
<router-link v-if="actionId" :to="`/action/${actionId}`" title="View all executions for this action" class="button neutral">
<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>
Action Details
</router-link>
<button @click="toggleSize" title="Toggle dialog size" class = "neutral">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z" />
</svg>
<button
v-for="dashboard in backToDashboards"
:key="dashboard.path"
@click="goToDashboard(dashboard.path)"
:title="'Back to ' + dashboard.title"
class="button neutral"
>
<HugeiconsIcon :icon="DashboardSquare01Icon" />
{{ dashboard.title }}
</button>
<button v-if="backToDashboards.length === 0" @click="goBack" title="Go back" class="button neutral">
<HugeiconsIcon :icon="ArrowLeftIcon" />
Back
</button>
</template>
<div v-if="logEntry" class = "flex-row">
<dl class = "fg1">
<dt>Action</dt>
<dd>
<LogActionTitle :action-title="title" :justification="logEntry.justification" />
</dd>
<dt>Duration</dt>
<dd><span v-html="duration"></span></dd>
@ -30,7 +46,6 @@
<ActionStatusDisplay :log-entry="logEntry" :link-queued-status="true" />
</dd>
</dl>
<ActionIconGlyph class="icon" role="img" :glyph="icon" style="align-self: start" />
</div>
<div v-if="notFound" class="error-message padded-content">
@ -40,16 +55,24 @@
<router-link to="/logs">View all logs</router-link> or <router-link to="/">return to home</router-link>.
</div>
<div ref="xtermOutput"></div>
<div class="xterm-output-container">
<div class="xterm-overlay-toolbar">
<button type="button" class="xterm-overlay-button" @click="copyOutput" title="Copy to clipboard">
<HugeiconsIcon :icon="Copy01Icon" />
</button>
<button type="button" class="xterm-overlay-button" @click="toggleSize" title="Toggle fullscreen">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z" />
</svg>
</button>
</div>
<div ref="xtermOutput"></div>
</div>
<br />
<div class="flex-row g1 buttons padded-content">
<button @click="goBack" title="Go back">
<HugeiconsIcon :icon="ArrowLeftIcon" />
Back
</button>
<div class = "fg1" />
<button :disabled="!canRerun" @click="rerunAction" title="Rerun">
@ -73,7 +96,7 @@ import LogActionTitle from '../components/LogActionTitle.vue'
import Section from 'picocrank/vue/components/Section.vue'
import { OutputTerminal } from '../../../js/OutputTerminal.js'
import { HugeiconsIcon } from '@hugeicons/vue'
import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons'
import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon, DashboardSquare01Icon, Copy01Icon } from '@hugeicons/core-free-icons'
import { useRouter } from 'vue-router'
import { buttonResults } from '../stores/buttonResults'
import { requestReconnectNow } from '../../../js/websocket.js'
@ -104,6 +127,7 @@ const logEntry = ref(null)
const canRerun = ref(false)
const canKill = ref(false)
const actionId = ref('')
const backToDashboards = ref([])
const notFound = ref(false)
const errorMessage = ref('')
@ -134,6 +158,19 @@ function toggleSize() {
}
}
async function copyOutput() {
const text = terminal?.getBufferAsString?.() || logEntry.value?.output || ''
if (!text) {
return
}
try {
await navigator.clipboard.writeText(text)
} catch (err) {
console.error('Failed to copy execution output:', err)
}
}
async function reset() {
executionSeconds.value = 0
executionTrackingId.value = 'notset'
@ -149,6 +186,7 @@ async function reset() {
canRerun.value = false
canKill.value = false
logEntry.value = null
backToDashboards.value = []
notFound.value = false
errorMessage.value = ''
@ -239,15 +277,16 @@ async function fetchExecutionResult(executionTrackingIdParam) {
executionTrackingId.value = executionTrackingIdParam
notFound.value = false
errorMessage.value = ''
backToDashboards.value = []
const executionStatusArgs = {
executionTrackingId: executionTrackingId.value
}
try {
const logEntryResult = await window.client.executionStatus(executionStatusArgs)
const executionStatusResult = await window.client.executionStatus(executionStatusArgs)
await renderExecutionResult(logEntryResult)
await renderExecutionResult(executionStatusResult)
} catch (err) {
// Check if it's a "not found" error (404 or similar)
if (err.status === 404 || err.code === 'NotFound' || err.message?.includes('not found')) {
@ -286,6 +325,9 @@ function updateDuration(logEntryParam) {
async function renderExecutionResult(res) {
logEntry.value = res.logEntry
if (res.backToDashboards) {
backToDashboards.value = res.backToDashboards.slice(0, 3)
}
// Clear ticker
if (executionTicker) {
@ -343,6 +385,10 @@ function goBack() {
router.back()
}
function goToDashboard(path) {
router.push(path)
}
onMounted(() => {
document.addEventListener('fullscreenchange', (e) => {
setTimeout(() => { // Wait for the DOM to settle
@ -390,6 +436,65 @@ defineExpose({
</script>
<style scoped>
.section-title-with-icon {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.action-title-icon {
font-size: 1.5rem;
}
.action-details-title-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: inherit;
text-decoration: none;
}
.action-details-title-link:hover {
text-decoration: underline;
}
.xterm-output-container {
position: relative;
}
.xterm-overlay-toolbar {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 2;
display: flex;
gap: 0.35rem;
}
.xterm-overlay-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.35rem;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 0.25rem;
background: rgba(30, 30, 30, 0.85);
color: #f0f0f0;
cursor: pointer;
line-height: 1;
}
.xterm-overlay-button:hover {
background: rgba(50, 50, 50, 0.95);
border-color: rgba(255, 255, 255, 0.45);
color: #fff;
}
.xterm-overlay-button:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.6);
outline-offset: 2px;
}
.action-history-link {
color: var(--link-color, #007bff);
text-decoration: none;

View File

@ -27,12 +27,10 @@
<ActionIconGlyph class="icon" :glyph="actionGroup.icon" />
<h2>{{ displayActionGroupName(actionGroup.name) }}</h2>
</div>
<span
v-if="actionGroup.maxConcurrent > 0"
class="queue-action-group-limit annotation"
>
{{ t('logs.queue-group-limit', { max: actionGroup.maxConcurrent, queued: actionGroup.queuedCount }) }}
</span>
<ActionGroupLimitsLabel
:max-concurrent="actionGroup.maxConcurrent"
:queue-size="actionGroup.queueSize"
/>
</div>
<div class="section-content">
@ -95,6 +93,7 @@ import Section from 'picocrank/vue/components/Section.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import LogActionTitle from '../components/LogActionTitle.vue'
import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
import { useI18n } from 'vue-i18n'
import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
@ -352,10 +351,6 @@ onUnmounted(() => {
margin: 0;
}
.queue-action-group-limit {
white-space: nowrap;
}
.timestamp {
font-family: monospace;
font-size: 0.875rem;

View File

@ -32,6 +32,7 @@
"language-dialog.title": "Sprache auswählen",
"login-button": "Login",
"logs.action": "Aktion",
"logs.action-group-limits": "gleichzeitig: {concurrent}, Warteschlangengröße: {queueSize}",
"logs.back-to-list": "Zurück zur Liste",
"logs.blocked": "Blockiert",
"logs.calendar": "Kalender",
@ -49,7 +50,6 @@
"logs.queue-entity": "Entität",
"logs.queue-group-active": "{active} aktiv (max. {max})",
"logs.queue-group-active-unlimited": "{active} aktiv",
"logs.queue-group-limit": "Limit {max}, wartend {queued}",
"logs.queue-page-description": "Aktive und wartende Ausführungen, nach Aktionsgruppe gruppiert. Einträge ohne Berechtigung werden ausgeblendet.",
"logs.queue-position": "#{position}",
"logs.queue-running": "Läuft",
@ -103,6 +103,7 @@
"language-dialog.title": "Select Language",
"login-button": "Login",
"logs.action": "Action",
"logs.action-group-limits": "concurrent: {concurrent}, queue size: {queueSize}",
"logs.back-to-list": "Back to List",
"logs.blocked": "Blocked",
"logs.calendar": "Calendar",
@ -128,7 +129,6 @@
"logs.queue-entity": "Entity",
"logs.queue-group-active": "{active} active (max {max})",
"logs.queue-group-active-unlimited": "{active} active",
"logs.queue-group-limit": "limit {max}, queued {queued}",
"logs.queue-page-description": "Active and waiting executions grouped by action group. Entries you are not permitted to view are hidden.",
"logs.queue-position": "#{position}",
"logs.queue-running": "Running",
@ -182,6 +182,7 @@
"language-dialog.title": "Seleccionar idioma",
"login-button": "Iniciar sesión",
"logs.action": "Acción",
"logs.action-group-limits": "simultáneas: {concurrent}, tamaño de cola: {queueSize}",
"logs.back-to-list": "Volver a la Lista",
"logs.blocked": "Bloqueado",
"logs.calendar": "Calendario",
@ -199,7 +200,6 @@
"logs.queue-entity": "Entidad",
"logs.queue-group-active": "{active} activas (máx. {max})",
"logs.queue-group-active-unlimited": "{active} activas",
"logs.queue-group-limit": "límite {max}, en espera {queued}",
"logs.queue-page-description": "Ejecuciones activas y en espera agrupadas por grupo de acciones. Las entradas que no puede ver se ocultan.",
"logs.queue-position": "#{position}",
"logs.queue-running": "En ejecución",
@ -253,6 +253,7 @@
"language-dialog.title": "Seleziona lingua",
"login-button": "Login",
"logs.action": "Azione",
"logs.action-group-limits": "simultanei: {concurrent}, dimensione coda: {queueSize}",
"logs.back-to-list": "Torna all'Elenco",
"logs.blocked": "Bloccato",
"logs.calendar": "Calendario",
@ -270,7 +271,6 @@
"logs.queue-entity": "Entità",
"logs.queue-group-active": "{active} attive (max {max})",
"logs.queue-group-active-unlimited": "{active} attive",
"logs.queue-group-limit": "limite {max}, in coda {queued}",
"logs.queue-page-description": "Esecuzioni attive e in attesa raggruppate per gruppo di azioni. Le voci non autorizzate sono nascoste.",
"logs.queue-position": "#{position}",
"logs.queue-running": "In esecuzione",
@ -324,6 +324,7 @@
"language-dialog.title": "选择语言",
"login-button": "登录",
"logs.action": "动作",
"logs.action-group-limits": "并发:{concurrent},队列大小:{queueSize}",
"logs.back-to-list": "返回列表",
"logs.blocked": "阻塞",
"logs.calendar": "日历",
@ -341,7 +342,6 @@
"logs.queue-entity": "实体",
"logs.queue-group-active": "{active} 个活动(上限 {max})",
"logs.queue-group-active-unlimited": "{active} 个活动",
"logs.queue-group-limit": "上限 {max},排队 {queued}",
"logs.queue-page-description": "按动作组分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。",
"logs.queue-position": "第 {position} 位",
"logs.queue-running": "运行中",

View File

@ -35,7 +35,7 @@ translations:
logs.queue-title: Ausführungswarteschlange
logs.queue-page-description: Aktive und wartende Ausführungen, nach Aktionsgruppe gruppiert. Einträge ohne Berechtigung werden ausgeblendet.
logs.queue-default-group: Standard
logs.queue-group-limit: "Limit {max}, wartend {queued}"
logs.action-group-limits: "gleichzeitig: {concurrent}, Warteschlangengröße: {queueSize}"
logs.queue-group-active-unlimited: "{active} aktiv"
logs.queue-empty: Derzeit gibt es keine aktiven oder wartenden Ausführungen.
logs.queue-group-active: "{active} aktiv (max. {max})"

View File

@ -43,7 +43,7 @@ translations:
logs.queue-page-description: Active and waiting executions grouped by action group. Entries you are not permitted to view are hidden.
logs.queue-empty: There are no active or waiting executions right now.
logs.queue-default-group: Default
logs.queue-group-limit: "limit {max}, queued {queued}"
logs.action-group-limits: "concurrent: {concurrent}, queue size: {queueSize}"
logs.queue-group-active: "{active} active (max {max})"
logs.queue-group-active-unlimited: "{active} active"
logs.queue-waiting: Waiting

View File

@ -35,7 +35,7 @@ translations:
logs.queue-title: Cola de ejecución
logs.queue-page-description: Ejecuciones activas y en espera agrupadas por grupo de acciones. Las entradas que no puede ver se ocultan.
logs.queue-default-group: Predeterminado
logs.queue-group-limit: "límite {max}, en espera {queued}"
logs.action-group-limits: "simultáneas: {concurrent}, tamaño de cola: {queueSize}"
logs.queue-group-active-unlimited: "{active} activas"
logs.queue-empty: No hay ejecuciones activas o en espera en este momento.
logs.queue-group-active: "{active} activas (máx. {max})"

View File

@ -35,7 +35,7 @@ translations:
logs.queue-title: Coda di esecuzione
logs.queue-page-description: Esecuzioni attive e in attesa raggruppate per gruppo di azioni. Le voci non autorizzate sono nascoste.
logs.queue-default-group: Predefinito
logs.queue-group-limit: "limite {max}, in coda {queued}"
logs.action-group-limits: "simultanei: {concurrent}, dimensione coda: {queueSize}"
logs.queue-group-active-unlimited: "{active} attive"
logs.queue-empty: Non ci sono esecuzioni attive o in attesa al momento.
logs.queue-group-active: "{active} attive (max {max})"

View File

@ -44,7 +44,7 @@ translations:
logs.queue-title: 执行队列
logs.queue-page-description: 按动作组分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。
logs.queue-default-group: 默认
logs.queue-group-limit: "上限 {max},排队 {queued}"
logs.action-group-limits: "并发:{concurrent},队列大小:{queueSize}"
logs.queue-group-active-unlimited: "{active} 个活动"
logs.queue-empty: 当前没有正在运行或等待中的执行。
logs.queue-group-active: "{active} 个活动(上限 {max})"

View File

@ -23,6 +23,13 @@ message Action {
bool justification = 16;
bool has_running_instance = 17;
bool has_queued_instance = 18;
repeated ActionGroupMembership groups = 19;
}
message ActionGroupMembership {
string name = 1;
int32 max_concurrent = 2;
int32 queue_size = 3;
}
message ActionWebhookExecHint {
@ -209,6 +216,7 @@ message ExecutionQueueGroup {
int32 active_count = 4;
repeated ExecutionQueueAction actions = 5;
int32 queued_count = 6;
int32 queue_size = 7;
}
message GetExecutionQueueResponse {
@ -241,8 +249,16 @@ message ExecutionStatusRequest {
string action_id = 2;
}
message DashboardNavigationTarget {
string title = 1;
string entity_type = 2;
string entity_key = 3;
string path = 4;
}
message ExecutionStatusResponse {
LogEntry log_entry = 1;
repeated DashboardNavigationTarget back_to_dashboards = 2;
}
message WhoAmIRequest {}
@ -406,6 +422,7 @@ message GetActionBindingRequest {
message GetActionBindingResponse {
Action action = 1;
repeated DashboardNavigationTarget back_to_dashboards = 2;
}
message GetEntitiesRequest {

File diff suppressed because it is too large Load Diff

View File

@ -450,6 +450,32 @@ func (api *oliveTinAPI) getExecutionStatusByRequest(msg *apiv1.ExecutionStatusRe
return getMostRecentExecutionStatusByActionId(api, msg.ActionId)
}
func dashboardNavigationTargetsToPb(targets []executor.DashboardNavigationTarget) []*apiv1.DashboardNavigationTarget {
if len(targets) == 0 {
return nil
}
result := make([]*apiv1.DashboardNavigationTarget, 0, len(targets))
for _, target := range targets {
result = append(result, &apiv1.DashboardNavigationTarget{
Title: target.Title,
EntityType: target.EntityType,
EntityKey: target.EntityKey,
Path: target.Path,
})
}
return result
}
func (api *oliveTinAPI) executionStatusBackToDashboards(ile *executor.InternalLogEntry) []*apiv1.DashboardNavigationTarget {
if ile == nil || ile.Binding == nil {
return nil
}
return dashboardNavigationTargetsToPb(ile.Binding.OnDashboards)
}
func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
user := auth.UserFromApiCall(ctx, req, api.cfg)
if err := api.checkDashboardAccess(user); err != nil {
@ -460,7 +486,8 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
return nil, err
}
res := &apiv1.ExecutionStatusResponse{
LogEntry: api.internalLogEntryToPb(ile, user),
LogEntry: api.internalLogEntryToPb(ile, user),
BackToDashboards: api.executionStatusBackToDashboards(ile),
}
return connect.NewResponse(res), nil
}
@ -531,7 +558,8 @@ func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedU
}
return &apiv1.GetActionBindingResponse{
Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
BackToDashboards: dashboardNavigationTargetsToPb(binding.OnDashboards),
}, nil
}

View File

@ -229,10 +229,39 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
applyActiveBindingStateToAction(&btn, binding.ID, rr.activeBindingStates)
applyActionExecTriggers(&btn, action)
btn.Arguments = buildActionArguments(action, binding.Entity)
btn.Groups = buildActionGroups(action, rr.cfg)
return &btn
}
func buildActionGroups(action *config.Action, cfg *config.Config) []*apiv1.ActionGroupMembership {
if action == nil || len(action.Groups) == 0 {
return nil
}
groups := make([]*apiv1.ActionGroupMembership, 0, len(action.Groups))
for _, name := range action.Groups {
groups = append(groups, actionGroupMembershipFromConfig(name, cfg))
}
return groups
}
func actionGroupMembershipFromConfig(name string, cfg *config.Config) *apiv1.ActionGroupMembership {
membership := &apiv1.ActionGroupMembership{Name: name}
group, found := cfg.ActionGroups[name]
if !found || group == nil || group.MaxConcurrent < 1 {
return membership
}
membership.MaxConcurrent = int32(group.MaxConcurrent)
membership.QueueSize = int32(group.QueueSize)
return membership
}
func buildChoices(arg config.ActionArgument) []*apiv1.ActionArgumentChoice {
if arg.Entity != "" && len(arg.Choices) == 1 {
return buildChoicesEntity(arg.Choices[0], arg.Entity)

View File

@ -148,6 +148,7 @@ func newExecutionQueueGroup(name string, cfg *config.Config) *apiv1.ExecutionQue
group.Icon = actionGroup.Icon
group.MaxConcurrent = int32(actionGroup.MaxConcurrent)
group.QueueSize = int32(actionGroup.QueueSize)
return group
}

View File

@ -57,6 +57,7 @@ func TestGetExecutionQueueGroupsByActionGroup(t *testing.T) {
require.NotNil(t, defaultGroup)
assert.Equal(t, int32(2), deployGroup.MaxConcurrent)
assert.Equal(t, int32(5), deployGroup.QueueSize)
assert.Equal(t, "&#128190;", deployGroup.Icon)
assert.Equal(t, int32(2), deployGroup.ActiveCount)
assert.Equal(t, int32(1), deployGroup.QueuedCount)

View File

@ -826,3 +826,96 @@ func assertEventStreamAdminReceivesSecretActionEvents(t *testing.T, adminEvents
assert.True(t, gotStarted, "admin must receive ExecutionStarted for secret_action")
assert.True(t, gotFinished, "admin must receive ExecutionFinished for secret_action")
}
func TestExecutionStatusReturnsBackToDashboards(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{Title: "Dashboard Action", Shell: "echo ok"},
}
cfg.Dashboards = []*config.DashboardComponent{
{
Title: "Ops",
Contents: []*config.DashboardComponent{
{Title: "Dashboard Action"},
},
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
_, client := getNewTestServerAndClientWithExecutor(cfg, ex)
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
}))
require.NoError(t, err)
statusResp, err := client.ExecutionStatus(context.Background(), connect.NewRequest(&apiv1.ExecutionStatusRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
}))
require.NoError(t, err)
require.NotNil(t, statusResp.Msg)
require.Len(t, statusResp.Msg.BackToDashboards, 1)
assert.Equal(t, "Ops", statusResp.Msg.BackToDashboards[0].Title)
assert.Equal(t, "/dashboards/Ops", statusResp.Msg.BackToDashboards[0].Path)
}
func TestGetActionBindingReturnsBackToDashboards(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{Title: "Dashboard Action", Shell: "echo ok"},
}
cfg.Dashboards = []*config.DashboardComponent{
{
Title: "Ops",
Contents: []*config.DashboardComponent{
{Title: "Dashboard Action"},
},
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
_, client := getNewTestServerAndClientWithExecutor(cfg, ex)
resp, err := client.GetActionBinding(context.Background(), connect.NewRequest(&apiv1.GetActionBindingRequest{
BindingId: binding.ID,
}))
require.NoError(t, err)
require.NotNil(t, resp.Msg)
require.Len(t, resp.Msg.BackToDashboards, 1)
assert.Equal(t, "Ops", resp.Msg.BackToDashboards[0].Title)
assert.Equal(t, "/dashboards/Ops", resp.Msg.BackToDashboards[0].Path)
}
func TestBuildActionIncludesGroups(t *testing.T) {
cfg := config.DefaultConfig()
cfg.ActionGroups = map[string]*config.ActionGroup{
"con2queue10": {MaxConcurrent: 2, QueueSize: 10},
}
cfg.Actions = []*config.Action{
{Title: "Long running action", Shell: "sleep 1", Groups: []string{"con2queue10", "missing"}},
}
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
rr := &DashboardRenderRequest{cfg: cfg, ex: ex}
actionResult := buildAction(binding, rr)
require.Len(t, actionResult.Groups, 2)
assert.Equal(t, "con2queue10", actionResult.Groups[0].Name)
assert.Equal(t, int32(2), actionResult.Groups[0].MaxConcurrent)
assert.Equal(t, int32(10), actionResult.Groups[0].QueueSize)
assert.Equal(t, "missing", actionResult.Groups[1].Name)
assert.Equal(t, int32(0), actionResult.Groups[1].MaxConcurrent)
}

View File

@ -137,7 +137,7 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
continue
}
if binding.IsOnDashboard {
if binding.IsOnConfiguredDashboard() {
continue
}

View File

@ -41,6 +41,7 @@ type Action struct {
// ActionGroup defines shared limits and metadata for a set of actions.
type ActionGroup struct {
MaxConcurrent int `koanf:"maxConcurrent"`
QueueSize int `koanf:"queueSize"`
Icon string `koanf:"icon"`
}

View File

@ -219,12 +219,18 @@ func appendUniqueString(out []string, seen map[string]struct{}, value string) []
return append(out, value)
}
const defaultActionGroupQueueSize = 5
func (cfg *Config) sanitizeActionGroups() {
for _, group := range cfg.ActionGroups {
if group == nil {
continue
}
if group.QueueSize <= 0 {
group.QueueSize = defaultActionGroupQueueSize
}
group.Icon = lookupHTMLIcon(group.Icon, cfg.DefaultIconForActions)
}
}

View File

@ -196,6 +196,28 @@ func TestSanitizeActionGroupsResolvesIcons(t *testing.T) {
assert.Equal(t, "&#128190;", c.ActionGroups["backup-jobs"].Icon)
}
func TestSanitizeActionGroupsDefaultsQueueSize(t *testing.T) {
c := DefaultConfig()
c.ActionGroups = map[string]*ActionGroup{
"unity": {MaxConcurrent: 1},
}
c.Sanitize()
assert.Equal(t, defaultActionGroupQueueSize, c.ActionGroups["unity"].QueueSize)
}
func TestSanitizeActionGroupsPreservesExplicitQueueSize(t *testing.T) {
c := DefaultConfig()
c.ActionGroups = map[string]*ActionGroup{
"unity": {MaxConcurrent: 1, QueueSize: 2},
}
c.Sanitize()
assert.Equal(t, 2, c.ActionGroups["unity"].QueueSize)
}
func TestValidateReservedActionArgumentNamesAllowsNonReserved(t *testing.T) {
c := DefaultConfig()
c.Actions = append(c.Actions, &Action{

View File

@ -146,8 +146,17 @@ func redactExecArgs(execArgs []string, arguments []config.ActionArgument, argume
return redacted
}
func argumentSkipsValidation(arg *config.ActionArgument) bool {
switch arg.Type {
case "confirmation", "html":
return true
default:
return false
}
}
func typecheckActionArgument(arg *config.ActionArgument, value string, action *config.Action) error {
if arg.Type == "confirmation" {
if argumentSkipsValidation(arg) {
return nil
}

View File

@ -660,6 +660,20 @@ func TestTypecheckActionArgumentConfirmation(t *testing.T) {
assert.Nil(t, err, "Confirmation type should always pass validation")
}
func TestTypecheckActionArgumentHtmlWithoutName(t *testing.T) {
action := config.Action{
Title: "Delete old backups",
Shell: "rm -rf /opt/oliveTinOldBackups/ && sleep 5",
Arguments: []config.ActionArgument{
{Type: "html", Title: "Description"},
{Type: "confirmation", Title: "Are you sure?!"},
},
}
err := validateArguments(map[string]string{}, &action)
assert.NoError(t, err)
}
func TestParseCommandForReplacements(t *testing.T) {
tests := []struct {
name string

View File

@ -48,11 +48,11 @@ var (
)
type ActionBinding struct {
ID string
Action *config.Action
Entity *entities.Entity
ConfigOrder int
IsOnDashboard bool
ID string
Action *config.Action
Entity *entities.Entity
ConfigOrder int
OnDashboards []DashboardNavigationTarget
}
// Executor represents a helper class for executing commands. It's main method
@ -641,6 +641,10 @@ func (e *Executor) registerOrQueueRequest(req *ExecutionRequest, wg *sync.WaitGr
}
func (e *Executor) finishIfConcurrencyBlocked(req *ExecutionRequest) bool {
if actionNeedsGroupLimit(req) {
return false
}
if stepConcurrencyCheck(req) {
return false
}
@ -663,7 +667,11 @@ func (e *Executor) queueRequestAfterACL(req *ExecutionRequest, wg *sync.WaitGrou
return true, false
}
e.queueRequest(req, wg)
if e.queueRequest(req, wg) {
e.finishExecChain(req)
return true, false
}
notifyListenersStarted(req)
return false, true
@ -708,6 +716,10 @@ func getConcurrentCount(req *ExecutionRequest) int {
}
func stepConcurrencyCheck(req *ExecutionRequest) bool {
if actionNeedsGroupLimit(req) {
return true
}
concurrentCount := getConcurrentCount(req)
// Note that the current execution is counted int the logs, so when checking we +1

View File

@ -3,7 +3,6 @@ package executor
import (
"crypto/sha256"
"fmt"
"slices"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
@ -37,8 +36,8 @@ func (e *Executor) FindBindingWithNoEntity(action *config.Action) *ActionBinding
}
type RebuildActionMapRequest struct {
Cfg *config.Config
DashboardActionTitles []string
Cfg *config.Config
dashboardTargets *dashboardTargetIndex
}
func validateArgumentDefaults(cfg *config.Config) {
@ -81,16 +80,10 @@ func (e *Executor) RebuildActionMap() {
clear(e.MapActionBindings)
req := &RebuildActionMapRequest{
Cfg: e.Cfg,
DashboardActionTitles: make([]string, 0),
Cfg: e.Cfg,
dashboardTargets: buildDashboardTargetIndex(e.Cfg),
}
findDashboardActionTitles(req)
log.WithFields(log.Fields{
"titles": req.DashboardActionTitles,
}).Trace("dashboardActionTitles")
for configOrder, action := range e.Cfg.Actions {
if action.Entity != "" {
registerActionsFromEntities(e, configOrder, action.Entity, action, req)
@ -106,42 +99,15 @@ func (e *Executor) RebuildActionMap() {
}
}
func findDashboardActionTitles(req *RebuildActionMapRequest) {
for _, dashboard := range req.Cfg.Dashboards {
recurseDashboardForActionTitles(dashboard, req)
}
}
//gocyclo:ignore
func recurseDashboardForActionTitles(component *config.DashboardComponent, req *RebuildActionMapRequest) {
for _, sub := range component.Contents {
if sub.InlineAction != nil {
title := sub.Title
if title == "" {
title = sub.InlineAction.Title
}
if title != "" {
req.DashboardActionTitles = append(req.DashboardActionTitles, title)
}
} else if sub.Type == "link" || sub.Type == "" {
req.DashboardActionTitles = append(req.DashboardActionTitles, sub.Title)
}
if len(sub.Contents) > 0 {
recurseDashboardForActionTitles(sub, req)
}
}
}
func registerAction(e *Executor, configOrder int, action *config.Action, req *RebuildActionMapRequest) {
bindingId := generateActionBindingId(action, "")
e.MapActionBindings[bindingId] = &ActionBinding{
ID: bindingId,
Action: action,
Entity: nil,
ConfigOrder: configOrder,
IsOnDashboard: slices.Contains(req.DashboardActionTitles, action.Title),
ID: bindingId,
Action: action,
Entity: nil,
ConfigOrder: configOrder,
OnDashboards: resolveOnDashboards(req.dashboardTargets, action.Title, ""),
}
}
@ -155,11 +121,11 @@ func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action,
virtualActionId := generateActionBindingId(tpl, ent.UniqueKey)
e.MapActionBindings[virtualActionId] = &ActionBinding{
ID: virtualActionId,
Action: tpl,
Entity: ent,
ConfigOrder: configOrder,
IsOnDashboard: slices.Contains(req.DashboardActionTitles, tpl.Title),
ID: virtualActionId,
Action: tpl,
Entity: ent,
ConfigOrder: configOrder,
OnDashboards: resolveOnDashboards(req.dashboardTargets, tpl.Title, ent.UniqueKey),
}
}

View File

@ -0,0 +1,249 @@
package executor
import (
"fmt"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
)
type DashboardNavigationTarget struct {
Title string
EntityType string
EntityKey string
Path string
}
func (target DashboardNavigationTarget) key() string {
return target.Title + "\x00" + target.EntityType + "\x00" + target.EntityKey
}
func (b *ActionBinding) IsOnConfiguredDashboard() bool {
for _, dashboard := range b.OnDashboards {
if dashboard.Title != "Actions" {
return true
}
}
return false
}
type dashboardTargetIndex struct {
byTitle map[string][]DashboardNavigationTarget
byTitleEntityKey map[string]map[string][]DashboardNavigationTarget
}
func buildDashboardTargetIndex(cfg *config.Config) *dashboardTargetIndex {
index := &dashboardTargetIndex{
byTitle: make(map[string][]DashboardNavigationTarget),
byTitleEntityKey: make(map[string]map[string][]DashboardNavigationTarget),
}
for _, dashboard := range cfg.Dashboards {
walkDashboardContents(dashboard.Contents, dashboard.Title, index)
}
return index
}
func walkDashboardContents(contents []*config.DashboardComponent, rootDashboardTitle string, index *dashboardTargetIndex) {
for _, component := range contents {
walkDashboardComponent(component, rootDashboardTitle, index)
}
}
func walkDashboardComponent(component *config.DashboardComponent, rootDashboardTitle string, index *dashboardTargetIndex) {
if component.Type == "fieldset" && component.Entity != "" {
walkEntityFieldset(component, rootDashboardTitle, component.Entity, index)
return
}
recordActionTarget(component, rootDashboardTitle, "", "", index)
if len(component.Contents) > 0 {
walkDashboardContents(component.Contents, rootDashboardTitle, index)
}
}
func walkEntityFieldset(fieldset *config.DashboardComponent, rootDashboardTitle, entityType string, index *dashboardTargetIndex) {
for _, component := range fieldset.Contents {
if component.Type == "directory" {
walkEntityDirectory(component, entityType, index)
continue
}
recordActionTargetForAllEntities(component, rootDashboardTitle, entityType, index)
if len(component.Contents) > 0 {
walkEntityFieldsetContents(component.Contents, rootDashboardTitle, entityType, index)
}
}
}
func walkEntityFieldsetContents(contents []*config.DashboardComponent, rootDashboardTitle, entityType string, index *dashboardTargetIndex) {
for _, component := range contents {
if component.Type == "directory" {
walkEntityDirectory(component, entityType, index)
continue
}
recordActionTargetForAllEntities(component, rootDashboardTitle, entityType, index)
if len(component.Contents) > 0 {
walkEntityFieldsetContents(component.Contents, rootDashboardTitle, entityType, index)
}
}
}
func walkEntityDirectory(directory *config.DashboardComponent, entityType string, index *dashboardTargetIndex) {
for _, entity := range entities.GetEntityInstancesOrdered(entityType) {
for _, component := range directory.Contents {
recordActionTarget(component, directory.Title, entityType, entity.UniqueKey, index)
}
}
}
func recordActionTargetForAllEntities(component *config.DashboardComponent, rootDashboardTitle, entityType string, index *dashboardTargetIndex) {
actionTitle := actionTitleFromComponent(component)
if actionTitle == "" {
return
}
target := dashboardNavigationTarget(rootDashboardTitle, "", "")
for _, entity := range entities.GetEntityInstancesOrdered(entityType) {
addEntityTarget(index, actionTitle, entity.UniqueKey, target)
}
}
func recordActionTarget(component *config.DashboardComponent, dashboardTitle, entityType, entityKey string, index *dashboardTargetIndex) {
actionTitle := actionTitleFromComponent(component)
if actionTitle == "" {
return
}
target := dashboardNavigationTarget(dashboardTitle, entityType, entityKey)
if entityType != "" && entityKey != "" {
addEntityTarget(index, actionTitle, entityKey, target)
return
}
addTitleTarget(index, actionTitle, target)
}
func actionTitleFromComponent(component *config.DashboardComponent) string {
if title := inlineActionTitle(component); title != "" {
return title
}
if component.Type == "link" || component.Type == "" {
return component.Title
}
return ""
}
func inlineActionTitle(component *config.DashboardComponent) string {
if component.InlineAction == nil {
return ""
}
if component.Title != "" {
return component.Title
}
return component.InlineAction.Title
}
func dashboardNavigationTarget(title, entityType, entityKey string) DashboardNavigationTarget {
return DashboardNavigationTarget{
Title: title,
EntityType: entityType,
EntityKey: entityKey,
Path: dashboardNavigationPath(title, entityType, entityKey),
}
}
func dashboardNavigationPath(title, entityType, entityKey string) string {
if title == "Actions" {
return "/"
}
if entityType != "" && entityKey != "" {
return fmt.Sprintf("/dashboards/%s/%s/%s", title, entityType, entityKey)
}
return fmt.Sprintf("/dashboards/%s", title)
}
func addTitleTarget(index *dashboardTargetIndex, actionTitle string, target DashboardNavigationTarget) {
index.byTitle[actionTitle] = appendUniqueTarget(index.byTitle[actionTitle], target)
}
func addEntityTarget(index *dashboardTargetIndex, actionTitle, entityKey string, target DashboardNavigationTarget) {
if index.byTitleEntityKey[actionTitle] == nil {
index.byTitleEntityKey[actionTitle] = make(map[string][]DashboardNavigationTarget)
}
entityTargets := index.byTitleEntityKey[actionTitle][entityKey]
index.byTitleEntityKey[actionTitle][entityKey] = appendUniqueTarget(entityTargets, target)
}
func appendUniqueTarget(targets []DashboardNavigationTarget, target DashboardNavigationTarget) []DashboardNavigationTarget {
for _, existing := range targets {
if existing.key() == target.key() {
return targets
}
}
return append(targets, target)
}
func (index *dashboardTargetIndex) targetsForAction(actionTitle string) []DashboardNavigationTarget {
return dedupeTargets(index.byTitle[actionTitle])
}
func (index *dashboardTargetIndex) targetsForEntityAction(actionTitle, entityKey string) []DashboardNavigationTarget {
targets := make([]DashboardNavigationTarget, 0)
targets = append(targets, index.byTitle[actionTitle]...)
if entityTargets, ok := index.byTitleEntityKey[actionTitle]; ok {
targets = append(targets, entityTargets[entityKey]...)
}
return dedupeTargets(targets)
}
func dedupeTargets(targets []DashboardNavigationTarget) []DashboardNavigationTarget {
if len(targets) == 0 {
return nil
}
seen := make(map[string]bool, len(targets))
result := make([]DashboardNavigationTarget, 0, len(targets))
for _, target := range targets {
key := target.key()
if seen[key] {
continue
}
seen[key] = true
result = append(result, target)
}
return result
}
func resolveOnDashboards(index *dashboardTargetIndex, actionTitle, entityKey string) []DashboardNavigationTarget {
var targets []DashboardNavigationTarget
if entityKey == "" {
targets = index.targetsForAction(actionTitle)
} else {
targets = index.targetsForEntityAction(actionTitle, entityKey)
}
if len(targets) == 0 {
return []DashboardNavigationTarget{dashboardNavigationTarget("Actions", "", "")}
}
return targets
}

View File

@ -0,0 +1,146 @@
package executor
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
)
func TestResolveOnDashboardsDefaultsToActionsDashboard(t *testing.T) {
index := buildDashboardTargetIndex(&config.Config{})
targets := resolveOnDashboards(index, "Lonely Action", "")
require.Len(t, targets, 1)
assert.Equal(t, "Actions", targets[0].Title)
assert.Equal(t, "/", targets[0].Path)
}
func TestResolveOnDashboardsConfiguredDashboard(t *testing.T) {
cfg := &config.Config{
Actions: []*config.Action{
{Title: "Restart"},
},
Dashboards: []*config.DashboardComponent{
{
Title: "Operations",
Contents: []*config.DashboardComponent{
{Title: "Restart"},
},
},
},
}
index := buildDashboardTargetIndex(cfg)
targets := resolveOnDashboards(index, "Restart", "")
require.Len(t, targets, 1)
assert.Equal(t, "Operations", targets[0].Title)
assert.Equal(t, "/dashboards/Operations", targets[0].Path)
}
func TestResolveOnDashboardsEntityDirectory(t *testing.T) {
cfg := &config.Config{
Actions: []*config.Action{
{Title: "Reboot", Entity: "host"},
},
Dashboards: []*config.DashboardComponent{
{
Title: "Servers",
Contents: []*config.DashboardComponent{
{
Type: "fieldset",
Entity: "host",
Contents: []*config.DashboardComponent{
{
Type: "directory",
Title: "Host Details",
Contents: []*config.DashboardComponent{
{Title: "Reboot"},
},
},
},
},
},
},
},
}
entities.ClearEntitiesOfType("host")
defer entities.ClearEntitiesOfType("host")
entities.AddEntity("host", "host-1", map[string]any{"title": "Host 1"})
index := buildDashboardTargetIndex(cfg)
targets := resolveOnDashboards(index, "Reboot", "host-1")
require.Len(t, targets, 1)
assert.Equal(t, "Host Details", targets[0].Title)
assert.Equal(t, "host", targets[0].EntityType)
assert.Equal(t, "host-1", targets[0].EntityKey)
assert.Equal(t, "/dashboards/Host Details/host/host-1", targets[0].Path)
}
func TestRebuildActionMapStoresOnDashboards(t *testing.T) {
cfg := &config.Config{
Actions: []*config.Action{
{Title: "Only Default"},
{Title: "Configured"},
},
Dashboards: []*config.DashboardComponent{
{
Title: "Custom",
Contents: []*config.DashboardComponent{
{Title: "Configured"},
},
},
},
}
ex := DefaultExecutor(cfg)
ex.RebuildActionMap()
defaultBinding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, defaultBinding)
require.Len(t, defaultBinding.OnDashboards, 1)
assert.Equal(t, "Actions", defaultBinding.OnDashboards[0].Title)
assert.False(t, defaultBinding.IsOnConfiguredDashboard())
configuredBinding := ex.FindBindingWithNoEntity(cfg.Actions[1])
require.NotNil(t, configuredBinding)
require.Len(t, configuredBinding.OnDashboards, 1)
assert.Equal(t, "Custom", configuredBinding.OnDashboards[0].Title)
assert.True(t, configuredBinding.IsOnConfiguredDashboard())
}
func TestResolveOnDashboardsMultipleDashboards(t *testing.T) {
cfg := &config.Config{
Actions: []*config.Action{
{Title: "Shared"},
},
Dashboards: []*config.DashboardComponent{
{
Title: "One",
Contents: []*config.DashboardComponent{
{Title: "Shared"},
},
},
{
Title: "Two",
Contents: []*config.DashboardComponent{
{Title: "Shared"},
},
},
},
}
index := buildDashboardTargetIndex(cfg)
targets := resolveOnDashboards(index, "Shared", "")
require.Len(t, targets, 2)
assert.Equal(t, "One", targets[0].Title)
assert.Equal(t, "Two", targets[1].Title)
}

View File

@ -12,6 +12,7 @@ import (
type groupLimit struct {
name string
maxConcurrent int
queueSize int
}
type queuedExecution struct {
@ -45,7 +46,11 @@ func groupLimitFromConfig(cfg *config.Config, groupName string) (groupLimit, boo
return groupLimit{}, false
}
return groupLimit{name: groupName, maxConcurrent: group.MaxConcurrent}, true
return groupLimit{
name: groupName,
maxConcurrent: group.MaxConcurrent,
queueSize: group.QueueSize,
}, true
}
func actionNeedsGroupLimit(req *ExecutionRequest) bool {
@ -79,6 +84,64 @@ func (e *Executor) countActiveInGroupLocked(groupName string) int {
return count
}
func (e *Executor) countQueuedInGroupLocked(groupName string) int {
count := 0
for _, logEntry := range e.logs {
if queuedLogEntryInGroup(logEntry, groupName) {
count++
}
}
return count
}
func queuedLogEntryInGroup(logEntry *InternalLogEntry, groupName string) bool {
if !logEntryIsBound(logEntry) {
return false
}
if !logEntry.Queued || logEntry.ExecutionFinished {
return false
}
return actionInGroup(logEntry.Binding.Action, groupName)
}
func logEntryIsBound(logEntry *InternalLogEntry) bool {
return logEntry != nil && logEntry.Binding != nil && logEntry.Binding.Action != nil
}
func groupIsAtActiveCapacity(activeCount int, limit groupLimit) bool {
return activeCount >= (limit.maxConcurrent + 1)
}
func (e *Executor) fullGroupWithQueueExceededLocked(req *ExecutionRequest) string {
for _, limit := range actionGroupLimits(req) {
if !groupIsAtActiveCapacity(e.countActiveInGroupLocked(limit.name), limit) {
continue
}
if e.countQueuedInGroupLocked(limit.name) >= limit.queueSize {
return limit.name
}
}
return ""
}
func (e *Executor) blockRequestForGroupQueue(req *ExecutionRequest, groupName string) {
log.WithFields(log.Fields{
"actionTitle": req.logEntry.ActionTitle,
"groupName": groupName,
}).Warnf("Blocked from executing due to action group queue limit")
req.mutateLogEntry(func(entry *InternalLogEntry) {
entry.Output = fmt.Sprintf("Blocked from executing due to action group %q queue limit", groupName)
entry.Blocked = true
})
}
func logEntryIsActiveInGroup(logEntry *InternalLogEntry, groupName string) bool {
if inactiveLogEntry(logEntry) {
return false
@ -143,16 +206,26 @@ func firstFullGroupNameLocked(e *Executor, req *ExecutionRequest) string {
return ""
}
func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) {
func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) bool {
e.groupQueueMu.Lock()
var groupName string
e.logmutex.RLock()
groupName := e.fullGroupWithQueueExceededLocked(req)
e.logmutex.RUnlock()
if groupName != "" {
e.groupQueueMu.Unlock()
e.blockRequestForGroupQueue(req, groupName)
return true
}
var waitingForGroup string
req.mutateLogEntry(func(entry *InternalLogEntry) {
groupName = firstFullGroupNameLocked(e, req)
waitingForGroup = firstFullGroupNameLocked(e, req)
entry.Queued = true
entry.QueuedForGroup = groupName
entry.Output = fmt.Sprintf("Queued waiting for action group %q", groupName)
entry.QueuedForGroup = waitingForGroup
entry.Output = fmt.Sprintf("Queued waiting for action group %q", waitingForGroup)
})
e.groupQueue = append(e.groupQueue, &queuedExecution{req: req, wg: wg})
@ -162,8 +235,10 @@ func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) {
log.WithFields(log.Fields{
"actionTitle": req.logEntry.ActionTitle,
"groupName": groupName,
"groupName": waitingForGroup,
}).Infof("Action queued due to action group concurrency limit")
return false
}
func (e *Executor) drainGroupQueue() {

View File

@ -236,20 +236,19 @@ func TestPerActionConcurrencyStillBlocksWithoutQueue(t *testing.T) {
assert.False(t, snapshot.Queued)
}
func TestPerActionConcurrencyBlocksSameBindingBeforeGroupQueue(t *testing.T) {
func TestGroupedSameBindingQueuesWhenGroupFull(t *testing.T) {
t.Parallel()
action := &config.Action{
Title: "Single binding grouped",
Shell: "sleep 1",
MaxConcurrent: 1,
Groups: []string{"unity"},
Title: "Single binding grouped",
Shell: "sleep 1",
Groups: []string{"unity"},
}
e, cfg := testGroupExecutor(
[]*config.Action{action},
map[string]*config.ActionGroup{
"unity": {MaxConcurrent: 1},
"unity": {MaxConcurrent: 1, QueueSize: 5},
},
)
binding := e.FindBindingWithNoEntity(action)
@ -268,15 +267,117 @@ func TestPerActionConcurrencyBlocksSameBindingBeforeGroupQueue(t *testing.T) {
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()
snapshot, ok := e.SnapshotLog(tracking2)
require.True(t, ok)
assert.True(t, snapshot.Blocked)
assert.False(t, snapshot.Blocked)
}
func TestGroupAllowsTwoConcurrentSameBinding(t *testing.T) {
t.Parallel()
action := &config.Action{
Title: "Long running action",
Shell: "sleep 1",
Groups: []string{"con2queue10"},
}
e, cfg := testGroupExecutor(
[]*config.Action{action},
map[string]*config.ActionGroup{
"con2queue10": {MaxConcurrent: 2, QueueSize: 10},
},
)
binding := e.FindBindingWithNoEntity(action)
wg1, tracking1 := e.ExecRequest(&ExecutionRequest{
Binding: binding,
Cfg: cfg,
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
})
waitUntilExecutionStarted(t, e, tracking1)
wg2, tracking2 := e.ExecRequest(&ExecutionRequest{
Binding: binding,
Cfg: cfg,
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
})
require.Eventually(t, func() bool {
snapshot, ok := e.SnapshotLog(tracking2)
return ok && snapshot.ExecutionStarted && !snapshot.Queued && !snapshot.Blocked
}, 2*time.Second, 10*time.Millisecond)
wg1.Wait()
wg2.Wait()
snapshot, ok := e.SnapshotLog(tracking2)
require.True(t, ok)
assert.False(t, snapshot.Blocked)
assert.False(t, snapshot.Queued)
}
func TestGroupQueuesThirdAndBlocksWhenQueueFull(t *testing.T) {
t.Parallel()
action := &config.Action{
Title: "Long running action",
Shell: "sleep 1",
Groups: []string{"con2queue10"},
}
e, cfg := testGroupExecutor(
[]*config.Action{action},
map[string]*config.ActionGroup{
"con2queue10": {MaxConcurrent: 2, QueueSize: 2},
},
)
binding := e.FindBindingWithNoEntity(action)
wg1, tracking1 := e.ExecRequest(&ExecutionRequest{
Binding: binding,
Cfg: cfg,
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
})
waitUntilExecutionStarted(t, e, tracking1)
wg2, tracking2 := e.ExecRequest(&ExecutionRequest{
Binding: binding,
Cfg: cfg,
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
})
waitUntilExecutionStarted(t, e, tracking2)
trackings := []string{tracking1, tracking2}
waitGroups := []*sync.WaitGroup{wg1, wg2}
for idx := 0; idx < 3; idx++ {
wg, tracking := e.ExecRequest(&ExecutionRequest{
Binding: binding,
Cfg: cfg,
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
})
trackings = append(trackings, tracking)
waitGroups = append(waitGroups, wg)
}
require.Eventually(t, func() bool {
return groupExecutionDistributionMatches(e, trackings, 2, 2, 1)
}, 2*time.Second, 20*time.Millisecond)
for _, wg := range waitGroups {
wg.Wait()
}
}
func waitUntilExecutionStarted(t *testing.T, e *Executor, trackingID string) {
t.Helper()
@ -390,6 +491,79 @@ func TestStartActionAndWaitWaitsForQueuedExecution(t *testing.T) {
assert.Contains(t, snapshot.Output, "waited")
}
func TestGroupQueueBlocksWhenQueueFull(t *testing.T) {
t.Parallel()
actions := []*config.Action{
{Title: "Hold 1", Shell: "sleep 1", Groups: []string{"unity"}},
{Title: "Hold 2", Shell: "sleep 1", Groups: []string{"unity"}},
{Title: "Hold 3", Shell: "sleep 1", Groups: []string{"unity"}},
{Title: "Hold 4", Shell: "sleep 1", Groups: []string{"unity"}},
}
e, cfg := testGroupExecutor(
actions,
map[string]*config.ActionGroup{
"unity": {MaxConcurrent: 1, QueueSize: 2},
},
)
trackings, waitGroups := execAllGroupActions(t, e, cfg, actions)
require.Eventually(t, func() bool {
return countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Blocked }) == 1 &&
countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Queued }) == 2 &&
countSnapshots(e, trackings, isRunningSnapshot) == 1
}, 2*time.Second, 20*time.Millisecond)
for _, wg := range waitGroups {
wg.Wait()
}
}
func execAllGroupActions(t *testing.T, e *Executor, cfg *config.Config, actions []*config.Action) ([]string, []*sync.WaitGroup) {
t.Helper()
trackings := make([]string, len(actions))
waitGroups := make([]*sync.WaitGroup, len(actions))
for idx, action := range actions {
wg, tracking := e.ExecRequest(&ExecutionRequest{
Binding: e.FindBindingWithNoEntity(action),
Cfg: cfg,
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
})
trackings[idx] = tracking
waitGroups[idx] = wg
}
return trackings, waitGroups
}
func groupExecutionDistributionMatches(e *Executor, trackings []string, wantRunning, wantQueued, wantBlocked int) bool {
running := countSnapshots(e, trackings, isRunningSnapshot)
queued := countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Queued })
blocked := countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Blocked })
return running == wantRunning && queued == wantQueued && blocked == wantBlocked
}
func countSnapshots(e *Executor, trackings []string, matches func(LogEntrySnapshot) bool) int {
count := 0
for _, tracking := range trackings {
snapshot, ok := e.SnapshotLog(tracking)
if ok && matches(snapshot) {
count++
}
}
return count
}
func isRunningSnapshot(snapshot LogEntrySnapshot) bool {
return snapshot.ExecutionStarted && !snapshot.ExecutionFinished
}
func TestUnknownActionGroupReferenceWarnsAndSkipsLimit(t *testing.T) {
t.Parallel()