feat: action group size (#1051)
This commit is contained in:
commit
1dd39742be
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
|
||||
logLevel: "INFO"
|
||||
|
||||
# Action groups share a concurrency limit across multiple actions. When the
|
||||
# limit is reached, additional requests are queued and run in order.
|
||||
# Docs: https://docs.olivetin.app/action_customization/concurrency.html#action-groups
|
||||
actionGroups:
|
||||
backup-jobs:
|
||||
maxConcurrent: 1
|
||||
icon: backup
|
||||
|
||||
# Actions are commands that are executed by OliveTin, and normally show up as
|
||||
# buttons on the WebUI.
|
||||
#
|
||||
|
|
@ -266,6 +258,7 @@ actions:
|
|||
timeout: 300
|
||||
icon: logs
|
||||
onclick: execution-dialog
|
||||
groups: [ con2queue10 ]
|
||||
execOnCron:
|
||||
- "@hourly"
|
||||
|
||||
|
|
@ -310,6 +303,17 @@ entities:
|
|||
- file: entities/containers.json
|
||||
name: container
|
||||
|
||||
# Action groups share a concurrency limit across multiple actions. When the
|
||||
# limit is reached, additional requests are queued and run in order.
|
||||
# Docs: https://docs.olivetin.app/action_customization/concurrency.html#action-groups
|
||||
actionGroups:
|
||||
backup-jobs:
|
||||
maxConcurrent: 1
|
||||
icon: backup
|
||||
con2queue10:
|
||||
maxConcurrent: 2
|
||||
queueSize: 10
|
||||
|
||||
# Dashboards are a way of taking actions from the default "actions" view, and
|
||||
# organizing them into groups - either into folders, or fieldsets.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
* Action Execution
|
||||
** xref:action_execution/create_your_first.adoc[Create your first action]
|
||||
** xref:action_execution/shellvsexec.adoc[Shell vs Exec]
|
||||
** xref:action_execution/ondemand.adoc[Execute on demand]
|
||||
** xref:action_execution/ondemand.adoc[Execute on click]
|
||||
** xref:action_execution/oncron.adoc[Execute on schedule (cron)]
|
||||
** xref:action_execution/onstartup.adoc[Execute on startup]
|
||||
** xref:action_execution/onwebhook.adoc[Execute on webhook]
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ Use `actionGroups` to define a shared limit, and assign actions to a group with
|
|||
actionGroups:
|
||||
unity:
|
||||
maxConcurrent: 1
|
||||
queueSize: 5
|
||||
|
||||
actions:
|
||||
- title: Unity Android Build
|
||||
|
|
@ -51,8 +52,21 @@ actions:
|
|||
groups: [ unity ]
|
||||
----
|
||||
|
||||
When the group limit is reached, additional requests are queued automatically and run in order when a slot becomes free. Queued executions appear in the logs with a queued status.
|
||||
=== maxConcurrent vs queueSize
|
||||
|
||||
Per-action `maxConcurrent` still applies separately. If the same action binding is started twice while one is already running, the second request is blocked immediately (not queued).
|
||||
Action groups define two related limits:
|
||||
|
||||
* `maxConcurrent` -- how many executions in the group may *run at the same time*. When every slot is in use, new requests wait for a free slot instead of running immediately.
|
||||
* `queueSize` -- how many executions may *wait in the queue* for a slot. If the group is full and the queue already holds this many waiting executions, additional requests are blocked (the same "blocked" status as per-action concurrency).
|
||||
|
||||
For example, with `maxConcurrent: 1` and `queueSize: 5`, OliveTin can have one execution running and up to five more waiting. A seventh request while those six are still outstanding is blocked.
|
||||
|
||||
`queueSize` defaults to `5` when omitted.
|
||||
|
||||
When the group limit is reached but the queue is not full, additional requests are queued automatically and run in order when a slot becomes free. Queued executions appear in the logs with a queued status.
|
||||
|
||||
Per-action `maxConcurrent` still applies to actions that are not in a group. For actions in a group, concurrency is governed by the group `maxConcurrent` and `queueSize` settings instead. A second request for the same action may run concurrently when the group has spare capacity, or join the queue when the group is full.
|
||||
|
||||
Actions that are not in a group are never queued. They only use their own `maxConcurrent` limit (default `1`).
|
||||
|
||||
The queue is held in memory. If OliveTin restarts while actions are queued, those queued requests are not preserved.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
[#exec-on-demand]
|
||||
= Execute on demand
|
||||
[#exec-on-click]
|
||||
= Execute on click
|
||||
|
||||
This is the default, meaning that the action shows up on a dashboard, and at the moment cannot be changed.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ The `Json` template function encodes a value as a JSON string. Pipe a template v
|
|||
----
|
||||
actions:
|
||||
- title: curl my knx thing
|
||||
shell: curl --json https://knx.example.com/v1/group/global_on/write -d '{{ .Arguments | Json }}'
|
||||
shell: curl --json '{{ .Arguments | Json }}' https://knx.example.com/v1/group/global_on/write
|
||||
entity: light
|
||||
arguments:
|
||||
- name: value
|
||||
|
|
|
|||
|
|
@ -11,19 +11,19 @@
|
|||
"dependencies": {
|
||||
"@connectrpc/connect": "^2.1.2",
|
||||
"@connectrpc/connect-web": "^2.1.2",
|
||||
"@hugeicons/core-free-icons": "^4.2.0",
|
||||
"@hugeicons/core-free-icons": "^4.2.1",
|
||||
"@hugeicons/vue": "^1.0.6",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"iconify-icon": "^3.0.2",
|
||||
"picocrank": "^1.16.0",
|
||||
"picocrank": "^1.17.0",
|
||||
"standard": "^17.1.2",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.0.16",
|
||||
"vue": "^3.5.38",
|
||||
"vue-i18n": "^11.4.5",
|
||||
"vue-i18n": "^11.4.6",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -941,9 +941,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@hugeicons/core-free-icons": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.0.tgz",
|
||||
"integrity": "sha512-V1G/Ph9TbmEow+pKnupZRWQjdORR/TGGr3JVRZOWkomdJ/5N6GgLuKPgBDs7G0kZ0//9LL34AGOUzWe3K+umNA==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.1.tgz",
|
||||
"integrity": "sha512-75jYZKYyA9VwS35YRmmGUGzFedbY+Fl0Vxx5FzXR2CGDlIhNRumFeVqaaKoClf2MeYEJwPAVMEL9RwCYtOgnSw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hugeicons/vue": {
|
||||
|
|
@ -997,14 +997,14 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "11.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.5.tgz",
|
||||
"integrity": "sha512-lja3F/iKVIvTa48mIwmrIeDcQUFZ0F0drvFvT8AwINOvbwnAzl/S/p8p2DxILZpWEUHRi1qewfWNIkMvhD3kKA==",
|
||||
"version": "11.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.6.tgz",
|
||||
"integrity": "sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/devtools-types": "11.4.5",
|
||||
"@intlify/message-compiler": "11.4.5",
|
||||
"@intlify/shared": "11.4.5"
|
||||
"@intlify/devtools-types": "11.4.6",
|
||||
"@intlify/message-compiler": "11.4.6",
|
||||
"@intlify/shared": "11.4.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
|
|
@ -1014,13 +1014,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@intlify/devtools-types": {
|
||||
"version": "11.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.5.tgz",
|
||||
"integrity": "sha512-W5vydP9Yq3t82IyWqCM6aR0BTWCZrN5RAwjZEPpH8I2OQWp2RLy03Evh2ANZlSMhcvGAoyDg25k0so85Kwncpw==",
|
||||
"version": "11.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.6.tgz",
|
||||
"integrity": "sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.4.5",
|
||||
"@intlify/shared": "11.4.5"
|
||||
"@intlify/core-base": "11.4.6",
|
||||
"@intlify/shared": "11.4.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
|
|
@ -1030,12 +1030,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "11.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.5.tgz",
|
||||
"integrity": "sha512-IEOZiHtbQopyPc/Dz2M869lOlZYX1SdcniNJwphATDYHhovvIneEKf1EFF37DE7NAABZtza1FNtnwwqZWInfpw==",
|
||||
"version": "11.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.6.tgz",
|
||||
"integrity": "sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "11.4.5",
|
||||
"@intlify/shared": "11.4.6",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1046,9 +1046,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "11.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.5.tgz",
|
||||
"integrity": "sha512-g/i5mtdUa9ia/8BaJ4w6ZRHgAXYQd9XyCaQPRMvsd8d5qmZwkjoTmHrNsI28Q/7I8h+2ijUkI4uEnnMCziKupQ==",
|
||||
"version": "11.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.6.tgz",
|
||||
"integrity": "sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
|
|
@ -5186,9 +5186,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picocrank": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.16.0.tgz",
|
||||
"integrity": "sha512-FHZ11Y0Jwqw89LQ0wdmglzUIk5W9vhG7CyrbW86qTbnke6hTyO721aixu0QkZtsw8GYSKJqO6QPLoOOxi0ZUMQ==",
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.17.0.tgz",
|
||||
"integrity": "sha512-EIUSI26elt6ulloN74CT2sX5tBxS2wjdV4A+eXHYNtHbH0hM2iKkYzpKKWOEr+yUTfrvjah6VuqieedFw/9R0w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@hugeicons/core-free-icons": "^4.1.1",
|
||||
|
|
@ -6900,14 +6900,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "11.4.5",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.5.tgz",
|
||||
"integrity": "sha512-rm8YJ6RpjOrkcgS2GLrZwLvs/VbhxbTSuEspbyXDo233+fPK0OMFNLOj3fdQYVKdOgcpSfLW91JhbqgpkkcBWA==",
|
||||
"version": "11.4.6",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
|
||||
"integrity": "sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.4.5",
|
||||
"@intlify/devtools-types": "11.4.5",
|
||||
"@intlify/shared": "11.4.5",
|
||||
"@intlify/core-base": "11.4.6",
|
||||
"@intlify/devtools-types": "11.4.6",
|
||||
"@intlify/shared": "11.4.6",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -24,19 +24,19 @@
|
|||
"dependencies": {
|
||||
"@connectrpc/connect": "^2.1.2",
|
||||
"@connectrpc/connect-web": "^2.1.2",
|
||||
"@hugeicons/core-free-icons": "^4.2.0",
|
||||
"@hugeicons/core-free-icons": "^4.2.1",
|
||||
"@hugeicons/vue": "^1.0.6",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"iconify-icon": "^3.0.2",
|
||||
"picocrank": "^1.16.0",
|
||||
"picocrank": "^1.17.0",
|
||||
"standard": "^17.1.2",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.0.16",
|
||||
"vue": "^3.5.38",
|
||||
"vue-i18n": "^11.4.5",
|
||||
"vue-i18n": "^11.4.6",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
|
|||
* @generated from field: bool has_queued_instance = 18;
|
||||
*/
|
||||
hasQueuedInstance: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated olivetin.api.v1.ActionGroupMembership groups = 19;
|
||||
*/
|
||||
groups: ActionGroupMembership[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -113,6 +118,32 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
|
|||
*/
|
||||
export declare const ActionSchema: GenMessage<Action>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.ActionGroupMembership
|
||||
*/
|
||||
export declare type ActionGroupMembership = Message<"olivetin.api.v1.ActionGroupMembership"> & {
|
||||
/**
|
||||
* @generated from field: string name = 1;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 max_concurrent = 2;
|
||||
*/
|
||||
maxConcurrent: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 queue_size = 3;
|
||||
*/
|
||||
queueSize: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message olivetin.api.v1.ActionGroupMembership.
|
||||
* Use `create(ActionGroupMembershipSchema)` to create a new message.
|
||||
*/
|
||||
export declare const ActionGroupMembershipSchema: GenMessage<ActionGroupMembership>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.ActionWebhookExecHint
|
||||
*/
|
||||
|
|
@ -912,6 +943,11 @@ export declare type ExecutionQueueGroup = Message<"olivetin.api.v1.ExecutionQueu
|
|||
* @generated from field: int32 queued_count = 6;
|
||||
*/
|
||||
queuedCount: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 queue_size = 7;
|
||||
*/
|
||||
queueSize: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1046,6 +1082,37 @@ export declare type ExecutionStatusRequest = Message<"olivetin.api.v1.ExecutionS
|
|||
*/
|
||||
export declare const ExecutionStatusRequestSchema: GenMessage<ExecutionStatusRequest>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.DashboardNavigationTarget
|
||||
*/
|
||||
export declare type DashboardNavigationTarget = Message<"olivetin.api.v1.DashboardNavigationTarget"> & {
|
||||
/**
|
||||
* @generated from field: string title = 1;
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string entity_type = 2;
|
||||
*/
|
||||
entityType: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string entity_key = 3;
|
||||
*/
|
||||
entityKey: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string path = 4;
|
||||
*/
|
||||
path: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message olivetin.api.v1.DashboardNavigationTarget.
|
||||
* Use `create(DashboardNavigationTargetSchema)` to create a new message.
|
||||
*/
|
||||
export declare const DashboardNavigationTargetSchema: GenMessage<DashboardNavigationTarget>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.ExecutionStatusResponse
|
||||
*/
|
||||
|
|
@ -1054,6 +1121,11 @@ export declare type ExecutionStatusResponse = Message<"olivetin.api.v1.Execution
|
|||
* @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
|
||||
*/
|
||||
logEntry?: LogEntry | undefined;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated olivetin.api.v1.DashboardNavigationTarget back_to_dashboards = 2;
|
||||
*/
|
||||
backToDashboards: DashboardNavigationTarget[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1800,6 +1872,11 @@ export declare type GetActionBindingResponse = Message<"olivetin.api.v1.GetActio
|
|||
* @generated from field: olivetin.api.v1.Action action = 1;
|
||||
*/
|
||||
action?: Action | undefined;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated olivetin.api.v1.DashboardNavigationTarget back_to_dashboards = 2;
|
||||
*/
|
||||
backToDashboards: DashboardNavigationTarget[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
router.beforeEach((to, from, next) => {
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta && to.meta.title) {
|
||||
const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
|
||||
document.title = to.meta.title + " - " + pageTitle
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
// Navigation guard for authentication (if needed)
|
||||
router.beforeEach((to, from, next) => {
|
||||
// Check if user is authenticated for protected routes
|
||||
const isAuthenticated = window.isAuthenticated || true // Default to true for now
|
||||
router.beforeEach((to) => {
|
||||
const isAuthenticated = window.isAuthenticated ?? false
|
||||
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
return '/login'
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<Section :title="'Action Details: ' + actionTitle" :padding="false">
|
||||
<Section :padding="false">
|
||||
<template #title>
|
||||
<span class="section-title-with-icon">
|
||||
Action Details:
|
||||
<ActionIconGlyph v-if="action" class="action-title-icon" :glyph="action.icon" />
|
||||
{{ actionTitle }}
|
||||
</span>
|
||||
</template>
|
||||
<template #toolbar>
|
||||
<div class="action-details-toolbar">
|
||||
<button v-if="action" @click="startAction" title="Start this action" class="button neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8 6v12l8-6z" />
|
||||
</svg>
|
||||
Start
|
||||
<button
|
||||
v-for="dashboard in backToDashboards"
|
||||
:key="dashboard.path"
|
||||
@click="goToDashboard(dashboard.path)"
|
||||
:title="'Back to ' + dashboard.title"
|
||||
class="button neutral"
|
||||
>
|
||||
<HugeiconsIcon :icon="DashboardSquare01Icon" />
|
||||
{{ dashboard.title }}
|
||||
</button>
|
||||
<button v-if="action" @click="startAction" title="Run this action" class="button neutral">
|
||||
<HugeiconsIcon :icon="WorkoutRunIcon" />
|
||||
Run
|
||||
</button>
|
||||
<router-link
|
||||
v-if="action"
|
||||
|
|
@ -14,7 +29,7 @@
|
|||
class="button neutral"
|
||||
title="View configured automatic triggers and on-demand execution"
|
||||
>
|
||||
Execution conditions
|
||||
Execution conditions ({{ executionConditionCount }})
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -22,18 +37,30 @@
|
|||
<div class = "flex-row padding" v-if="action">
|
||||
<div class = "fg1">
|
||||
<dl>
|
||||
<dt>Title</dt>
|
||||
<dd>{{ action.title }}</dd>
|
||||
<dt>Timeout</dt>
|
||||
<dd>{{ action.timeout }} seconds</dd>
|
||||
|
||||
<template v-if="actionGroups.length > 0">
|
||||
<dt>
|
||||
<router-link :to="{ name: 'LogsQueue' }" class="action-groups-link">Action groups</router-link>
|
||||
</dt>
|
||||
<dd>
|
||||
<ul class="action-group-list">
|
||||
<li v-for="group in actionGroups" :key="group.name" class="action-group-row">
|
||||
<router-link :to="{ name: 'LogsQueue' }" class="action-groups-link action-group-name">{{ group.name }}</router-link><template v-if="group.maxConcurrent > 0 && group.queueSize > 0"> - </template><ActionGroupLimitsLabel
|
||||
:max-concurrent="group.maxConcurrent"
|
||||
:queue-size="group.queueSize"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<p class = "fg1">
|
||||
Execution history for this action. You can filter by execution tracking ID.
|
||||
</p>
|
||||
</div>
|
||||
<div style = "align-self: start; text-align: right;">
|
||||
<ActionIconGlyph class="icon" :glyph="action.icon" />
|
||||
|
||||
<div class="filter-container">
|
||||
<label class="input-with-icons">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
|
|
@ -106,14 +133,19 @@ import Pagination from 'picocrank/vue/components/Pagination.vue'
|
|||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
||||
import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
|
||||
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||
import { DashboardSquare01Icon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
|
||||
import { requestReconnectNow } from '../../../js/websocket.js'
|
||||
import { needsArgumentForm } from '../utils/needsArgumentForm.js'
|
||||
import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
|
||||
import { countExecutionConditions } from '../utils/executionConditionCount.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const logs = ref([])
|
||||
const action = ref(null)
|
||||
const backToDashboards = ref([])
|
||||
const actionTitle = ref('Action Details')
|
||||
const searchText = ref('')
|
||||
const pageSize = ref(10)
|
||||
|
|
@ -134,6 +166,10 @@ const filteredLogs = computed(() => {
|
|||
)
|
||||
})
|
||||
|
||||
const executionConditionCount = computed(() => countExecutionConditions(action.value))
|
||||
|
||||
const actionGroups = computed(() => action.value?.groups ?? [])
|
||||
|
||||
async function fetchActionLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
|
|
@ -171,6 +207,7 @@ async function fetchAction() {
|
|||
}
|
||||
const response = await window.client.getActionBinding(args)
|
||||
action.value = response.action
|
||||
backToDashboards.value = (response.backToDashboards || []).slice(0, 3)
|
||||
actionTitle.value = action.value?.title || 'Unknown Action'
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch action:', err)
|
||||
|
|
@ -178,8 +215,13 @@ async function fetchAction() {
|
|||
}
|
||||
}
|
||||
|
||||
function goToDashboard(path) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
action.value = null
|
||||
backToDashboards.value = []
|
||||
actionTitle.value = 'Action Details'
|
||||
logs.value = []
|
||||
totalCount.value = 0
|
||||
|
|
@ -346,7 +388,13 @@ onUnmounted(() => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon {
|
||||
.section-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-title-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
|
|
@ -472,4 +520,28 @@ onUnmounted(() => {
|
|||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-group-list {
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.action-group-row {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.action-group-name {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.action-groups-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-groups-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--link-color, #007bff);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
</p>
|
||||
|
||||
<h3 class="exec-type-heading">
|
||||
On demand
|
||||
On click
|
||||
<a class="doc-link" :href="execConditionDocs.onDemand" target="_blank" rel="noopener noreferrer">Documentation</a>
|
||||
</h3>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,43 @@
|
|||
<template>
|
||||
<Section :title="'Execution Results: ' + title" id = "execution-results-popup">
|
||||
<Section id="execution-results-popup">
|
||||
<template #title>
|
||||
<span class="section-title-with-icon">
|
||||
Execution Results:
|
||||
<router-link
|
||||
v-if="actionId"
|
||||
:to="`/action/${actionId}`"
|
||||
class="action-details-title-link"
|
||||
:title="titleTooltip"
|
||||
>
|
||||
<ActionIconGlyph class="action-title-icon" :glyph="icon" />
|
||||
<LogActionTitle v-if="logEntry" :action-title="title" :justification="logEntry.justification" />
|
||||
<span v-else>{{ title }}</span>
|
||||
</router-link>
|
||||
<template v-else>
|
||||
<LogActionTitle v-if="logEntry" :action-title="title" :justification="logEntry.justification" />
|
||||
<span v-else>{{ title }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
<template #toolbar>
|
||||
<router-link v-if="actionId" :to="`/action/${actionId}`" title="View all executions for this action" class="button neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.31-8.86c-1.77-.45-2.34-.94-2.34-1.67 0-.84.79-1.43 2.1-1.43 1.38 0 1.9.66 1.94 1.64h1.71c-.05-1.34-.87-2.57-2.49-2.97V5H10.9v1.69c-1.51.32-2.72 1.3-2.72 2.81 0 1.79 1.49 2.69 3.66 3.21 1.95.46 2.34 1.22 2.34 1.8 0 .53-.39 1.39-2.1 1.39-1.6 0-2.05-.56-2.13-1.45H8.04c.08 1.5 1.18 2.37 2.82 2.69V19h2.34v-1.63c1.65-.35 2.48-1.24 2.48-2.77-.01-1.88-1.51-2.87-3.7-3.23z"/>
|
||||
</svg>
|
||||
Action Details
|
||||
</router-link>
|
||||
<button @click="toggleSize" title="Toggle dialog size" class = "neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor"
|
||||
d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z" />
|
||||
</svg>
|
||||
<button
|
||||
v-for="dashboard in backToDashboards"
|
||||
:key="dashboard.path"
|
||||
@click="goToDashboard(dashboard.path)"
|
||||
:title="'Back to ' + dashboard.title"
|
||||
class="button neutral"
|
||||
>
|
||||
<HugeiconsIcon :icon="DashboardSquare01Icon" />
|
||||
{{ dashboard.title }}
|
||||
</button>
|
||||
<button v-if="backToDashboards.length === 0" @click="goBack" title="Go back" class="button neutral">
|
||||
<HugeiconsIcon :icon="ArrowLeftIcon" />
|
||||
Back
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="logEntry" class = "flex-row">
|
||||
<dl class = "fg1">
|
||||
<dt>Action</dt>
|
||||
<dd>
|
||||
<LogActionTitle :action-title="title" :justification="logEntry.justification" />
|
||||
</dd>
|
||||
|
||||
<dt>Duration</dt>
|
||||
<dd><span v-html="duration"></span></dd>
|
||||
|
||||
|
|
@ -30,7 +46,6 @@
|
|||
<ActionStatusDisplay :log-entry="logEntry" :link-queued-status="true" />
|
||||
</dd>
|
||||
</dl>
|
||||
<ActionIconGlyph class="icon" role="img" :glyph="icon" style="align-self: start" />
|
||||
</div>
|
||||
|
||||
<div v-if="notFound" class="error-message padded-content">
|
||||
|
|
@ -40,16 +55,24 @@
|
|||
<router-link to="/logs">View all logs</router-link> or <router-link to="/">return to home</router-link>.
|
||||
</div>
|
||||
|
||||
<div ref="xtermOutput"></div>
|
||||
<div class="xterm-output-container">
|
||||
<div class="xterm-overlay-toolbar">
|
||||
<button type="button" class="xterm-overlay-button" @click="copyOutput" title="Copy to clipboard">
|
||||
<HugeiconsIcon :icon="Copy01Icon" />
|
||||
</button>
|
||||
<button type="button" class="xterm-overlay-button" @click="toggleSize" title="Toggle fullscreen">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor"
|
||||
d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="xtermOutput"></div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div class="flex-row g1 buttons padded-content">
|
||||
<button @click="goBack" title="Go back">
|
||||
<HugeiconsIcon :icon="ArrowLeftIcon" />
|
||||
Back
|
||||
</button>
|
||||
|
||||
<div class = "fg1" />
|
||||
|
||||
<button :disabled="!canRerun" @click="rerunAction" title="Rerun">
|
||||
|
|
@ -73,7 +96,7 @@ import LogActionTitle from '../components/LogActionTitle.vue'
|
|||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
import { OutputTerminal } from '../../../js/OutputTerminal.js'
|
||||
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||
import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons'
|
||||
import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon, DashboardSquare01Icon, Copy01Icon } from '@hugeicons/core-free-icons'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { buttonResults } from '../stores/buttonResults'
|
||||
import { requestReconnectNow } from '../../../js/websocket.js'
|
||||
|
|
@ -104,6 +127,7 @@ const logEntry = ref(null)
|
|||
const canRerun = ref(false)
|
||||
const canKill = ref(false)
|
||||
const actionId = ref('')
|
||||
const backToDashboards = ref([])
|
||||
const notFound = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
|
|
@ -134,6 +158,19 @@ function toggleSize() {
|
|||
}
|
||||
}
|
||||
|
||||
async function copyOutput() {
|
||||
const text = terminal?.getBufferAsString?.() || logEntry.value?.output || ''
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy execution output:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
executionSeconds.value = 0
|
||||
executionTrackingId.value = 'notset'
|
||||
|
|
@ -149,6 +186,7 @@ async function reset() {
|
|||
canRerun.value = false
|
||||
canKill.value = false
|
||||
logEntry.value = null
|
||||
backToDashboards.value = []
|
||||
notFound.value = false
|
||||
errorMessage.value = ''
|
||||
|
||||
|
|
@ -239,15 +277,16 @@ async function fetchExecutionResult(executionTrackingIdParam) {
|
|||
executionTrackingId.value = executionTrackingIdParam
|
||||
notFound.value = false
|
||||
errorMessage.value = ''
|
||||
backToDashboards.value = []
|
||||
|
||||
const executionStatusArgs = {
|
||||
executionTrackingId: executionTrackingId.value
|
||||
}
|
||||
|
||||
try {
|
||||
const logEntryResult = await window.client.executionStatus(executionStatusArgs)
|
||||
const executionStatusResult = await window.client.executionStatus(executionStatusArgs)
|
||||
|
||||
await renderExecutionResult(logEntryResult)
|
||||
await renderExecutionResult(executionStatusResult)
|
||||
} catch (err) {
|
||||
// Check if it's a "not found" error (404 or similar)
|
||||
if (err.status === 404 || err.code === 'NotFound' || err.message?.includes('not found')) {
|
||||
|
|
@ -286,6 +325,9 @@ function updateDuration(logEntryParam) {
|
|||
|
||||
async function renderExecutionResult(res) {
|
||||
logEntry.value = res.logEntry
|
||||
if (res.backToDashboards) {
|
||||
backToDashboards.value = res.backToDashboards.slice(0, 3)
|
||||
}
|
||||
|
||||
// Clear ticker
|
||||
if (executionTicker) {
|
||||
|
|
@ -343,6 +385,10 @@ function goBack() {
|
|||
router.back()
|
||||
}
|
||||
|
||||
function goToDashboard(path) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('fullscreenchange', (e) => {
|
||||
setTimeout(() => { // Wait for the DOM to settle
|
||||
|
|
@ -390,6 +436,65 @@ defineExpose({
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-title-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.action-details-title-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-details-title-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.xterm-output-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm-overlay-toolbar {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.xterm-overlay-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(30, 30, 30, 0.85);
|
||||
color: #f0f0f0;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xterm-overlay-button:hover {
|
||||
background: rgba(50, 50, 50, 0.95);
|
||||
border-color: rgba(255, 255, 255, 0.45);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.xterm-overlay-button:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.action-history-link {
|
||||
color: var(--link-color, #007bff);
|
||||
text-decoration: none;
|
||||
|
|
|
|||
|
|
@ -281,13 +281,6 @@ onUnmounted(() => {
|
|||
fetchTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (fetchTimer) {
|
||||
clearTimeout(fetchTimer)
|
||||
fetchTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -27,12 +27,10 @@
|
|||
<ActionIconGlyph class="icon" :glyph="actionGroup.icon" />
|
||||
<h2>{{ displayActionGroupName(actionGroup.name) }}</h2>
|
||||
</div>
|
||||
<span
|
||||
v-if="actionGroup.maxConcurrent > 0"
|
||||
class="queue-action-group-limit annotation"
|
||||
>
|
||||
{{ t('logs.queue-group-limit', { max: actionGroup.maxConcurrent, queued: actionGroup.queuedCount }) }}
|
||||
</span>
|
||||
<ActionGroupLimitsLabel
|
||||
:max-concurrent="actionGroup.maxConcurrent"
|
||||
:queue-size="actionGroup.queueSize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="section-content">
|
||||
|
|
@ -95,6 +93,7 @@ import Section from 'picocrank/vue/components/Section.vue'
|
|||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
||||
import LogActionTitle from '../components/LogActionTitle.vue'
|
||||
import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
|
||||
|
||||
|
|
@ -352,10 +351,6 @@ onUnmounted(() => {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.queue-action-group-limit {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,16 @@ export default defineConfig({
|
|||
}),
|
||||
vue(),
|
||||
],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
onLog (level, log, defaultHandler) {
|
||||
if (log.code === 'INVALID_ANNOTATION') {
|
||||
return
|
||||
}
|
||||
defaultHandler(level, log)
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export async function waitForExecutionComplete(timeoutMs = DEFAULT_UI_WAIT_MS) {
|
|||
try {
|
||||
const statusElement = await webdriver.findElement(executionDialogStatusBy)
|
||||
const statusText = await statusElement.getText()
|
||||
return !statusText.includes('Still running')
|
||||
return !statusText.includes('Still running') && !statusText.includes('Queued')
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,20 +13,40 @@ import {
|
|||
waitForExecutionComplete,
|
||||
} from '../../lib/elements.js'
|
||||
|
||||
async function clickBackFromLogsPage() {
|
||||
const goBackButtons = await webdriver.findElements(By.css('button[title="Go back"]'))
|
||||
if (goBackButtons.length > 0) {
|
||||
await goBackButtons[0].click()
|
||||
return 'history'
|
||||
}
|
||||
|
||||
const dashboardBackButtons = await webdriver.findElements(By.css('button[title^="Back to "]'))
|
||||
if (dashboardBackButtons.length > 0) {
|
||||
await dashboardBackButtons[0].click()
|
||||
return 'dashboard'
|
||||
}
|
||||
|
||||
throw new Error('No back button found on execution logs page')
|
||||
}
|
||||
|
||||
async function ensureOnDashboard() {
|
||||
let url = await webdriver.getCurrentUrl()
|
||||
|
||||
if (url.includes('/logs/')) {
|
||||
const backButton = await webdriver.findElement(By.css('button[title="Go back"]'))
|
||||
await backButton.click()
|
||||
await webdriver.wait(
|
||||
new Condition('wait for argument form after logs back', async () => {
|
||||
const currentUrl = await webdriver.getCurrentUrl()
|
||||
return currentUrl.includes('/argumentForm')
|
||||
}),
|
||||
DEFAULT_UI_WAIT_MS
|
||||
)
|
||||
url = await webdriver.getCurrentUrl()
|
||||
const backType = await clickBackFromLogsPage()
|
||||
if (backType === 'history') {
|
||||
await webdriver.wait(
|
||||
new Condition('wait for argument form after logs back', async () => {
|
||||
const currentUrl = await webdriver.getCurrentUrl()
|
||||
return currentUrl.includes('/argumentForm')
|
||||
}),
|
||||
DEFAULT_UI_WAIT_MS
|
||||
)
|
||||
url = await webdriver.getCurrentUrl()
|
||||
} else {
|
||||
await waitForDashboardLoaded()
|
||||
url = await webdriver.getCurrentUrl()
|
||||
}
|
||||
}
|
||||
|
||||
if (url.includes('/argumentForm')) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"language-dialog.title": "Sprache auswählen",
|
||||
"login-button": "Login",
|
||||
"logs.action": "Aktion",
|
||||
"logs.action-group-limits": "gleichzeitig: {concurrent}, Warteschlangengröße: {queueSize}",
|
||||
"logs.back-to-list": "Zurück zur Liste",
|
||||
"logs.blocked": "Blockiert",
|
||||
"logs.calendar": "Kalender",
|
||||
|
|
@ -49,7 +50,6 @@
|
|||
"logs.queue-entity": "Entität",
|
||||
"logs.queue-group-active": "{active} aktiv (max. {max})",
|
||||
"logs.queue-group-active-unlimited": "{active} aktiv",
|
||||
"logs.queue-group-limit": "Limit {max}, wartend {queued}",
|
||||
"logs.queue-page-description": "Aktive und wartende Ausführungen, nach Aktionsgruppe gruppiert. Einträge ohne Berechtigung werden ausgeblendet.",
|
||||
"logs.queue-position": "#{position}",
|
||||
"logs.queue-running": "Läuft",
|
||||
|
|
@ -103,6 +103,7 @@
|
|||
"language-dialog.title": "Select Language",
|
||||
"login-button": "Login",
|
||||
"logs.action": "Action",
|
||||
"logs.action-group-limits": "concurrent: {concurrent}, queue size: {queueSize}",
|
||||
"logs.back-to-list": "Back to List",
|
||||
"logs.blocked": "Blocked",
|
||||
"logs.calendar": "Calendar",
|
||||
|
|
@ -128,7 +129,6 @@
|
|||
"logs.queue-entity": "Entity",
|
||||
"logs.queue-group-active": "{active} active (max {max})",
|
||||
"logs.queue-group-active-unlimited": "{active} active",
|
||||
"logs.queue-group-limit": "limit {max}, queued {queued}",
|
||||
"logs.queue-page-description": "Active and waiting executions grouped by action group. Entries you are not permitted to view are hidden.",
|
||||
"logs.queue-position": "#{position}",
|
||||
"logs.queue-running": "Running",
|
||||
|
|
@ -182,6 +182,7 @@
|
|||
"language-dialog.title": "Seleccionar idioma",
|
||||
"login-button": "Iniciar sesión",
|
||||
"logs.action": "Acción",
|
||||
"logs.action-group-limits": "simultáneas: {concurrent}, tamaño de cola: {queueSize}",
|
||||
"logs.back-to-list": "Volver a la Lista",
|
||||
"logs.blocked": "Bloqueado",
|
||||
"logs.calendar": "Calendario",
|
||||
|
|
@ -199,7 +200,6 @@
|
|||
"logs.queue-entity": "Entidad",
|
||||
"logs.queue-group-active": "{active} activas (máx. {max})",
|
||||
"logs.queue-group-active-unlimited": "{active} activas",
|
||||
"logs.queue-group-limit": "límite {max}, en espera {queued}",
|
||||
"logs.queue-page-description": "Ejecuciones activas y en espera agrupadas por grupo de acciones. Las entradas que no puede ver se ocultan.",
|
||||
"logs.queue-position": "#{position}",
|
||||
"logs.queue-running": "En ejecución",
|
||||
|
|
@ -253,6 +253,7 @@
|
|||
"language-dialog.title": "Seleziona lingua",
|
||||
"login-button": "Login",
|
||||
"logs.action": "Azione",
|
||||
"logs.action-group-limits": "simultanei: {concurrent}, dimensione coda: {queueSize}",
|
||||
"logs.back-to-list": "Torna all'Elenco",
|
||||
"logs.blocked": "Bloccato",
|
||||
"logs.calendar": "Calendario",
|
||||
|
|
@ -270,7 +271,6 @@
|
|||
"logs.queue-entity": "Entità",
|
||||
"logs.queue-group-active": "{active} attive (max {max})",
|
||||
"logs.queue-group-active-unlimited": "{active} attive",
|
||||
"logs.queue-group-limit": "limite {max}, in coda {queued}",
|
||||
"logs.queue-page-description": "Esecuzioni attive e in attesa raggruppate per gruppo di azioni. Le voci non autorizzate sono nascoste.",
|
||||
"logs.queue-position": "#{position}",
|
||||
"logs.queue-running": "In esecuzione",
|
||||
|
|
@ -324,6 +324,7 @@
|
|||
"language-dialog.title": "选择语言",
|
||||
"login-button": "登录",
|
||||
"logs.action": "动作",
|
||||
"logs.action-group-limits": "并发:{concurrent},队列大小:{queueSize}",
|
||||
"logs.back-to-list": "返回列表",
|
||||
"logs.blocked": "阻塞",
|
||||
"logs.calendar": "日历",
|
||||
|
|
@ -341,7 +342,6 @@
|
|||
"logs.queue-entity": "实体",
|
||||
"logs.queue-group-active": "{active} 个活动(上限 {max})",
|
||||
"logs.queue-group-active-unlimited": "{active} 个活动",
|
||||
"logs.queue-group-limit": "上限 {max},排队 {queued}",
|
||||
"logs.queue-page-description": "按动作组分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。",
|
||||
"logs.queue-position": "第 {position} 位",
|
||||
"logs.queue-running": "运行中",
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ translations:
|
|||
logs.queue-title: Ausführungswarteschlange
|
||||
logs.queue-page-description: Aktive und wartende Ausführungen, nach Aktionsgruppe gruppiert. Einträge ohne Berechtigung werden ausgeblendet.
|
||||
logs.queue-default-group: Standard
|
||||
logs.queue-group-limit: "Limit {max}, wartend {queued}"
|
||||
logs.action-group-limits: "gleichzeitig: {concurrent}, Warteschlangengröße: {queueSize}"
|
||||
logs.queue-group-active-unlimited: "{active} aktiv"
|
||||
logs.queue-empty: Derzeit gibt es keine aktiven oder wartenden Ausführungen.
|
||||
logs.queue-group-active: "{active} aktiv (max. {max})"
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ translations:
|
|||
logs.queue-page-description: Active and waiting executions grouped by action group. Entries you are not permitted to view are hidden.
|
||||
logs.queue-empty: There are no active or waiting executions right now.
|
||||
logs.queue-default-group: Default
|
||||
logs.queue-group-limit: "limit {max}, queued {queued}"
|
||||
logs.action-group-limits: "concurrent: {concurrent}, queue size: {queueSize}"
|
||||
logs.queue-group-active: "{active} active (max {max})"
|
||||
logs.queue-group-active-unlimited: "{active} active"
|
||||
logs.queue-waiting: Waiting
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ translations:
|
|||
logs.queue-title: Cola de ejecución
|
||||
logs.queue-page-description: Ejecuciones activas y en espera agrupadas por grupo de acciones. Las entradas que no puede ver se ocultan.
|
||||
logs.queue-default-group: Predeterminado
|
||||
logs.queue-group-limit: "límite {max}, en espera {queued}"
|
||||
logs.action-group-limits: "simultáneas: {concurrent}, tamaño de cola: {queueSize}"
|
||||
logs.queue-group-active-unlimited: "{active} activas"
|
||||
logs.queue-empty: No hay ejecuciones activas o en espera en este momento.
|
||||
logs.queue-group-active: "{active} activas (máx. {max})"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ translations:
|
|||
logs.queue-title: Coda di esecuzione
|
||||
logs.queue-page-description: Esecuzioni attive e in attesa raggruppate per gruppo di azioni. Le voci non autorizzate sono nascoste.
|
||||
logs.queue-default-group: Predefinito
|
||||
logs.queue-group-limit: "limite {max}, in coda {queued}"
|
||||
logs.action-group-limits: "simultanei: {concurrent}, dimensione coda: {queueSize}"
|
||||
logs.queue-group-active-unlimited: "{active} attive"
|
||||
logs.queue-empty: Non ci sono esecuzioni attive o in attesa al momento.
|
||||
logs.queue-group-active: "{active} attive (max {max})"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ translations:
|
|||
logs.queue-title: 执行队列
|
||||
logs.queue-page-description: 按动作组分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。
|
||||
logs.queue-default-group: 默认
|
||||
logs.queue-group-limit: "上限 {max},排队 {queued}"
|
||||
logs.action-group-limits: "并发:{concurrent},队列大小:{queueSize}"
|
||||
logs.queue-group-active-unlimited: "{active} 个活动"
|
||||
logs.queue-empty: 当前没有正在运行或等待中的执行。
|
||||
logs.queue-group-active: "{active} 个活动(上限 {max})"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ message Action {
|
|||
bool justification = 16;
|
||||
bool has_running_instance = 17;
|
||||
bool has_queued_instance = 18;
|
||||
repeated ActionGroupMembership groups = 19;
|
||||
}
|
||||
|
||||
message ActionGroupMembership {
|
||||
string name = 1;
|
||||
int32 max_concurrent = 2;
|
||||
int32 queue_size = 3;
|
||||
}
|
||||
|
||||
message ActionWebhookExecHint {
|
||||
|
|
@ -209,6 +216,7 @@ message ExecutionQueueGroup {
|
|||
int32 active_count = 4;
|
||||
repeated ExecutionQueueAction actions = 5;
|
||||
int32 queued_count = 6;
|
||||
int32 queue_size = 7;
|
||||
}
|
||||
|
||||
message GetExecutionQueueResponse {
|
||||
|
|
@ -241,8 +249,16 @@ message ExecutionStatusRequest {
|
|||
string action_id = 2;
|
||||
}
|
||||
|
||||
message DashboardNavigationTarget {
|
||||
string title = 1;
|
||||
string entity_type = 2;
|
||||
string entity_key = 3;
|
||||
string path = 4;
|
||||
}
|
||||
|
||||
message ExecutionStatusResponse {
|
||||
LogEntry log_entry = 1;
|
||||
repeated DashboardNavigationTarget back_to_dashboards = 2;
|
||||
}
|
||||
|
||||
message WhoAmIRequest {}
|
||||
|
|
@ -406,6 +422,7 @@ message GetActionBindingRequest {
|
|||
|
||||
message GetActionBindingResponse {
|
||||
Action action = 1;
|
||||
repeated DashboardNavigationTarget back_to_dashboards = 2;
|
||||
}
|
||||
|
||||
message GetEntitiesRequest {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -450,6 +450,32 @@ func (api *oliveTinAPI) getExecutionStatusByRequest(msg *apiv1.ExecutionStatusRe
|
|||
return getMostRecentExecutionStatusByActionId(api, msg.ActionId)
|
||||
}
|
||||
|
||||
func dashboardNavigationTargetsToPb(targets []executor.DashboardNavigationTarget) []*apiv1.DashboardNavigationTarget {
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]*apiv1.DashboardNavigationTarget, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
result = append(result, &apiv1.DashboardNavigationTarget{
|
||||
Title: target.Title,
|
||||
EntityType: target.EntityType,
|
||||
EntityKey: target.EntityKey,
|
||||
Path: target.Path,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) executionStatusBackToDashboards(ile *executor.InternalLogEntry) []*apiv1.DashboardNavigationTarget {
|
||||
if ile == nil || ile.Binding == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dashboardNavigationTargetsToPb(ile.Binding.OnDashboards)
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
|
||||
user := auth.UserFromApiCall(ctx, req, api.cfg)
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
|
|
@ -460,7 +486,8 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
|
|||
return nil, err
|
||||
}
|
||||
res := &apiv1.ExecutionStatusResponse{
|
||||
LogEntry: api.internalLogEntryToPb(ile, user),
|
||||
LogEntry: api.internalLogEntryToPb(ile, user),
|
||||
BackToDashboards: api.executionStatusBackToDashboards(ile),
|
||||
}
|
||||
return connect.NewResponse(res), nil
|
||||
}
|
||||
|
|
@ -531,7 +558,8 @@ func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedU
|
|||
}
|
||||
|
||||
return &apiv1.GetActionBindingResponse{
|
||||
Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
|
||||
Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
|
||||
BackToDashboards: dashboardNavigationTargetsToPb(binding.OnDashboards),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -995,6 +1023,7 @@ func (api *oliveTinAPI) sendEventStreamHeartbeats(client *streamingClient) {
|
|||
defer close(client.heartbeatDone)
|
||||
|
||||
if !api.sendEventStreamHeartbeat(client) {
|
||||
go api.removeClient(client)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1009,6 +1038,7 @@ func (api *oliveTinAPI) runEventStreamHeartbeatLoop(client *streamingClient, tic
|
|||
return
|
||||
}
|
||||
if !api.sendEventStreamHeartbeat(client) {
|
||||
go api.removeClient(client)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -1554,18 +1584,43 @@ func (api *oliveTinAPI) restartActionLogEntry(executionTrackingId string) (*exec
|
|||
return execReqLogEntry, nil
|
||||
}
|
||||
|
||||
func newServer(ex *executor.Executor) *oliveTinAPI {
|
||||
server := oliveTinAPI{}
|
||||
server.cfg = ex.Cfg
|
||||
server.executor = ex
|
||||
server.streamingClients = make(map[*streamingClient]struct{})
|
||||
var (
|
||||
executorListenersMu sync.Mutex
|
||||
executorListeners = map[*executor.Executor]*oliveTinAPI{}
|
||||
)
|
||||
|
||||
ex.AddListener(&server)
|
||||
return &server
|
||||
// RegisterExecutorListener registers the API server as an executor listener during startup.
|
||||
// Call this before background goroutines that may trigger RebuildActionMap.
|
||||
func RegisterExecutorListener(ex *executor.Executor) {
|
||||
ensureExecutorListener(ex)
|
||||
}
|
||||
|
||||
func ensureExecutorListener(ex *executor.Executor) *oliveTinAPI {
|
||||
executorListenersMu.Lock()
|
||||
defer executorListenersMu.Unlock()
|
||||
|
||||
if server, ok := executorListeners[ex]; ok {
|
||||
return server
|
||||
}
|
||||
|
||||
server := newServer(ex)
|
||||
executorListeners[ex] = server
|
||||
return server
|
||||
}
|
||||
|
||||
func newServer(ex *executor.Executor) *oliveTinAPI {
|
||||
server := &oliveTinAPI{
|
||||
cfg: ex.Cfg,
|
||||
executor: ex,
|
||||
streamingClients: make(map[*streamingClient]struct{}),
|
||||
}
|
||||
|
||||
ex.AddListener(server)
|
||||
return server
|
||||
}
|
||||
|
||||
func GetNewHandler(ex *executor.Executor) (string, http.Handler) {
|
||||
server := newServer(ex)
|
||||
server := ensureExecutorListener(ex)
|
||||
|
||||
jsonOpt := connectproto.WithJSON(
|
||||
protojson.MarshalOptions{
|
||||
|
|
|
|||
|
|
@ -229,10 +229,39 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
|
|||
applyActiveBindingStateToAction(&btn, binding.ID, rr.activeBindingStates)
|
||||
applyActionExecTriggers(&btn, action)
|
||||
btn.Arguments = buildActionArguments(action, binding.Entity)
|
||||
btn.Groups = buildActionGroups(action, rr.cfg)
|
||||
|
||||
return &btn
|
||||
}
|
||||
|
||||
func buildActionGroups(action *config.Action, cfg *config.Config) []*apiv1.ActionGroupMembership {
|
||||
if action == nil || len(action.Groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := make([]*apiv1.ActionGroupMembership, 0, len(action.Groups))
|
||||
|
||||
for _, name := range action.Groups {
|
||||
groups = append(groups, actionGroupMembershipFromConfig(name, cfg))
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func actionGroupMembershipFromConfig(name string, cfg *config.Config) *apiv1.ActionGroupMembership {
|
||||
membership := &apiv1.ActionGroupMembership{Name: name}
|
||||
|
||||
group, found := cfg.ActionGroups[name]
|
||||
if !found || group == nil || group.MaxConcurrent < 1 {
|
||||
return membership
|
||||
}
|
||||
|
||||
membership.MaxConcurrent = int32(group.MaxConcurrent)
|
||||
membership.QueueSize = int32(group.QueueSize)
|
||||
|
||||
return membership
|
||||
}
|
||||
|
||||
func buildChoices(arg config.ActionArgument) []*apiv1.ActionArgumentChoice {
|
||||
if arg.Entity != "" && len(arg.Choices) == 1 {
|
||||
return buildChoicesEntity(arg.Choices[0], arg.Entity)
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ func newExecutionQueueGroup(name string, cfg *config.Config) *apiv1.ExecutionQue
|
|||
|
||||
group.Icon = actionGroup.Icon
|
||||
group.MaxConcurrent = int32(actionGroup.MaxConcurrent)
|
||||
group.QueueSize = int32(actionGroup.QueueSize)
|
||||
return group
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ func TestGetExecutionQueueGroupsByActionGroup(t *testing.T) {
|
|||
require.NotNil(t, defaultGroup)
|
||||
|
||||
assert.Equal(t, int32(2), deployGroup.MaxConcurrent)
|
||||
assert.Equal(t, int32(5), deployGroup.QueueSize)
|
||||
assert.Equal(t, "💾", deployGroup.Icon)
|
||||
assert.Equal(t, int32(2), deployGroup.ActiveCount)
|
||||
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, 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
|
||||
}
|
||||
|
||||
if binding.IsOnDashboard {
|
||||
if binding.IsOnConfiguredDashboard() {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ type Action struct {
|
|||
// ActionGroup defines shared limits and metadata for a set of actions.
|
||||
type ActionGroup struct {
|
||||
MaxConcurrent int `koanf:"maxConcurrent"`
|
||||
QueueSize int `koanf:"queueSize"`
|
||||
Icon string `koanf:"icon"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -219,12 +219,18 @@ func appendUniqueString(out []string, seen map[string]struct{}, value string) []
|
|||
return append(out, value)
|
||||
}
|
||||
|
||||
const defaultActionGroupQueueSize = 5
|
||||
|
||||
func (cfg *Config) sanitizeActionGroups() {
|
||||
for _, group := range cfg.ActionGroups {
|
||||
if group == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if group.QueueSize <= 0 {
|
||||
group.QueueSize = defaultActionGroupQueueSize
|
||||
}
|
||||
|
||||
group.Icon = lookupHTMLIcon(group.Icon, cfg.DefaultIconForActions)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,28 @@ func TestSanitizeActionGroupsResolvesIcons(t *testing.T) {
|
|||
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) {
|
||||
c := DefaultConfig()
|
||||
c.Actions = append(c.Actions, &Action{
|
||||
|
|
|
|||
|
|
@ -146,8 +146,17 @@ func redactExecArgs(execArgs []string, arguments []config.ActionArgument, argume
|
|||
return redacted
|
||||
}
|
||||
|
||||
func argumentSkipsValidation(arg *config.ActionArgument) bool {
|
||||
switch arg.Type {
|
||||
case "confirmation", "html":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func typecheckActionArgument(arg *config.ActionArgument, value string, action *config.Action) error {
|
||||
if arg.Type == "confirmation" {
|
||||
if argumentSkipsValidation(arg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -660,6 +660,20 @@ func TestTypecheckActionArgumentConfirmation(t *testing.T) {
|
|||
assert.Nil(t, err, "Confirmation type should always pass validation")
|
||||
}
|
||||
|
||||
func TestTypecheckActionArgumentHtmlWithoutName(t *testing.T) {
|
||||
action := config.Action{
|
||||
Title: "Delete old backups",
|
||||
Shell: "rm -rf /opt/oliveTinOldBackups/ && sleep 5",
|
||||
Arguments: []config.ActionArgument{
|
||||
{Type: "html", Title: "Description"},
|
||||
{Type: "confirmation", Title: "Are you sure?!"},
|
||||
},
|
||||
}
|
||||
|
||||
err := validateArguments(map[string]string{}, &action)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestParseCommandForReplacements(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -48,11 +48,11 @@ var (
|
|||
)
|
||||
|
||||
type ActionBinding struct {
|
||||
ID string
|
||||
Action *config.Action
|
||||
Entity *entities.Entity
|
||||
ConfigOrder int
|
||||
IsOnDashboard bool
|
||||
ID string
|
||||
Action *config.Action
|
||||
Entity *entities.Entity
|
||||
ConfigOrder int
|
||||
OnDashboards []DashboardNavigationTarget
|
||||
}
|
||||
|
||||
// Executor represents a helper class for executing commands. It's main method
|
||||
|
|
@ -69,7 +69,8 @@ type Executor struct {
|
|||
|
||||
Cfg *config.Config
|
||||
|
||||
listeners []listener
|
||||
listeners []listener
|
||||
listenersMu sync.RWMutex
|
||||
|
||||
chainOfCommand []executorStepFunc
|
||||
|
||||
|
|
@ -218,9 +219,19 @@ type listener interface {
|
|||
}
|
||||
|
||||
func (e *Executor) AddListener(m listener) {
|
||||
e.listenersMu.Lock()
|
||||
defer e.listenersMu.Unlock()
|
||||
e.listeners = append(e.listeners, m)
|
||||
}
|
||||
|
||||
func (e *Executor) copyListeners() []listener {
|
||||
e.listenersMu.RLock()
|
||||
defer e.listenersMu.RUnlock()
|
||||
out := make([]listener, len(e.listeners))
|
||||
copy(out, e.listeners)
|
||||
return out
|
||||
}
|
||||
|
||||
// getPagingStartIndex calculates the starting index for log pagination.
|
||||
// Parameters:
|
||||
//
|
||||
|
|
@ -641,6 +652,10 @@ func (e *Executor) registerOrQueueRequest(req *ExecutionRequest, wg *sync.WaitGr
|
|||
}
|
||||
|
||||
func (e *Executor) finishIfConcurrencyBlocked(req *ExecutionRequest) bool {
|
||||
if actionNeedsGroupLimit(req) {
|
||||
return false
|
||||
}
|
||||
|
||||
if stepConcurrencyCheck(req) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -663,7 +678,11 @@ func (e *Executor) queueRequestAfterACL(req *ExecutionRequest, wg *sync.WaitGrou
|
|||
return true, false
|
||||
}
|
||||
|
||||
e.queueRequest(req, wg)
|
||||
if e.queueRequest(req, wg) {
|
||||
e.finishExecChain(req)
|
||||
return true, false
|
||||
}
|
||||
|
||||
notifyListenersStarted(req)
|
||||
|
||||
return false, true
|
||||
|
|
@ -708,6 +727,10 @@ func getConcurrentCount(req *ExecutionRequest) int {
|
|||
}
|
||||
|
||||
func stepConcurrencyCheck(req *ExecutionRequest) bool {
|
||||
if actionNeedsGroupLimit(req) {
|
||||
return true
|
||||
}
|
||||
|
||||
concurrentCount := getConcurrentCount(req)
|
||||
|
||||
// Note that the current execution is counted int the logs, so when checking we +1
|
||||
|
|
@ -1040,13 +1063,13 @@ func stepLogFinish(req *ExecutionRequest) bool {
|
|||
}
|
||||
|
||||
func notifyListenersFinished(req *ExecutionRequest) {
|
||||
for _, listener := range req.executor.listeners {
|
||||
for _, listener := range req.executor.copyListeners() {
|
||||
listener.OnExecutionFinished(req.logEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func notifyListenersStarted(req *ExecutionRequest) {
|
||||
for _, listener := range req.executor.listeners {
|
||||
for _, listener := range req.executor.copyListeners() {
|
||||
listener.OnExecutionStarted(req.logEntry)
|
||||
}
|
||||
}
|
||||
|
|
@ -1067,7 +1090,7 @@ type OutputStreamer struct {
|
|||
}
|
||||
|
||||
func (ost *OutputStreamer) Write(o []byte) (n int, err error) {
|
||||
for _, listener := range ost.Req.executor.listeners {
|
||||
for _, listener := range ost.Req.executor.copyListeners() {
|
||||
listener.OnOutputChunk(o, ost.Req.TrackingID)
|
||||
}
|
||||
|
||||
|
|
@ -1095,6 +1118,13 @@ func buildEnv(args map[string]string) []string {
|
|||
return ret
|
||||
}
|
||||
|
||||
func commandExitCode(cmd *exec.Cmd) int {
|
||||
if cmd == nil || cmd.ProcessState == nil {
|
||||
return -1
|
||||
}
|
||||
return cmd.ProcessState.ExitCode()
|
||||
}
|
||||
|
||||
func stepExec(req *ExecutionRequest) bool {
|
||||
ctx, cancel := newTimeoutContext(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second, req.executor)
|
||||
defer cancel()
|
||||
|
|
@ -1115,7 +1145,7 @@ func stepExec(req *ExecutionRequest) bool {
|
|||
ctx.setProcess(cmd.Process)
|
||||
waiterr := cmd.Wait()
|
||||
req.mutateLogEntry(func(entry *InternalLogEntry) {
|
||||
entry.ExitCode = int32(cmd.ProcessState.ExitCode())
|
||||
entry.ExitCode = int32(commandExitCode(cmd))
|
||||
entry.Output = streamer.String()
|
||||
})
|
||||
|
||||
|
|
@ -1206,7 +1236,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
|
|||
}
|
||||
|
||||
req.mutateLogEntry(func(entry *InternalLogEntry) {
|
||||
entry.Output += fmt.Sprintf("Your shellAfterCompleted exited with code %v\n", cmd.ProcessState.ExitCode())
|
||||
entry.Output += fmt.Sprintf("Your shellAfterCompleted exited with code %v\n", commandExitCode(cmd))
|
||||
entry.Output += "OliveTin::shellAfterCompleted output complete\n"
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package executor
|
|||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
|
|
@ -37,8 +36,8 @@ func (e *Executor) FindBindingWithNoEntity(action *config.Action) *ActionBinding
|
|||
}
|
||||
|
||||
type RebuildActionMapRequest struct {
|
||||
Cfg *config.Config
|
||||
DashboardActionTitles []string
|
||||
Cfg *config.Config
|
||||
dashboardTargets *dashboardTargetIndex
|
||||
}
|
||||
|
||||
func validateArgumentDefaults(cfg *config.Config) {
|
||||
|
|
@ -81,16 +80,10 @@ func (e *Executor) RebuildActionMap() {
|
|||
clear(e.MapActionBindings)
|
||||
|
||||
req := &RebuildActionMapRequest{
|
||||
Cfg: e.Cfg,
|
||||
DashboardActionTitles: make([]string, 0),
|
||||
Cfg: e.Cfg,
|
||||
dashboardTargets: buildDashboardTargetIndex(e.Cfg),
|
||||
}
|
||||
|
||||
findDashboardActionTitles(req)
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"titles": req.DashboardActionTitles,
|
||||
}).Trace("dashboardActionTitles")
|
||||
|
||||
for configOrder, action := range e.Cfg.Actions {
|
||||
if action.Entity != "" {
|
||||
registerActionsFromEntities(e, configOrder, action.Entity, action, req)
|
||||
|
|
@ -101,47 +94,20 @@ func (e *Executor) RebuildActionMap() {
|
|||
|
||||
e.MapActionBindingsLock.Unlock()
|
||||
|
||||
for _, l := range e.listeners {
|
||||
for _, l := range e.copyListeners() {
|
||||
l.OnActionMapRebuilt()
|
||||
}
|
||||
}
|
||||
|
||||
func findDashboardActionTitles(req *RebuildActionMapRequest) {
|
||||
for _, dashboard := range req.Cfg.Dashboards {
|
||||
recurseDashboardForActionTitles(dashboard, req)
|
||||
}
|
||||
}
|
||||
|
||||
//gocyclo:ignore
|
||||
func recurseDashboardForActionTitles(component *config.DashboardComponent, req *RebuildActionMapRequest) {
|
||||
for _, sub := range component.Contents {
|
||||
if sub.InlineAction != nil {
|
||||
title := sub.Title
|
||||
if title == "" {
|
||||
title = sub.InlineAction.Title
|
||||
}
|
||||
if title != "" {
|
||||
req.DashboardActionTitles = append(req.DashboardActionTitles, title)
|
||||
}
|
||||
} else if sub.Type == "link" || sub.Type == "" {
|
||||
req.DashboardActionTitles = append(req.DashboardActionTitles, sub.Title)
|
||||
}
|
||||
|
||||
if len(sub.Contents) > 0 {
|
||||
recurseDashboardForActionTitles(sub, req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerAction(e *Executor, configOrder int, action *config.Action, req *RebuildActionMapRequest) {
|
||||
bindingId := generateActionBindingId(action, "")
|
||||
|
||||
e.MapActionBindings[bindingId] = &ActionBinding{
|
||||
ID: bindingId,
|
||||
Action: action,
|
||||
Entity: nil,
|
||||
ConfigOrder: configOrder,
|
||||
IsOnDashboard: slices.Contains(req.DashboardActionTitles, action.Title),
|
||||
ID: bindingId,
|
||||
Action: action,
|
||||
Entity: nil,
|
||||
ConfigOrder: configOrder,
|
||||
OnDashboards: resolveOnDashboards(req.dashboardTargets, action.Title, ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,11 +121,11 @@ func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action,
|
|||
virtualActionId := generateActionBindingId(tpl, ent.UniqueKey)
|
||||
|
||||
e.MapActionBindings[virtualActionId] = &ActionBinding{
|
||||
ID: virtualActionId,
|
||||
Action: tpl,
|
||||
Entity: ent,
|
||||
ConfigOrder: configOrder,
|
||||
IsOnDashboard: slices.Contains(req.DashboardActionTitles, tpl.Title),
|
||||
ID: virtualActionId,
|
||||
Action: tpl,
|
||||
Entity: ent,
|
||||
ConfigOrder: configOrder,
|
||||
OnDashboards: resolveOnDashboards(req.dashboardTargets, tpl.Title, ent.UniqueKey),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
name string
|
||||
maxConcurrent int
|
||||
queueSize int
|
||||
}
|
||||
|
||||
type queuedExecution struct {
|
||||
|
|
@ -45,7 +46,11 @@ func groupLimitFromConfig(cfg *config.Config, groupName string) (groupLimit, boo
|
|||
return groupLimit{}, false
|
||||
}
|
||||
|
||||
return groupLimit{name: groupName, maxConcurrent: group.MaxConcurrent}, true
|
||||
return groupLimit{
|
||||
name: groupName,
|
||||
maxConcurrent: group.MaxConcurrent,
|
||||
queueSize: group.QueueSize,
|
||||
}, true
|
||||
}
|
||||
|
||||
func actionNeedsGroupLimit(req *ExecutionRequest) bool {
|
||||
|
|
@ -79,6 +84,64 @@ func (e *Executor) countActiveInGroupLocked(groupName string) int {
|
|||
return count
|
||||
}
|
||||
|
||||
func (e *Executor) countQueuedInGroupLocked(groupName string) int {
|
||||
count := 0
|
||||
|
||||
for _, logEntry := range e.logs {
|
||||
if queuedLogEntryInGroup(logEntry, groupName) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func queuedLogEntryInGroup(logEntry *InternalLogEntry, groupName string) bool {
|
||||
if !logEntryIsBound(logEntry) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !logEntry.Queued || logEntry.ExecutionFinished {
|
||||
return false
|
||||
}
|
||||
|
||||
return actionInGroup(logEntry.Binding.Action, groupName)
|
||||
}
|
||||
|
||||
func logEntryIsBound(logEntry *InternalLogEntry) bool {
|
||||
return logEntry != nil && logEntry.Binding != nil && logEntry.Binding.Action != nil
|
||||
}
|
||||
|
||||
func groupIsAtActiveCapacity(activeCount int, limit groupLimit) bool {
|
||||
return activeCount >= (limit.maxConcurrent + 1)
|
||||
}
|
||||
|
||||
func (e *Executor) fullGroupWithQueueExceededLocked(req *ExecutionRequest) string {
|
||||
for _, limit := range actionGroupLimits(req) {
|
||||
if !groupIsAtActiveCapacity(e.countActiveInGroupLocked(limit.name), limit) {
|
||||
continue
|
||||
}
|
||||
|
||||
if e.countQueuedInGroupLocked(limit.name) >= limit.queueSize {
|
||||
return limit.name
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *Executor) blockRequestForGroupQueue(req *ExecutionRequest, groupName string) {
|
||||
log.WithFields(log.Fields{
|
||||
"actionTitle": req.logEntry.ActionTitle,
|
||||
"groupName": groupName,
|
||||
}).Warnf("Blocked from executing due to action group queue limit")
|
||||
|
||||
req.mutateLogEntry(func(entry *InternalLogEntry) {
|
||||
entry.Output = fmt.Sprintf("Blocked from executing due to action group %q queue limit", groupName)
|
||||
entry.Blocked = true
|
||||
})
|
||||
}
|
||||
|
||||
func logEntryIsActiveInGroup(logEntry *InternalLogEntry, groupName string) bool {
|
||||
if inactiveLogEntry(logEntry) {
|
||||
return false
|
||||
|
|
@ -143,16 +206,26 @@ func firstFullGroupNameLocked(e *Executor, req *ExecutionRequest) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) {
|
||||
func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) bool {
|
||||
e.groupQueueMu.Lock()
|
||||
|
||||
var groupName string
|
||||
e.logmutex.RLock()
|
||||
groupName := e.fullGroupWithQueueExceededLocked(req)
|
||||
e.logmutex.RUnlock()
|
||||
|
||||
if groupName != "" {
|
||||
e.groupQueueMu.Unlock()
|
||||
e.blockRequestForGroupQueue(req, groupName)
|
||||
return true
|
||||
}
|
||||
|
||||
var waitingForGroup string
|
||||
|
||||
req.mutateLogEntry(func(entry *InternalLogEntry) {
|
||||
groupName = firstFullGroupNameLocked(e, req)
|
||||
waitingForGroup = firstFullGroupNameLocked(e, req)
|
||||
entry.Queued = true
|
||||
entry.QueuedForGroup = groupName
|
||||
entry.Output = fmt.Sprintf("Queued waiting for action group %q", groupName)
|
||||
entry.QueuedForGroup = waitingForGroup
|
||||
entry.Output = fmt.Sprintf("Queued waiting for action group %q", waitingForGroup)
|
||||
})
|
||||
|
||||
e.groupQueue = append(e.groupQueue, &queuedExecution{req: req, wg: wg})
|
||||
|
|
@ -162,8 +235,10 @@ func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) {
|
|||
|
||||
log.WithFields(log.Fields{
|
||||
"actionTitle": req.logEntry.ActionTitle,
|
||||
"groupName": groupName,
|
||||
"groupName": waitingForGroup,
|
||||
}).Infof("Action queued due to action group concurrency limit")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *Executor) drainGroupQueue() {
|
||||
|
|
|
|||
|
|
@ -236,20 +236,19 @@ func TestPerActionConcurrencyStillBlocksWithoutQueue(t *testing.T) {
|
|||
assert.False(t, snapshot.Queued)
|
||||
}
|
||||
|
||||
func TestPerActionConcurrencyBlocksSameBindingBeforeGroupQueue(t *testing.T) {
|
||||
func TestGroupedSameBindingQueuesWhenGroupFull(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
action := &config.Action{
|
||||
Title: "Single binding grouped",
|
||||
Shell: "sleep 1",
|
||||
MaxConcurrent: 1,
|
||||
Groups: []string{"unity"},
|
||||
Title: "Single binding grouped",
|
||||
Shell: "sleep 1",
|
||||
Groups: []string{"unity"},
|
||||
}
|
||||
|
||||
e, cfg := testGroupExecutor(
|
||||
[]*config.Action{action},
|
||||
map[string]*config.ActionGroup{
|
||||
"unity": {MaxConcurrent: 1},
|
||||
"unity": {MaxConcurrent: 1, QueueSize: 5},
|
||||
},
|
||||
)
|
||||
binding := e.FindBindingWithNoEntity(action)
|
||||
|
|
@ -268,15 +267,117 @@ func TestPerActionConcurrencyBlocksSameBindingBeforeGroupQueue(t *testing.T) {
|
|||
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
snapshot, ok := e.SnapshotLog(tracking2)
|
||||
return ok && snapshot.Queued
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
wg1.Wait()
|
||||
wg2.Wait()
|
||||
|
||||
snapshot, ok := e.SnapshotLog(tracking2)
|
||||
require.True(t, ok)
|
||||
assert.True(t, snapshot.Blocked)
|
||||
assert.False(t, snapshot.Blocked)
|
||||
}
|
||||
|
||||
func TestGroupAllowsTwoConcurrentSameBinding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
action := &config.Action{
|
||||
Title: "Long running action",
|
||||
Shell: "sleep 1",
|
||||
Groups: []string{"con2queue10"},
|
||||
}
|
||||
|
||||
e, cfg := testGroupExecutor(
|
||||
[]*config.Action{action},
|
||||
map[string]*config.ActionGroup{
|
||||
"con2queue10": {MaxConcurrent: 2, QueueSize: 10},
|
||||
},
|
||||
)
|
||||
binding := e.FindBindingWithNoEntity(action)
|
||||
|
||||
wg1, tracking1 := e.ExecRequest(&ExecutionRequest{
|
||||
Binding: binding,
|
||||
Cfg: cfg,
|
||||
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
|
||||
})
|
||||
|
||||
waitUntilExecutionStarted(t, e, tracking1)
|
||||
|
||||
wg2, tracking2 := e.ExecRequest(&ExecutionRequest{
|
||||
Binding: binding,
|
||||
Cfg: cfg,
|
||||
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
snapshot, ok := e.SnapshotLog(tracking2)
|
||||
return ok && snapshot.ExecutionStarted && !snapshot.Queued && !snapshot.Blocked
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
wg1.Wait()
|
||||
wg2.Wait()
|
||||
|
||||
snapshot, ok := e.SnapshotLog(tracking2)
|
||||
require.True(t, ok)
|
||||
assert.False(t, snapshot.Blocked)
|
||||
assert.False(t, snapshot.Queued)
|
||||
}
|
||||
|
||||
func TestGroupQueuesThirdAndBlocksWhenQueueFull(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
action := &config.Action{
|
||||
Title: "Long running action",
|
||||
Shell: "sleep 1",
|
||||
Groups: []string{"con2queue10"},
|
||||
}
|
||||
|
||||
e, cfg := testGroupExecutor(
|
||||
[]*config.Action{action},
|
||||
map[string]*config.ActionGroup{
|
||||
"con2queue10": {MaxConcurrent: 2, QueueSize: 2},
|
||||
},
|
||||
)
|
||||
binding := e.FindBindingWithNoEntity(action)
|
||||
|
||||
wg1, tracking1 := e.ExecRequest(&ExecutionRequest{
|
||||
Binding: binding,
|
||||
Cfg: cfg,
|
||||
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
|
||||
})
|
||||
waitUntilExecutionStarted(t, e, tracking1)
|
||||
|
||||
wg2, tracking2 := e.ExecRequest(&ExecutionRequest{
|
||||
Binding: binding,
|
||||
Cfg: cfg,
|
||||
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
|
||||
})
|
||||
waitUntilExecutionStarted(t, e, tracking2)
|
||||
|
||||
trackings := []string{tracking1, tracking2}
|
||||
waitGroups := []*sync.WaitGroup{wg1, wg2}
|
||||
|
||||
for idx := 0; idx < 3; idx++ {
|
||||
wg, tracking := e.ExecRequest(&ExecutionRequest{
|
||||
Binding: binding,
|
||||
Cfg: cfg,
|
||||
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
|
||||
})
|
||||
trackings = append(trackings, tracking)
|
||||
waitGroups = append(waitGroups, wg)
|
||||
}
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return groupExecutionDistributionMatches(e, trackings, 2, 2, 1)
|
||||
}, 2*time.Second, 20*time.Millisecond)
|
||||
|
||||
for _, wg := range waitGroups {
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilExecutionStarted(t *testing.T, e *Executor, trackingID string) {
|
||||
t.Helper()
|
||||
|
||||
|
|
@ -390,6 +491,79 @@ func TestStartActionAndWaitWaitsForQueuedExecution(t *testing.T) {
|
|||
assert.Contains(t, snapshot.Output, "waited")
|
||||
}
|
||||
|
||||
func TestGroupQueueBlocksWhenQueueFull(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
actions := []*config.Action{
|
||||
{Title: "Hold 1", Shell: "sleep 1", Groups: []string{"unity"}},
|
||||
{Title: "Hold 2", Shell: "sleep 1", Groups: []string{"unity"}},
|
||||
{Title: "Hold 3", Shell: "sleep 1", Groups: []string{"unity"}},
|
||||
{Title: "Hold 4", Shell: "sleep 1", Groups: []string{"unity"}},
|
||||
}
|
||||
|
||||
e, cfg := testGroupExecutor(
|
||||
actions,
|
||||
map[string]*config.ActionGroup{
|
||||
"unity": {MaxConcurrent: 1, QueueSize: 2},
|
||||
},
|
||||
)
|
||||
|
||||
trackings, waitGroups := execAllGroupActions(t, e, cfg, actions)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Blocked }) == 1 &&
|
||||
countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Queued }) == 2 &&
|
||||
countSnapshots(e, trackings, isRunningSnapshot) == 1
|
||||
}, 2*time.Second, 20*time.Millisecond)
|
||||
|
||||
for _, wg := range waitGroups {
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func execAllGroupActions(t *testing.T, e *Executor, cfg *config.Config, actions []*config.Action) ([]string, []*sync.WaitGroup) {
|
||||
t.Helper()
|
||||
|
||||
trackings := make([]string, len(actions))
|
||||
waitGroups := make([]*sync.WaitGroup, len(actions))
|
||||
|
||||
for idx, action := range actions {
|
||||
wg, tracking := e.ExecRequest(&ExecutionRequest{
|
||||
Binding: e.FindBindingWithNoEntity(action),
|
||||
Cfg: cfg,
|
||||
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
|
||||
})
|
||||
trackings[idx] = tracking
|
||||
waitGroups[idx] = wg
|
||||
}
|
||||
|
||||
return trackings, waitGroups
|
||||
}
|
||||
|
||||
func groupExecutionDistributionMatches(e *Executor, trackings []string, wantRunning, wantQueued, wantBlocked int) bool {
|
||||
running := countSnapshots(e, trackings, isRunningSnapshot)
|
||||
queued := countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Queued })
|
||||
blocked := countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Blocked })
|
||||
return running == wantRunning && queued == wantQueued && blocked == wantBlocked
|
||||
}
|
||||
|
||||
func countSnapshots(e *Executor, trackings []string, matches func(LogEntrySnapshot) bool) int {
|
||||
count := 0
|
||||
|
||||
for _, tracking := range trackings {
|
||||
snapshot, ok := e.SnapshotLog(tracking)
|
||||
if ok && matches(snapshot) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func isRunningSnapshot(snapshot LogEntrySnapshot) bool {
|
||||
return snapshot.ExecutionStarted && !snapshot.ExecutionFinished
|
||||
}
|
||||
|
||||
func TestUnknownActionGroupReferenceWarnsAndSkipsLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/OliveTin/OliveTin/internal/api"
|
||||
"github.com/OliveTin/OliveTin/internal/auth"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
"github.com/OliveTin/OliveTin/internal/executor"
|
||||
|
|
@ -259,12 +260,14 @@ func main() {
|
|||
|
||||
executor.LoadLogsFromDisk()
|
||||
|
||||
api.RegisterExecutorListener(executor)
|
||||
entities.AddListener(executor.RebuildActionMap)
|
||||
|
||||
go onstartup.Execute(cfg, executor)
|
||||
go oncron.Schedule(cfg, executor)
|
||||
go onfileindir.WatchFilesInDirectory(cfg, executor)
|
||||
go oncalendarfile.Schedule(cfg, executor)
|
||||
|
||||
entities.AddListener(executor.RebuildActionMap)
|
||||
go entities.SetupEntityFileWatchers(cfg)
|
||||
|
||||
go updatecheck.StartUpdateChecker(cfg)
|
||||
|
|
|
|||
Loading…
Reference in New Issue