jamesread 2 недель назад
Родитель
Сommit
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
 # Docs: https://docs.olivetin.app/advanced_configuration/logs.html
 logLevel: "INFO"
 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
 # Actions are commands that are executed by OliveTin, and normally show up as
 # buttons on the WebUI.
 # buttons on the WebUI.
 #
 #
@@ -266,6 +258,7 @@ actions:
     timeout: 300
     timeout: 300
     icon: logs
     icon: logs
     onclick: execution-dialog
     onclick: execution-dialog
+    groups: [ con2queue10 ]
     execOnCron:
     execOnCron:
       - "@hourly"
       - "@hourly"
 
 
@@ -310,6 +303,17 @@ entities:
   - file: entities/containers.json
   - file: entities/containers.json
     name: container
     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
 # Dashboards are a way of taking actions from the default "actions" view, and
 # organizing them into groups - either into folders, or fieldsets.
 # organizing them into groups - either into folders, or fieldsets.
 #
 #

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

@@ -37,7 +37,7 @@
 * Action Execution
 * Action Execution
 ** xref:action_execution/create_your_first.adoc[Create your first action]
 ** xref:action_execution/create_your_first.adoc[Create your first action]
 ** xref:action_execution/shellvsexec.adoc[Shell vs Exec]
 ** 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/oncron.adoc[Execute on schedule (cron)]
 ** xref:action_execution/onstartup.adoc[Execute on startup]
 ** xref:action_execution/onstartup.adoc[Execute on startup]
 ** xref:action_execution/onwebhook.adoc[Execute on webhook]
 ** 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:
 actionGroups:
   unity:
   unity:
     maxConcurrent: 1
     maxConcurrent: 1
+    queueSize: 5
 
 
 actions:
 actions:
   - title: Unity Android Build
   - title: Unity Android Build
@@ -51,8 +52,21 @@ actions:
     groups: [ unity ]
     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.
 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.
 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": {
 			"dependencies": {
 				"@connectrpc/connect": "^2.1.2",
 				"@connectrpc/connect": "^2.1.2",
 				"@connectrpc/connect-web": "^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",
 				"@hugeicons/vue": "^1.0.6",
 				"@vitejs/plugin-vue": "^6.0.7",
 				"@vitejs/plugin-vue": "^6.0.7",
 				"@xterm/addon-fit": "^0.11.0",
 				"@xterm/addon-fit": "^0.11.0",
 				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/xterm": "^6.0.0",
 				"@xterm/xterm": "^6.0.0",
 				"iconify-icon": "^3.0.2",
 				"iconify-icon": "^3.0.2",
-				"picocrank": "^1.16.0",
+				"picocrank": "^1.17.0",
 				"standard": "^17.1.2",
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^32.1.0",
 				"unplugin-vue-components": "^32.1.0",
 				"vite": "^8.0.16",
 				"vite": "^8.0.16",
 				"vue": "^3.5.38",
 				"vue": "^3.5.38",
-				"vue-i18n": "^11.4.5",
+				"vue-i18n": "^11.4.6",
 				"vue-router": "^5.1.0"
 				"vue-router": "^5.1.0"
 			},
 			},
 			"devDependencies": {
 			"devDependencies": {
@@ -941,9 +941,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@hugeicons/core-free-icons": {
 		"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"
 			"license": "MIT"
 		},
 		},
 		"node_modules/@hugeicons/vue": {
 		"node_modules/@hugeicons/vue": {
@@ -997,14 +997,14 @@
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/@intlify/core-base": {
 		"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",
 			"license": "MIT",
 			"dependencies": {
 			"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": {
 			"engines": {
 				"node": ">= 22"
 				"node": ">= 22"
@@ -1014,13 +1014,13 @@
 			}
 			}
 		},
 		},
 		"node_modules/@intlify/devtools-types": {
 		"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",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@intlify/core-base": "11.4.5",
-				"@intlify/shared": "11.4.5"
+				"@intlify/core-base": "11.4.6",
+				"@intlify/shared": "11.4.6"
 			},
 			},
 			"engines": {
 			"engines": {
 				"node": ">= 22"
 				"node": ">= 22"
@@ -1030,12 +1030,12 @@
 			}
 			}
 		},
 		},
 		"node_modules/@intlify/message-compiler": {
 		"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",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@intlify/shared": "11.4.5",
+				"@intlify/shared": "11.4.6",
 				"source-map-js": "^1.0.2"
 				"source-map-js": "^1.0.2"
 			},
 			},
 			"engines": {
 			"engines": {
@@ -1046,9 +1046,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@intlify/shared": {
 		"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",
 			"license": "MIT",
 			"engines": {
 			"engines": {
 				"node": ">= 22"
 				"node": ">= 22"
@@ -5186,9 +5186,9 @@
 			"license": "ISC"
 			"license": "ISC"
 		},
 		},
 		"node_modules/picocrank": {
 		"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",
 			"license": "ISC",
 			"dependencies": {
 			"dependencies": {
 				"@hugeicons/core-free-icons": "^4.1.1",
 				"@hugeicons/core-free-icons": "^4.1.1",
@@ -6900,14 +6900,14 @@
 			}
 			}
 		},
 		},
 		"node_modules/vue-i18n": {
 		"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",
 			"license": "MIT",
 			"dependencies": {
 			"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"
 				"@vue/devtools-api": "^6.5.0"
 			},
 			},
 			"engines": {
 			"engines": {

+ 3 - 3
frontend/package.json

@@ -24,19 +24,19 @@
 	"dependencies": {
 	"dependencies": {
 		"@connectrpc/connect": "^2.1.2",
 		"@connectrpc/connect": "^2.1.2",
 		"@connectrpc/connect-web": "^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",
 		"@hugeicons/vue": "^1.0.6",
 		"@vitejs/plugin-vue": "^6.0.7",
 		"@vitejs/plugin-vue": "^6.0.7",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
 		"@xterm/xterm": "^6.0.0",
 		"iconify-icon": "^3.0.2",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.16.0",
+		"picocrank": "^1.17.0",
 		"standard": "^17.1.2",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^32.1.0",
 		"unplugin-vue-components": "^32.1.0",
 		"vite": "^8.0.16",
 		"vite": "^8.0.16",
 		"vue": "^3.5.38",
 		"vue": "^3.5.38",
-		"vue-i18n": "^11.4.5",
+		"vue-i18n": "^11.4.6",
 		"vue-router": "^5.1.0"
 		"vue-router": "^5.1.0"
 	},
 	},
 	"engines": {
 	"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;
    * @generated from field: bool has_queued_instance = 18;
    */
    */
   hasQueuedInstance: boolean;
   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>;
 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
  * @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;
    * @generated from field: int32 queued_count = 6;
    */
    */
   queuedCount: number;
   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>;
 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
  * @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;
    * @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
    */
    */
   logEntry?: LogEntry | undefined;
   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;
    * @generated from field: olivetin.api.v1.Action action = 1;
    */
    */
   action?: Action | undefined;
   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
 // Navigation guard to update page title
-router.beforeEach((to, from, next) => {
+router.beforeEach((to) => {
   if (to.meta && to.meta.title) {
   if (to.meta && to.meta.title) {
     const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
     const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
     document.title = to.meta.title + " - " + pageTitle
     document.title = to.meta.title + " - " + pageTitle
   }
   }
-  next()
 })
 })
 
 
 // Navigation guard for authentication (if needed)
 // 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
   const isAuthenticated = window.isAuthenticated || true // Default to true for now
 
 
   if (to.meta.requiresAuth && !isAuthenticated) {
   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>
 <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>
       <template #toolbar>
         <div class="action-details-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>
           </button>
           <router-link
           <router-link
             v-if="action"
             v-if="action"
@@ -14,7 +29,7 @@
             class="button neutral"
             class="button neutral"
             title="View configured automatic triggers and on-demand execution"
             title="View configured automatic triggers and on-demand execution"
           >
           >
-            Execution conditions
+            Execution conditions ({{ executionConditionCount }})
           </router-link>
           </router-link>
         </div>
         </div>
       </template>
       </template>
@@ -22,18 +37,30 @@
       <div class = "flex-row padding" v-if="action">
       <div class = "flex-row padding" v-if="action">
         <div class = "fg1">
         <div class = "fg1">
           <dl>
           <dl>
-            <dt>Title</dt>
-            <dd>{{ action.title }}</dd>
             <dt>Timeout</dt>
             <dt>Timeout</dt>
             <dd>{{ action.timeout }} seconds</dd>
             <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>
           </dl>
           <p class = "fg1">
           <p class = "fg1">
             Execution history for this action. You can filter by execution tracking ID.
             Execution history for this action. You can filter by execution tracking ID.
           </p>
           </p>
         </div>
         </div>
         <div style = "align-self: start; text-align: right;">
         <div style = "align-self: start; text-align: right;">
-          <ActionIconGlyph class="icon" :glyph="action.icon" />
-
           <div class="filter-container">
           <div class="filter-container">
             <label class="input-with-icons">
             <label class="input-with-icons">
               <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
               <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 Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
+import 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 { requestReconnectNow } from '../../../js/websocket.js'
 import { needsArgumentForm } from '../utils/needsArgumentForm.js'
 import { needsArgumentForm } from '../utils/needsArgumentForm.js'
 import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
 import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
+import { countExecutionConditions } from '../utils/executionConditionCount.js'
 
 
 const route = useRoute()
 const route = useRoute()
 const router = useRouter()
 const router = useRouter()
 const logs = ref([])
 const logs = ref([])
 const action = ref(null)
 const action = ref(null)
+const backToDashboards = ref([])
 const actionTitle = ref('Action Details')
 const actionTitle = ref('Action Details')
 const searchText = ref('')
 const searchText = ref('')
 const pageSize = ref(10)
 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() {
 async function fetchActionLogs() {
   loading.value = true
   loading.value = true
   try {
   try {
@@ -171,6 +207,7 @@ async function fetchAction() {
     }
     }
     const response = await window.client.getActionBinding(args)
     const response = await window.client.getActionBinding(args)
     action.value = response.action
     action.value = response.action
+    backToDashboards.value = (response.backToDashboards || []).slice(0, 3)
     actionTitle.value = action.value?.title || 'Unknown Action'
     actionTitle.value = action.value?.title || 'Unknown Action'
   } catch (err) {
   } catch (err) {
     console.error('Failed to fetch action:', err)
     console.error('Failed to fetch action:', err)
@@ -178,8 +215,13 @@ async function fetchAction() {
   }
   }
 }
 }
 
 
+function goToDashboard(path) {
+  router.push(path)
+}
+
 function resetState() {
 function resetState() {
   action.value = null
   action.value = null
+  backToDashboards.value = []
   actionTitle.value = 'Action Details'
   actionTitle.value = 'Action Details'
   logs.value = []
   logs.value = []
   totalCount.value = 0
   totalCount.value = 0
@@ -346,7 +388,13 @@ onUnmounted(() => {
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-.icon {
+.section-title-with-icon {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.action-title-icon {
   font-size: 1.5rem;
   font-size: 1.5rem;
 }
 }
 
 
@@ -472,4 +520,28 @@ onUnmounted(() => {
   gap: 0.5rem;
   gap: 0.5rem;
   align-items: center;
   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>
 </style>

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

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

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

@@ -1,27 +1,43 @@
 <template>
 <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>
     <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>
 			</button>
     </template>
     </template>
 
 
 		<div v-if="logEntry" class = "flex-row">
 		<div v-if="logEntry" class = "flex-row">
 				<dl class = "fg1">
 				<dl class = "fg1">
-					<dt>Action</dt>
-					<dd>
-						<LogActionTitle :action-title="title" :justification="logEntry.justification" />
-					</dd>
-
 					<dt>Duration</dt>
 					<dt>Duration</dt>
 					<dd><span v-html="duration"></span></dd>
 					<dd><span v-html="duration"></span></dd>
 
 
@@ -30,7 +46,6 @@
 						<ActionStatusDisplay :log-entry="logEntry" :link-queued-status="true" />
 						<ActionStatusDisplay :log-entry="logEntry" :link-queued-status="true" />
 					</dd>
 					</dd>
 				</dl>
 				</dl>
-        <ActionIconGlyph class="icon" role="img" :glyph="icon" style="align-self: start" />
     </div>
     </div>
 
 
 		<div v-if="notFound" class="error-message padded-content">
 		<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>.
 			<router-link to="/logs">View all logs</router-link> or <router-link to="/">return to home</router-link>.
 		</div>
 		</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 />
 			<br />
 
 
 			<div class="flex-row g1 buttons padded-content">
 			<div class="flex-row g1 buttons padded-content">
-				<button @click="goBack" title="Go back">
-					<HugeiconsIcon :icon="ArrowLeftIcon" />
-					Back
-				</button>
-
 				<div class = "fg1" />
 				<div class = "fg1" />
 
 
 					<button :disabled="!canRerun" @click="rerunAction" title="Rerun">
 					<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 Section from 'picocrank/vue/components/Section.vue'
 import { OutputTerminal } from '../../../js/OutputTerminal.js'
 import { OutputTerminal } from '../../../js/OutputTerminal.js'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { HugeiconsIcon } from '@hugeicons/vue'
-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 { useRouter } from 'vue-router'
 import { buttonResults } from '../stores/buttonResults'
 import { buttonResults } from '../stores/buttonResults'
 import { requestReconnectNow } from '../../../js/websocket.js'
 import { requestReconnectNow } from '../../../js/websocket.js'
@@ -104,6 +127,7 @@ const logEntry = ref(null)
 const canRerun = ref(false)
 const canRerun = ref(false)
 const canKill = ref(false)
 const canKill = ref(false)
 const actionId = ref('')
 const actionId = ref('')
+const backToDashboards = ref([])
 const notFound = ref(false)
 const notFound = ref(false)
 const errorMessage = ref('')
 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() {
 async function reset() {
   executionSeconds.value = 0
   executionSeconds.value = 0
   executionTrackingId.value = 'notset'
   executionTrackingId.value = 'notset'
@@ -149,6 +186,7 @@ async function reset() {
   canRerun.value = false
   canRerun.value = false
   canKill.value = false
   canKill.value = false
   logEntry.value = null
   logEntry.value = null
+  backToDashboards.value = []
   notFound.value = false
   notFound.value = false
   errorMessage.value = ''
   errorMessage.value = ''
 
 
@@ -239,15 +277,16 @@ async function fetchExecutionResult(executionTrackingIdParam) {
   executionTrackingId.value = executionTrackingIdParam
   executionTrackingId.value = executionTrackingIdParam
   notFound.value = false
   notFound.value = false
   errorMessage.value = ''
   errorMessage.value = ''
+  backToDashboards.value = []
 
 
   const executionStatusArgs = {
   const executionStatusArgs = {
 	executionTrackingId: executionTrackingId.value
 	executionTrackingId: executionTrackingId.value
   }
   }
 
 
   try {
   try {
-	const logEntryResult = await window.client.executionStatus(executionStatusArgs)
+	const executionStatusResult = await window.client.executionStatus(executionStatusArgs)
 
 
-	await renderExecutionResult(logEntryResult)
+	await renderExecutionResult(executionStatusResult)
   } catch (err) {
   } catch (err) {
 	// Check if it's a "not found" error (404 or similar)
 	// Check if it's a "not found" error (404 or similar)
 	if (err.status === 404 || err.code === 'NotFound' || err.message?.includes('not found')) {
 	if (err.status === 404 || err.code === 'NotFound' || err.message?.includes('not found')) {
@@ -286,6 +325,9 @@ function updateDuration(logEntryParam) {
 
 
 async function renderExecutionResult(res) {
 async function renderExecutionResult(res) {
   logEntry.value = res.logEntry
   logEntry.value = res.logEntry
+  if (res.backToDashboards) {
+    backToDashboards.value = res.backToDashboards.slice(0, 3)
+  }
 
 
   // Clear ticker
   // Clear ticker
   if (executionTicker) {
   if (executionTicker) {
@@ -343,6 +385,10 @@ function goBack() {
   router.back()
   router.back()
 }
 }
 
 
+function goToDashboard(path) {
+  router.push(path)
+}
+
 onMounted(() => {
 onMounted(() => {
   document.addEventListener('fullscreenchange', (e) => {
   document.addEventListener('fullscreenchange', (e) => {
     setTimeout(() => { // Wait for the DOM to settle
     setTimeout(() => { // Wait for the DOM to settle
@@ -390,6 +436,65 @@ defineExpose({
 </script>
 </script>
 
 
 <style scoped>
 <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 {
 .action-history-link {
   color: var(--link-color, #007bff);
   color: var(--link-color, #007bff);
   text-decoration: none;
   text-decoration: none;

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

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

+ 5 - 5
lang/combined_output.json

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

+ 1 - 1
lang/de-DE.yaml

@@ -35,7 +35,7 @@ translations:
   logs.queue-title: Ausführungswarteschlange
   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-page-description: Aktive und wartende Ausführungen, nach Aktionsgruppe gruppiert. Einträge ohne Berechtigung werden ausgeblendet.
   logs.queue-default-group: Standard
   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-group-active-unlimited: "{active} aktiv"
   logs.queue-empty: Derzeit gibt es keine aktiven oder wartenden Ausführungen.
   logs.queue-empty: Derzeit gibt es keine aktiven oder wartenden Ausführungen.
   logs.queue-group-active: "{active} aktiv (max. {max})"
   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-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-empty: There are no active or waiting executions right now.
   logs.queue-default-group: Default
   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: "{active} active (max {max})"
   logs.queue-group-active-unlimited: "{active} active"
   logs.queue-group-active-unlimited: "{active} active"
   logs.queue-waiting: Waiting
   logs.queue-waiting: Waiting

+ 1 - 1
lang/es-ES.yaml

@@ -35,7 +35,7 @@ translations:
   logs.queue-title: Cola de ejecución
   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-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-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-group-active-unlimited: "{active} activas"
   logs.queue-empty: No hay ejecuciones activas o en espera en este momento.
   logs.queue-empty: No hay ejecuciones activas o en espera en este momento.
   logs.queue-group-active: "{active} activas (máx. {max})"
   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-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-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-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-group-active-unlimited: "{active} attive"
   logs.queue-empty: Non ci sono esecuzioni attive o in attesa al momento.
   logs.queue-empty: Non ci sono esecuzioni attive o in attesa al momento.
   logs.queue-group-active: "{active} attive (max {max})"
   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-title: 执行队列
   logs.queue-page-description: 按动作组分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。
   logs.queue-page-description: 按动作组分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。
   logs.queue-default-group: 默认
   logs.queue-default-group: 默认
-  logs.queue-group-limit: "上限 {max},排队 {queued}"
+  logs.action-group-limits: "并发:{concurrent},队列大小:{queueSize}"
   logs.queue-group-active-unlimited: "{active} 个活动"
   logs.queue-group-active-unlimited: "{active} 个活动"
   logs.queue-empty: 当前没有正在运行或等待中的执行。
   logs.queue-empty: 当前没有正在运行或等待中的执行。
   logs.queue-group-active: "{active} 个活动(上限 {max})"
   logs.queue-group-active: "{active} 个活动(上限 {max})"

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

@@ -23,6 +23,13 @@ message Action {
 	bool justification = 16;
 	bool justification = 16;
 	bool has_running_instance = 17;
 	bool has_running_instance = 17;
 	bool has_queued_instance = 18;
 	bool has_queued_instance = 18;
+	repeated ActionGroupMembership groups = 19;
+}
+
+message ActionGroupMembership {
+	string name = 1;
+	int32 max_concurrent = 2;
+	int32 queue_size = 3;
 }
 }
 
 
 message ActionWebhookExecHint {
 message ActionWebhookExecHint {
@@ -209,6 +216,7 @@ message ExecutionQueueGroup {
 	int32 active_count = 4;
 	int32 active_count = 4;
 	repeated ExecutionQueueAction actions = 5;
 	repeated ExecutionQueueAction actions = 5;
 	int32 queued_count = 6;
 	int32 queued_count = 6;
+	int32 queue_size = 7;
 }
 }
 
 
 message GetExecutionQueueResponse {
 message GetExecutionQueueResponse {
@@ -241,8 +249,16 @@ message ExecutionStatusRequest {
 	string action_id = 2;
 	string action_id = 2;
 }
 }
 
 
+message DashboardNavigationTarget {
+	string title = 1;
+	string entity_type = 2;
+	string entity_key = 3;
+	string path = 4;
+}
+
 message ExecutionStatusResponse {
 message ExecutionStatusResponse {
 	LogEntry log_entry = 1;
 	LogEntry log_entry = 1;
+	repeated DashboardNavigationTarget back_to_dashboards = 2;
 }
 }
 
 
 message WhoAmIRequest {}
 message WhoAmIRequest {}
@@ -406,6 +422,7 @@ message GetActionBindingRequest {
 
 
 message GetActionBindingResponse {
 message GetActionBindingResponse {
 	Action action = 1;
 	Action action = 1;
+	repeated DashboardNavigationTarget back_to_dashboards = 2;
 }
 }
 
 
 message GetEntitiesRequest {
 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)
 	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) {
 func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 	if err := api.checkDashboardAccess(user); err != nil {
 	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
 		return nil, err
 	}
 	}
 	res := &apiv1.ExecutionStatusResponse{
 	res := &apiv1.ExecutionStatusResponse{
-		LogEntry: api.internalLogEntryToPb(ile, user),
+		LogEntry:         api.internalLogEntryToPb(ile, user),
+		BackToDashboards: api.executionStatusBackToDashboards(ile),
 	}
 	}
 	return connect.NewResponse(res), nil
 	return connect.NewResponse(res), nil
 }
 }
@@ -531,7 +558,8 @@ func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedU
 	}
 	}
 
 
 	return &apiv1.GetActionBindingResponse{
 	return &apiv1.GetActionBindingResponse{
-		Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
+		Action:           buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
+		BackToDashboards: dashboardNavigationTargetsToPb(binding.OnDashboards),
 	}, nil
 	}, 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)
 	applyActiveBindingStateToAction(&btn, binding.ID, rr.activeBindingStates)
 	applyActionExecTriggers(&btn, action)
 	applyActionExecTriggers(&btn, action)
 	btn.Arguments = buildActionArguments(action, binding.Entity)
 	btn.Arguments = buildActionArguments(action, binding.Entity)
+	btn.Groups = buildActionGroups(action, rr.cfg)
 
 
 	return &btn
 	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 {
 func buildChoices(arg config.ActionArgument) []*apiv1.ActionArgumentChoice {
 	if arg.Entity != "" && len(arg.Choices) == 1 {
 	if arg.Entity != "" && len(arg.Choices) == 1 {
 		return buildChoicesEntity(arg.Choices[0], arg.Entity)
 		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.Icon = actionGroup.Icon
 	group.MaxConcurrent = int32(actionGroup.MaxConcurrent)
 	group.MaxConcurrent = int32(actionGroup.MaxConcurrent)
+	group.QueueSize = int32(actionGroup.QueueSize)
 	return group
 	return group
 }
 }
 
 

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

@@ -57,6 +57,7 @@ func TestGetExecutionQueueGroupsByActionGroup(t *testing.T) {
 	require.NotNil(t, defaultGroup)
 	require.NotNil(t, defaultGroup)
 
 
 	assert.Equal(t, int32(2), deployGroup.MaxConcurrent)
 	assert.Equal(t, int32(2), deployGroup.MaxConcurrent)
+	assert.Equal(t, int32(5), deployGroup.QueueSize)
 	assert.Equal(t, "&#128190;", deployGroup.Icon)
 	assert.Equal(t, "&#128190;", deployGroup.Icon)
 	assert.Equal(t, int32(2), deployGroup.ActiveCount)
 	assert.Equal(t, int32(2), deployGroup.ActiveCount)
 	assert.Equal(t, int32(1), deployGroup.QueuedCount)
 	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, gotStarted, "admin must receive ExecutionStarted for secret_action")
 	assert.True(t, gotFinished, "admin must receive ExecutionFinished 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
 			continue
 		}
 		}
 
 
-		if binding.IsOnDashboard {
+		if binding.IsOnConfiguredDashboard() {
 			continue
 			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.
 // ActionGroup defines shared limits and metadata for a set of actions.
 type ActionGroup struct {
 type ActionGroup struct {
 	MaxConcurrent int    `koanf:"maxConcurrent"`
 	MaxConcurrent int    `koanf:"maxConcurrent"`
+	QueueSize     int    `koanf:"queueSize"`
 	Icon          string `koanf:"icon"`
 	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)
 	return append(out, value)
 }
 }
 
 
+const defaultActionGroupQueueSize = 5
+
 func (cfg *Config) sanitizeActionGroups() {
 func (cfg *Config) sanitizeActionGroups() {
 	for _, group := range cfg.ActionGroups {
 	for _, group := range cfg.ActionGroups {
 		if group == nil {
 		if group == nil {
 			continue
 			continue
 		}
 		}
 
 
+		if group.QueueSize <= 0 {
+			group.QueueSize = defaultActionGroupQueueSize
+		}
+
 		group.Icon = lookupHTMLIcon(group.Icon, cfg.DefaultIconForActions)
 		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)
 	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) {
 func TestValidateReservedActionArgumentNamesAllowsNonReserved(t *testing.T) {
 	c := DefaultConfig()
 	c := DefaultConfig()
 	c.Actions = append(c.Actions, &Action{
 	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
 	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 {
 func typecheckActionArgument(arg *config.ActionArgument, value string, action *config.Action) error {
-	if arg.Type == "confirmation" {
+	if argumentSkipsValidation(arg) {
 		return nil
 		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")
 	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) {
 func TestParseCommandForReplacements(t *testing.T) {
 	tests := []struct {
 	tests := []struct {
 		name           string
 		name           string

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

@@ -48,11 +48,11 @@ var (
 )
 )
 
 
 type ActionBinding struct {
 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
 // 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 {
 func (e *Executor) finishIfConcurrencyBlocked(req *ExecutionRequest) bool {
+	if actionNeedsGroupLimit(req) {
+		return false
+	}
+
 	if stepConcurrencyCheck(req) {
 	if stepConcurrencyCheck(req) {
 		return false
 		return false
 	}
 	}
@@ -663,7 +667,11 @@ func (e *Executor) queueRequestAfterACL(req *ExecutionRequest, wg *sync.WaitGrou
 		return true, false
 		return true, false
 	}
 	}
 
 
-	e.queueRequest(req, wg)
+	if e.queueRequest(req, wg) {
+		e.finishExecChain(req)
+		return true, false
+	}
+
 	notifyListenersStarted(req)
 	notifyListenersStarted(req)
 
 
 	return false, true
 	return false, true
@@ -708,6 +716,10 @@ func getConcurrentCount(req *ExecutionRequest) int {
 }
 }
 
 
 func stepConcurrencyCheck(req *ExecutionRequest) bool {
 func stepConcurrencyCheck(req *ExecutionRequest) bool {
+	if actionNeedsGroupLimit(req) {
+		return true
+	}
+
 	concurrentCount := getConcurrentCount(req)
 	concurrentCount := getConcurrentCount(req)
 
 
 	// Note that the current execution is counted int the logs, so when checking we +1
 	// 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 (
 import (
 	"crypto/sha256"
 	"crypto/sha256"
 	"fmt"
 	"fmt"
-	"slices"
 
 
 	config "github.com/OliveTin/OliveTin/internal/config"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/OliveTin/OliveTin/internal/entities"
@@ -37,8 +36,8 @@ func (e *Executor) FindBindingWithNoEntity(action *config.Action) *ActionBinding
 }
 }
 
 
 type RebuildActionMapRequest struct {
 type RebuildActionMapRequest struct {
-	Cfg                   *config.Config
-	DashboardActionTitles []string
+	Cfg              *config.Config
+	dashboardTargets *dashboardTargetIndex
 }
 }
 
 
 func validateArgumentDefaults(cfg *config.Config) {
 func validateArgumentDefaults(cfg *config.Config) {
@@ -81,16 +80,10 @@ func (e *Executor) RebuildActionMap() {
 	clear(e.MapActionBindings)
 	clear(e.MapActionBindings)
 
 
 	req := &RebuildActionMapRequest{
 	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 {
 	for configOrder, action := range e.Cfg.Actions {
 		if action.Entity != "" {
 		if action.Entity != "" {
 			registerActionsFromEntities(e, configOrder, action.Entity, action, req)
 			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) {
 func registerAction(e *Executor, configOrder int, action *config.Action, req *RebuildActionMapRequest) {
 	bindingId := generateActionBindingId(action, "")
 	bindingId := generateActionBindingId(action, "")
 
 
 	e.MapActionBindings[bindingId] = &ActionBinding{
 	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)
 	virtualActionId := generateActionBindingId(tpl, ent.UniqueKey)
 
 
 	e.MapActionBindings[virtualActionId] = &ActionBinding{
 	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 {
 type groupLimit struct {
 	name          string
 	name          string
 	maxConcurrent int
 	maxConcurrent int
+	queueSize     int
 }
 }
 
 
 type queuedExecution struct {
 type queuedExecution struct {
@@ -45,7 +46,11 @@ func groupLimitFromConfig(cfg *config.Config, groupName string) (groupLimit, boo
 		return groupLimit{}, false
 		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 {
 func actionNeedsGroupLimit(req *ExecutionRequest) bool {
@@ -79,6 +84,64 @@ func (e *Executor) countActiveInGroupLocked(groupName string) int {
 	return count
 	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 {
 func logEntryIsActiveInGroup(logEntry *InternalLogEntry, groupName string) bool {
 	if inactiveLogEntry(logEntry) {
 	if inactiveLogEntry(logEntry) {
 		return false
 		return false
@@ -143,16 +206,26 @@ func firstFullGroupNameLocked(e *Executor, req *ExecutionRequest) string {
 	return ""
 	return ""
 }
 }
 
 
-func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) {
+func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) bool {
 	e.groupQueueMu.Lock()
 	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) {
 	req.mutateLogEntry(func(entry *InternalLogEntry) {
-		groupName = firstFullGroupNameLocked(e, req)
+		waitingForGroup = firstFullGroupNameLocked(e, req)
 		entry.Queued = true
 		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})
 	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{
 	log.WithFields(log.Fields{
 		"actionTitle": req.logEntry.ActionTitle,
 		"actionTitle": req.logEntry.ActionTitle,
-		"groupName":   groupName,
+		"groupName":   waitingForGroup,
 	}).Infof("Action queued due to action group concurrency limit")
 	}).Infof("Action queued due to action group concurrency limit")
+
+	return false
 }
 }
 
 
 func (e *Executor) drainGroupQueue() {
 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)
 	assert.False(t, snapshot.Queued)
 }
 }
 
 
-func TestPerActionConcurrencyBlocksSameBindingBeforeGroupQueue(t *testing.T) {
+func TestGroupedSameBindingQueuesWhenGroupFull(t *testing.T) {
 	t.Parallel()
 	t.Parallel()
 
 
 	action := &config.Action{
 	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(
 	e, cfg := testGroupExecutor(
 		[]*config.Action{action},
 		[]*config.Action{action},
 		map[string]*config.ActionGroup{
 		map[string]*config.ActionGroup{
-			"unity": {MaxConcurrent: 1},
+			"unity": {MaxConcurrent: 1, QueueSize: 5},
 		},
 		},
 	)
 	)
 	binding := e.FindBindingWithNoEntity(action)
 	binding := e.FindBindingWithNoEntity(action)
@@ -268,15 +267,117 @@ func TestPerActionConcurrencyBlocksSameBindingBeforeGroupQueue(t *testing.T) {
 		AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
 		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()
 	wg1.Wait()
 	wg2.Wait()
 	wg2.Wait()
 
 
 	snapshot, ok := e.SnapshotLog(tracking2)
 	snapshot, ok := e.SnapshotLog(tracking2)
 	require.True(t, ok)
 	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)
 	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) {
 func waitUntilExecutionStarted(t *testing.T, e *Executor, trackingID string) {
 	t.Helper()
 	t.Helper()
 
 
@@ -390,6 +491,79 @@ func TestStartActionAndWaitWaitsForQueuedExecution(t *testing.T) {
 	assert.Contains(t, snapshot.Output, "waited")
 	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) {
 func TestUnknownActionGroupReferenceWarnsAndSkipsLimit(t *testing.T) {
 	t.Parallel()
 	t.Parallel()
 
 

Некоторые файлы не были показаны из-за большого количества измененных файлов