feat: action group sizing
This commit is contained in:
parent
a3413c0bc3
commit
633d9ecd82
20
config.yaml
20
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.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
[#exec-on-demand]
|
[#exec-on-click]
|
||||||
= Execute on demand
|
= 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.1.tgz",
|
||||||
"integrity": "sha512-V1G/Ph9TbmEow+pKnupZRWQjdORR/TGGr3JVRZOWkomdJ/5N6GgLuKPgBDs7G0kZ0//9LL34AGOUzWe3K+umNA==",
|
"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",
|
"version": "11.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.6.tgz",
|
||||||
"integrity": "sha512-lja3F/iKVIvTa48mIwmrIeDcQUFZ0F0drvFvT8AwINOvbwnAzl/S/p8p2DxILZpWEUHRi1qewfWNIkMvhD3kKA==",
|
"integrity": "sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/devtools-types": "11.4.5",
|
"@intlify/devtools-types": "11.4.6",
|
||||||
"@intlify/message-compiler": "11.4.5",
|
"@intlify/message-compiler": "11.4.6",
|
||||||
"@intlify/shared": "11.4.5"
|
"@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",
|
"version": "11.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.6.tgz",
|
||||||
"integrity": "sha512-W5vydP9Yq3t82IyWqCM6aR0BTWCZrN5RAwjZEPpH8I2OQWp2RLy03Evh2ANZlSMhcvGAoyDg25k0so85Kwncpw==",
|
"integrity": "sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.4.5",
|
"@intlify/core-base": "11.4.6",
|
||||||
"@intlify/shared": "11.4.5"
|
"@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",
|
"version": "11.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.6.tgz",
|
||||||
"integrity": "sha512-IEOZiHtbQopyPc/Dz2M869lOlZYX1SdcniNJwphATDYHhovvIneEKf1EFF37DE7NAABZtza1FNtnwwqZWInfpw==",
|
"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",
|
"version": "11.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.6.tgz",
|
||||||
"integrity": "sha512-g/i5mtdUa9ia/8BaJ4w6ZRHgAXYQd9XyCaQPRMvsd8d5qmZwkjoTmHrNsI28Q/7I8h+2ijUkI4uEnnMCziKupQ==",
|
"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",
|
"version": "1.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.17.0.tgz",
|
||||||
"integrity": "sha512-FHZ11Y0Jwqw89LQ0wdmglzUIk5W9vhG7CyrbW86qTbnke6hTyO721aixu0QkZtsw8GYSKJqO6QPLoOOxi0ZUMQ==",
|
"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",
|
"version": "11.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
|
||||||
"integrity": "sha512-rm8YJ6RpjOrkcgS2GLrZwLvs/VbhxbTSuEspbyXDo233+fPK0OMFNLOj3fdQYVKdOgcpSfLW91JhbqgpkkcBWA==",
|
"integrity": "sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.4.5",
|
"@intlify/core-base": "11.4.6",
|
||||||
"@intlify/devtools-types": "11.4.5",
|
"@intlify/devtools-types": "11.4.6",
|
||||||
"@intlify/shared": "11.4.5",
|
"@intlify/shared": "11.4.6",
|
||||||
"@vue/devtools-api": "^6.5.0"
|
"@vue/devtools-api": "^6.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
@ -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) => {
|
router.beforeEach((to) => {
|
||||||
// Check if user is authenticated for protected routes
|
|
||||||
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')
|
return '/login'
|
||||||
} else {
|
|
||||||
next()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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">
|
<button
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
v-for="dashboard in backToDashboards"
|
||||||
<path fill="currentColor" d="M8 6v12l8-6z" />
|
:key="dashboard.path"
|
||||||
</svg>
|
@click="goToDashboard(dashboard.path)"
|
||||||
Start
|
: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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,43 @@
|
||||||
<template>
|
<template>
|
||||||
<Section :title="'Execution Results: ' + title" id = "execution-results-popup">
|
<Section id="execution-results-popup">
|
||||||
<template #toolbar>
|
<template #title>
|
||||||
<router-link v-if="actionId" :to="`/action/${actionId}`" title="View all executions for this action" class="button neutral">
|
<span class="section-title-with-icon">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
Execution Results:
|
||||||
<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"/>
|
<router-link
|
||||||
</svg>
|
v-if="actionId"
|
||||||
Action Details
|
: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>
|
</router-link>
|
||||||
<button @click="toggleSize" title="Toggle dialog size" class = "neutral">
|
<template v-else>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
<LogActionTitle v-if="logEntry" :action-title="title" :justification="logEntry.justification" />
|
||||||
<path fill="currentColor"
|
<span v-else>{{ title }}</span>
|
||||||
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" />
|
</template>
|
||||||
</svg>
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #toolbar>
|
||||||
|
<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 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 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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
<ActionGroupLimitsLabel
|
||||||
v-if="actionGroup.maxConcurrent > 0"
|
:max-concurrent="actionGroup.maxConcurrent"
|
||||||
class="queue-action-group-limit annotation"
|
:queue-size="actionGroup.queueSize"
|
||||||
>
|
/>
|
||||||
{{ t('logs.queue-group-limit', { max: actionGroup.maxConcurrent, queued: actionGroup.queuedCount }) }}
|
|
||||||
</span>
|
|
||||||
</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;
|
||||||
|
|
|
||||||
|
|
@ -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": "运行中",
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 {
|
||||||
|
|
@ -461,6 +487,7 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
|
||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -532,6 +559,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, "💾", deployGroup.Icon)
|
assert.Equal(t, "💾", 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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if binding.IsOnDashboard {
|
if binding.IsOnConfiguredDashboard() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,28 @@ func TestSanitizeActionGroupsResolvesIcons(t *testing.T) {
|
||||||
assert.Equal(t, "💾", c.ActionGroups["backup-jobs"].Icon)
|
assert.Equal(t, "💾", 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{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ type ActionBinding struct {
|
||||||
Action *config.Action
|
Action *config.Action
|
||||||
Entity *entities.Entity
|
Entity *entities.Entity
|
||||||
ConfigOrder int
|
ConfigOrder int
|
||||||
IsOnDashboard bool
|
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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -38,7 +37,7 @@ func (e *Executor) FindBindingWithNoEntity(action *config.Action) *ActionBinding
|
||||||
|
|
||||||
type RebuildActionMapRequest struct {
|
type RebuildActionMapRequest struct {
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
DashboardActionTitles []string
|
dashboardTargets *dashboardTargetIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateArgumentDefaults(cfg *config.Config) {
|
func validateArgumentDefaults(cfg *config.Config) {
|
||||||
|
|
@ -82,15 +81,9 @@ func (e *Executor) RebuildActionMap() {
|
||||||
|
|
||||||
req := &RebuildActionMapRequest{
|
req := &RebuildActionMapRequest{
|
||||||
Cfg: e.Cfg,
|
Cfg: e.Cfg,
|
||||||
DashboardActionTitles: make([]string, 0),
|
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,33 +99,6 @@ 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, "")
|
||||||
|
|
||||||
|
|
@ -141,7 +107,7 @@ func registerAction(e *Executor, configOrder int, action *config.Action, req *Re
|
||||||
Action: action,
|
Action: action,
|
||||||
Entity: nil,
|
Entity: nil,
|
||||||
ConfigOrder: configOrder,
|
ConfigOrder: configOrder,
|
||||||
IsOnDashboard: slices.Contains(req.DashboardActionTitles, action.Title),
|
OnDashboards: resolveOnDashboards(req.dashboardTargets, action.Title, ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,7 +125,7 @@ func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action,
|
||||||
Action: tpl,
|
Action: tpl,
|
||||||
Entity: ent,
|
Entity: ent,
|
||||||
ConfigOrder: configOrder,
|
ConfigOrder: configOrder,
|
||||||
IsOnDashboard: slices.Contains(req.DashboardActionTitles, tpl.Title),
|
OnDashboards: resolveOnDashboards(req.dashboardTargets, tpl.Title, ent.UniqueKey),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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.QueuedForGroup = waitingForGroup
|
||||||
entry.Output = fmt.Sprintf("Queued waiting for action group %q", groupName)
|
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() {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
Title: "Single binding grouped",
|
||||||
Shell: "sleep 1",
|
Shell: "sleep 1",
|
||||||
MaxConcurrent: 1,
|
|
||||||
Groups: []string{"unity"},
|
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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue