4
0
Эх сурвалжийг харах

feat: action group sizing

jamesread 2 долоо хоног өмнө
parent
commit
633d9ecd82
40 өөрчлөгдсөн 1604 нэмэгдсэн , 288 устгасан
  1. 12 8
      config.yaml
  2. 1 1
      docs/modules/ROOT/nav.adoc
  3. 16 2
      docs/modules/ROOT/pages/action_customization/concurrency.adoc
  4. 2 3
      docs/modules/ROOT/pages/action_execution/ondemand.adoc
  5. 33 33
      frontend/package-lock.json
  6. 3 3
      frontend/package.json
  7. 77 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  8. 0 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  9. 34 0
      frontend/resources/vue/components/ActionGroupLimitsLabel.vue
  10. 3 7
      frontend/resources/vue/router.js
  11. 32 0
      frontend/resources/vue/utils/executionConditionCount.js
  12. 84 12
      frontend/resources/vue/views/ActionDetailsView.vue
  13. 1 1
      frontend/resources/vue/views/ActionExecConditionsView.vue
  14. 132 27
      frontend/resources/vue/views/ExecutionView.vue
  15. 5 10
      frontend/resources/vue/views/LogsQueueView.vue
  16. 5 5
      lang/combined_output.json
  17. 1 1
      lang/de-DE.yaml
  18. 1 1
      lang/en.yaml
  19. 1 1
      lang/es-ES.yaml
  20. 1 1
      lang/it-IT.yaml
  21. 1 1
      lang/zh-Hans-CN.yaml
  22. 17 0
      proto/olivetin/api/v1/olivetin.proto
  23. 244 99
      service/gen/olivetin/api/v1/olivetin.pb.go
  24. 30 2
      service/internal/api/api.go
  25. 29 0
      service/internal/api/apiActions.go
  26. 1 0
      service/internal/api/api_queue.go
  27. 1 0
      service/internal/api/api_queue_test.go
  28. 93 0
      service/internal/api/api_test.go
  29. 1 1
      service/internal/api/dashboards.go
  30. 1 0
      service/internal/config/config.go
  31. 6 0
      service/internal/config/sanitize.go
  32. 22 0
      service/internal/config/sanitize_test.go
  33. 10 1
      service/internal/executor/arguments.go
  34. 14 0
      service/internal/executor/arguments_test.go
  35. 18 6
      service/internal/executor/executor.go
  36. 14 48
      service/internal/executor/executor_actions.go
  37. 249 0
      service/internal/executor/executor_dashboards.go
  38. 146 0
      service/internal/executor/executor_dashboards_test.go
  39. 82 7
      service/internal/executor/group_concurrency.go
  40. 181 7
      service/internal/executor/group_concurrency_test.go

+ 12 - 8
config.yaml

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

+ 1 - 1
docs/modules/ROOT/nav.adoc

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

+ 16 - 2
docs/modules/ROOT/pages/action_customization/concurrency.adoc

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

+ 2 - 3
docs/modules/ROOT/pages/action_execution/ondemand.adoc

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

+ 33 - 33
frontend/package-lock.json

@@ -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": {

+ 3 - 3
frontend/package.json

@@ -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": {

+ 77 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -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[];
 };
 
 /**

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 34 - 0
frontend/resources/vue/components/ActionGroupLimitsLabel.vue

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

+ 3 - 7
frontend/resources/vue/router.js

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

+ 32 - 0
frontend/resources/vue/utils/executionConditionCount.js

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

+ 84 - 12
frontend/resources/vue/views/ActionDetailsView.vue

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

+ 1 - 1
frontend/resources/vue/views/ActionExecConditionsView.vue

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

+ 132 - 27
frontend/resources/vue/views/ExecutionView.vue

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

+ 5 - 10
frontend/resources/vue/views/LogsQueueView.vue

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

+ 5 - 5
lang/combined_output.json

@@ -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": "运行中",

+ 1 - 1
lang/de-DE.yaml

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

+ 1 - 1
lang/en.yaml

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

+ 1 - 1
lang/es-ES.yaml

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

+ 1 - 1
lang/it-IT.yaml

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

+ 1 - 1
lang/zh-Hans-CN.yaml

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

+ 17 - 0
proto/olivetin/api/v1/olivetin.proto

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

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 244 - 99
service/gen/olivetin/api/v1/olivetin.pb.go


+ 30 - 2
service/internal/api/api.go

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

+ 29 - 0
service/internal/api/apiActions.go

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

+ 1 - 0
service/internal/api/api_queue.go

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

+ 1 - 0
service/internal/api/api_queue_test.go

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

+ 93 - 0
service/internal/api/api_test.go

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

+ 1 - 1
service/internal/api/dashboards.go

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

+ 1 - 0
service/internal/config/config.go

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

+ 6 - 0
service/internal/config/sanitize.go

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

+ 22 - 0
service/internal/config/sanitize_test.go

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

+ 10 - 1
service/internal/executor/arguments.go

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

+ 14 - 0
service/internal/executor/arguments_test.go

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

+ 18 - 6
service/internal/executor/executor.go

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

+ 14 - 48
service/internal/executor/executor_actions.go

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

+ 249 - 0
service/internal/executor/executor_dashboards.go

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

+ 146 - 0
service/internal/executor/executor_dashboards_test.go

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

+ 82 - 7
service/internal/executor/group_concurrency.go

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

+ 181 - 7
service/internal/executor/group_concurrency_test.go

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

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно